Merge branch 'develop' into fix/cw-6921-server-hmac-verification

This commit is contained in:
Vishnu Narayanan
2026-07-09 12:53:37 +05:30
committed by GitHub
195 changed files with 8014 additions and 182 deletions
+1 -1
View File
@@ -1030,7 +1030,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.7)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -2,6 +2,8 @@ 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)
@@ -10,6 +12,7 @@ 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,6 +6,7 @@ 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,
@@ -15,6 +16,8 @@ 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,6 +66,7 @@ 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],
@@ -74,4 +75,10 @@ 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,6 +6,8 @@ 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,27 +1,40 @@
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 = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
@participants = participant_ids_to_add.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
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 }
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 }
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
@@ -38,4 +51,11 @@ 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,12 +2,28 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
counts = if filtered_unread_counts_enabled?
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
else
unread_counts
end
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,6 +164,7 @@ 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,7 +2,6 @@ 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,8 +8,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue StandardError => e
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e
render_embedded_signup_error(e)
end
private
@@ -55,7 +57,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
def render_error_response(error)
def render_embedded_signup_error(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
@@ -2,6 +2,7 @@ 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
@@ -46,6 +47,16 @@ 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?
@@ -62,11 +73,15 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
expected_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,8 +1,15 @@
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
@@ -18,6 +25,9 @@ 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
@@ -31,8 +41,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(message)
render json: { error: message }, status: :unprocessable_entity
def render_could_not_create_error(error)
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
end
def render_payment_required(message)
@@ -59,4 +69,19 @@ 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,6 +11,8 @@ class Instagram::CallbacksController < ApplicationController
end
process_successful_authorization
rescue CustomExceptions::Inbox::LimitExceeded => e
handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -47,6 +49,14 @@ 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,11 +35,15 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
expected_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,6 +6,8 @@ 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
@@ -36,6 +38,14 @@ 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,10 +114,4 @@ 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
+10
View File
@@ -68,6 +68,8 @@ 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
@@ -82,10 +84,18 @@ 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?
@@ -6,9 +6,12 @@ class AssignableAgents extends ApiClient {
super('assignable_agents', { accountScoped: true });
}
get(inboxIds) {
get(inboxIds, { includeAgentBots = false } = {}) {
return axios.get(this.url, {
params: { inbox_ids: inboxIds },
params: {
inbox_ids: inboxIds,
...(includeAgentBots ? { include_agent_bots: true } : {}),
},
});
}
}
@@ -1,6 +1,10 @@
/* 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 });
@@ -21,6 +25,18 @@ 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();
@@ -62,9 +62,10 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId }) {
assignAgent({ conversationId, agentId, assigneeType }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
@@ -26,5 +26,15 @@ 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,11 +90,16 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -182,7 +182,8 @@ const handleCreateAssistant = () => {
</div>
</div>
<div class="flex gap-2">
<div class="flex items-center gap-2">
<slot name="headerActions" />
<slot name="search" />
<div
v-if="!showPaywall && buttonLabel"
@@ -0,0 +1,96 @@
<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>
@@ -0,0 +1,73 @@
<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>
@@ -0,0 +1,87 @@
<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>
@@ -0,0 +1,40 @@
<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>
@@ -0,0 +1,81 @@
<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>
@@ -0,0 +1,78 @@
<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>
@@ -0,0 +1,75 @@
<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_responses_index';
currentRouteName || 'captain_assistants_overview_index';
await fetchDataForRoute(targetRouteName, assistant.id);
@@ -75,6 +75,16 @@ 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;
@@ -199,6 +209,18 @@ 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(
@@ -226,14 +248,22 @@ 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: hasConversationUnreadCounts.value,
hasUnreadCounts: hasUnreadCountsForSection(section),
});
const getSortForSection = section =>
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
hasUnreadCounts: hasConversationUnreadCounts.value,
hasUnreadCounts: hasUnreadCountsForSection(section),
});
const updateSortPreference = (section, sortBy) => {
@@ -253,6 +283,7 @@ const sortedFolders = computed(() =>
sortSidebarItems(conversationCustomViews.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
labelKey: view => view.name,
unreadCountKey: view => getFolderUnreadCount.value(view.id),
})
);
@@ -342,6 +373,9 @@ 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'),
},
@@ -349,6 +383,9 @@ 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'),
},
@@ -357,6 +394,9 @@ 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'),
},
{
@@ -370,6 +410,9 @@ 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 }),
})),
},
@@ -440,6 +483,14 @@ 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,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import ChannelSelector from '../ChannelSelector.vue';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const props = defineProps({
channel: {
@@ -20,7 +21,10 @@ const hasFbConfigured = computed(() => {
});
const hasInstagramConfigured = computed(() => {
return window.chatwootConfig?.instagramAppId;
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.instagramAppId
);
});
const hasTiktokConfigured = computed(() => {
@@ -58,6 +62,7 @@ const isActive = computed(() => {
if (key === 'whatsapp_call') {
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
@@ -62,7 +62,10 @@ export default {
immediate: true,
handler(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
this.$store.dispatch('inboxAssignableAgents/fetch', {
inboxIds: [inboxId],
includeAgentBots: true,
});
}
},
},
@@ -2,6 +2,7 @@
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';
@@ -57,6 +58,10 @@ const showMetaSection = computed(() => {
);
});
const isAgentBotAssignee = computed(
() => props.chat?.meta?.assignee_type === 'AgentBot'
);
const hasSlaPolicyId = computed(
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
);
@@ -159,10 +164,15 @@ 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 truncate"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center gap-px truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
<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>
</span>
<CardPriorityIcon
:priority="chat.priority"
@@ -123,7 +123,13 @@ export default {
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_CONTACT')
}}</label>
</div>
<div v-if="currentChat.meta.assignee" class="flex items-center gap-2">
<div
v-if="
currentChat.meta.assignee &&
currentChat.meta.assignee_type !== 'AgentBot'
"
class="flex items-center gap-2"
>
<input
id="assignee"
v-model="selectedType"
@@ -33,10 +33,19 @@ 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,11 +26,12 @@ 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(() => allAgentsData),
'inboxAssignableAgents/getAssignableAgents': ref(getAssignableAgents),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -53,6 +54,24 @@ 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,9 +10,14 @@ 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) {
export function useAgentsList(
includeNoneAgent = true,
{ includeAgentBots = false } = {}
) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
@@ -39,7 +44,9 @@ export function useAgentsList(includeNoneAgent = true) {
* @type {import('vue').ComputedRef<Array>}
*/
const assignableAgents = computed(() => {
return inboxId.value ? assignable.value(inboxId.value) : [];
return inboxId.value
? assignable.value(inboxId.value, { includeAgentBots })
: [];
});
/**
@@ -78,3 +78,8 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
// Temporarily disables Instagram and WhatsApp inbox creation
// (WhatsApp embedded signup popup, Instagram OAuth, WhatsApp Call).
// Flip to false when the channels are brought back.
export const IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED = true;
+1
View File
@@ -47,6 +47,7 @@ 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,6 +17,13 @@ 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) {
@@ -25,6 +32,9 @@ 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,
@@ -140,7 +150,12 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onConversationUnreadCountChanged = () => {
this.refreshConversationUnreadCountsWithFilteredRetry();
};
refreshConversationUnreadCountsWithFilteredRetry = () => {
this.throttledFetchConversationUnreadCounts();
this.scheduleFilteredUnreadCountsRetry();
};
throttledFetchConversationUnreadCounts = () => {
@@ -171,6 +186,51 @@ 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;
@@ -189,6 +249,17 @@ 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;
@@ -212,6 +283,7 @@ class ActionCableConnector extends BaseActionCableConnector {
onConversationMentioned = data => {
this.app.$store.dispatch('addMentions', data);
this.scheduleMentionUnreadCountsFetch();
};
clearTimer = conversationId => {
@@ -273,6 +345,12 @@ 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.id === currentUser.id && (item.assignee_type || 'User') === 'User'
? {
...item,
availability_status: currentUser.accounts.find(
@@ -19,6 +19,7 @@ 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,6 +20,8 @@ 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,5 +1,6 @@
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: {
@@ -17,6 +18,9 @@ global.chatwootConfig = {
websocketURL: 'wss://test.chatwoot.com',
};
const mockRetryJitter = value =>
vi.spyOn(Math, 'random').mockReturnValue(value);
describe('ActionCableConnector - Copilot Tests', () => {
let store;
let actionCable;
@@ -39,6 +43,8 @@ describe('ActionCableConnector - Copilot Tests', () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('copilot event handlers', () => {
@@ -81,12 +87,223 @@ 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]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.FOLDERS]: 'unsupported_sort',
});
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
@@ -171,6 +171,15 @@ 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,
@@ -180,6 +189,16 @@ 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', () => {
@@ -202,4 +221,14 @@ 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,6 +392,93 @@
"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,6 +324,7 @@
"ALL_COMPANIES": "All Companies",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_OVERVIEW": "Overview",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
@@ -541,7 +542,7 @@
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
"PLACEHOLDER": "https://sso.example.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
@@ -561,7 +562,7 @@
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
"PLACEHOLDER": "https://sso.example.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://your-idp.com/saml/sso"
"PLACEHOLDER": "https://sso.example.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://seu-idp.com/saml"
"PLACEHOLDER": "https://sso.example.com/saml"
},
"UPDATE_BUTTON": "Atualizar configurações de SAML",
"API": {
@@ -0,0 +1,156 @@
<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,6 +6,7 @@ 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';
@@ -36,6 +37,12 @@ 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,
@@ -129,7 +136,7 @@ export const routes = [
return {
name: 'captain_assistants_index',
params: {
navigationPath: 'captain_assistants_responses_index',
navigationPath: 'captain_assistants_overview_index',
...to.params,
},
};
@@ -53,6 +53,7 @@ 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
@@ -64,7 +65,7 @@ const routeToLastActiveAssistant = () => {
const navigateTo = isAValidRoute
? navigationPath
: 'captain_assistants_responses_index';
: 'captain_assistants_overview_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
@@ -25,7 +25,7 @@ export default {
},
},
setup() {
const { agentsList } = useAgentsList();
const { agentsList } = useAgentsList(true, { includeAgentBots: true });
return {
agentsList,
};
@@ -81,18 +81,27 @@ export default {
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
const assignee = this.currentChat.meta.assignee;
return (
assignee && {
...assignee,
assignee_type: this.currentChat.meta.assignee_type || 'User',
}
);
},
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'));
@@ -152,7 +161,10 @@ export default {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
if (
this.assignedAgent.id !== this.currentUser.id ||
(this.assignedAgent.assignee_type || 'User') !== 'User'
) {
return true;
}
return false;
@@ -183,7 +195,11 @@ export default {
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
if (
this.assignedAgent?.id === selectedItem.id &&
(this.assignedAgent?.assignee_type || 'User') ===
(selectedItem.assignee_type || 'User')
) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
@@ -1,4 +1,5 @@
import { useMapGetter } from 'dashboard/composables/store';
import { IS_INSTAGRAM_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
@@ -13,11 +14,14 @@ 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_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () => Boolean(installationConfig.instagramAppId),
instagram: () =>
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
@@ -6,6 +6,13 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Neutralize the temporary Instagram/WhatsApp kill switch so these specs keep
// covering the credential-based gating it currently short-circuits.
vi.mock('dashboard/constants/globals', async importOriginal => ({
...(await importOriginal()),
IS_INSTAGRAM_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
@@ -7,6 +7,7 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
@@ -23,6 +24,7 @@ const PROVIDER_TYPES = {
const hasWhatsappAppId = computed(() => {
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
@@ -6,6 +6,10 @@ export const state = {
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
};
const normalizeCount = count => {
@@ -37,6 +41,18 @@ 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;
},
@@ -46,6 +62,9 @@ export const getters = {
getTeamUnreadCounts($state) {
return $state.teams;
},
getFolderUnreadCounts($state) {
return $state.folders;
},
};
export const actions = {
@@ -68,6 +87,10 @@ 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,8 +1,15 @@
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: {
@@ -20,6 +27,43 @@ 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, {
@@ -42,7 +86,10 @@ export const actions = {
}
},
update: async ({ commit }, { conversationId, userIds }) => {
update: async (
{ commit, dispatch, rootGetters, state: moduleState },
{ conversationId, userIds }
) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
isUpdating: true,
});
@@ -52,10 +99,18 @@ 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,23 +208,31 @@ const actions = {
}
},
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
assignAgent: async (
{ dispatch },
{ conversationId, agentId, assigneeType }
) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
assigneeType,
});
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
assigneeType,
});
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
setCurrentChatAssignee(
{ commit },
{ conversationId, assignee, assigneeType }
) {
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
@@ -108,10 +108,11 @@ export const mutations = {
}
},
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
[types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
chat.meta.assignee_type = assigneeType;
}
},
@@ -1,11 +1,17 @@
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 = {
@@ -21,6 +27,38 @@ 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: [],
@@ -71,14 +109,19 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: false });
}
},
create: async function createCustomViews({ commit }, obj) {
create: async function createCustomViews(
{ commit, dispatch, rootGetters },
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: FILTER_KEYS[obj.filter_type],
filterType,
});
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
return response;
} catch (error) {
const errorMessage = error?.response?.data?.message;
@@ -87,14 +130,19 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
update: async function updateCustomViews({ commit }, obj) {
update: async function updateCustomViews(
{ commit, dispatch, rootGetters },
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: FILTER_KEYS[obj.filter_type],
filterType,
});
refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
@@ -102,11 +150,12 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
delete: async ({ commit }, { id, filterType }) => {
delete: async ({ commit, dispatch, rootGetters }, { 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,31 +7,52 @@ 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 => {
const allAgents = $state.records[inboxId] || [];
const verifiedAgents = allAgents.filter(record => record.confirmed);
return verifiedAgents;
},
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;
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
async fetch({ commit }, inboxIds) {
async fetch({ commit }, actionPayload) {
const inboxIds = Array.isArray(actionPayload)
? actionPayload
: actionPayload.inboxIds;
const includeAgentBots =
!Array.isArray(actionPayload) && actionPayload.includeAgentBots;
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true });
try {
const {
data: { payload },
} = await AssignableAgentsAPI.get(inboxIds);
} = await AssignableAgentsAPI.get(inboxIds, { includeAgentBots });
if (includeAgentBots) {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: inboxIds.join(','),
members: payload,
});
}
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: inboxIds.join(','),
inboxId: recordKey(inboxIds.join(','), { includeAgentBots }),
members: payload,
});
} catch (error) {
@@ -19,6 +19,10 @@ 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,6 +7,7 @@ describe('#getters', () => {
inboxes: { 1: 2 },
labels: {},
teams: {},
folders: {},
};
expect(getters.getInboxUnreadCount(state)(1)).toBe(2);
@@ -20,6 +21,7 @@ describe('#getters', () => {
inboxes: {},
labels: { 3: 4 },
teams: {},
folders: {},
};
expect(getters.getLabelUnreadCount(state)(3)).toBe(4);
@@ -33,6 +35,7 @@ describe('#getters', () => {
inboxes: {},
labels: {},
teams: { 5: 6 },
folders: {},
};
expect(getters.getTeamUnreadCount(state)(5)).toBe(6);
@@ -46,21 +49,44 @@ 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,7 +4,16 @@ import { mutations } from '../../conversationUnreadCounts';
describe('#mutations', () => {
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
it('normalizes unread count payload', () => {
const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
const state = {
allCount: 0,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 0,
participatingCount: 0,
unattendedCount: 0,
folders: {},
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: '3',
@@ -21,6 +30,13 @@ describe('#mutations', () => {
6: '7',
7: 0,
},
mentions_count: '8',
participating_count: 9,
unattended_count: 0,
folders: {
10: '11',
12: -1,
},
});
expect(state).toEqual({
@@ -28,6 +44,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
mentionsCount: 8,
participatingCount: 9,
unattendedCount: 0,
folders: { 10: 11 },
});
});
@@ -37,6 +57,10 @@ 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, {});
@@ -46,17 +70,36 @@ 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: {} };
const state = {
allCount: 2,
inboxes: {},
labels: {},
teams: {},
mentionsCount: 2,
participatingCount: 3,
unattendedCount: 4,
folders: {},
};
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,11 +1,32 @@
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 () => {
@@ -48,6 +69,56 @@ 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,11 +357,12 @@ describe('#actions', () => {
});
await actions.assignAgent(
{ dispatch },
{ conversationId: 1, agentId: 1 }
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
});
});
});
@@ -371,6 +372,7 @@ describe('#actions', () => {
const payload = {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
};
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1);
@@ -712,8 +712,10 @@ 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,6 +1,7 @@
import axios from 'axios';
import * as types from '../../../mutation-types';
import { actions } from '../../customViews';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import {
contactFilterView,
customViewList,
@@ -11,6 +12,25 @@ 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 () => {
@@ -49,6 +69,36 @@ 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);
@@ -69,6 +119,44 @@ 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);
@@ -93,6 +181,29 @@ 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,12 +7,21 @@ 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 }],
[
@@ -24,13 +33,37 @@ describe('#actions', () => {
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.fetch({ commit }, { inboxId: 1 })).rejects.toThrow(
Error
);
await expect(actions.fetch({ commit }, [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 '../../teamMembers';
import { getters } from '../../inboxAssignableAgents';
import agentsData from './fixtures';
describe('#getters', () => {
@@ -8,7 +8,26 @@ describe('#getters', () => {
1: [agentsData[0]],
},
};
expect(getters.getTeamMembers(state)(1)).toEqual([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]]);
});
it('getUIFlags', () => {
@@ -83,7 +83,7 @@ describe('#actions', () => {
);
});
it('ignores invalid preferences', () => {
it('ignores invalid sort values', () => {
actions.setSectionSort(
{
commit,
@@ -95,7 +95,7 @@ describe('#actions', () => {
},
{
section: SIDEBAR_SORT_SECTIONS.FOLDERS,
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
sortBy: 'invalid_sort',
}
);
@@ -63,6 +63,14 @@ 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>
@@ -93,16 +101,25 @@ const hasIcon = computed(() => {
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail && !hasIcon"
:src="selectedItem.thumbnail"
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
:src="selectedThumbnail"
: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-if="hasValue && hasIcon && showEmojiIcon"
v-else-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,7 +73,13 @@ export default {
this.$refs.searchbar.focus();
},
isActive(option) {
return this.selectedItems.some(item => item && option.id === item.id);
return this.selectedItems.some(item => {
if (!item || option.id !== item.id) return false;
return (
(option.assignee_type || 'User') === (item.assignee_type || 'User')
);
});
},
},
};
@@ -94,7 +100,10 @@ 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.id">
<WootDropdownItem
v-for="option in filteredOptions"
:key="`${option.assignee_type || 'User'}-${option.id}`"
>
<NextButton
slate
:variant="isActive(option) ? 'faded' : 'ghost'"
@@ -114,16 +123,38 @@ export default {
</span>
</div>
<Avatar
v-if="hasThumbnail && !option.icon"
v-if="
hasThumbnail &&
(!option.icon || option.assignee_type === 'AgentBot')
"
: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-if="option.icon && showEmojiIcon"
v-else-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
+5 -1
View File
@@ -31,7 +31,11 @@ class Agents::DestroyJob < ApplicationJob
def unassign_conversations(account, user)
# rubocop:disable Rails/SkipsModelValidations
user.assigned_conversations.where(account: account).in_batches.update_all(assignee_id: nil)
unassigned_count = 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
+2 -1
View File
@@ -7,6 +7,7 @@
# 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")
@@ -23,7 +24,7 @@
#
class Account < ApplicationRecord
# used for single column multi flags
# used for multi-flag bitset columns
include FlagShihTzu
include Reportable
include Featurable
+18
View File
@@ -39,6 +39,8 @@ 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 }
@@ -79,6 +81,22 @@ 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,6 +53,7 @@ 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?
@@ -88,6 +89,15 @@ 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,6 +6,8 @@ module AccountCacheRevalidator
end
def update_account_cache
return if account.blank?
account.update_cache_key(self.class.name.underscore)
end
end
+54 -3
View File
@@ -1,6 +1,10 @@
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
@@ -8,15 +12,62 @@ module Featurable
FEATURE_LIST = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
FEATURES = FEATURE_LIST.each_with_object({}) do |feature, result|
result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym
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
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
has_flags FEATURES.merge(column: 'feature_flags').merge(QUERY_MODE)
FEATURE_FLAG_COLUMNS.each do |column|
has_flags FEATURES_BY_COLUMN.fetch(column).merge(column: column).merge(QUERY_MODE)
end
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)
+26 -4
View File
@@ -62,6 +62,14 @@ 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
@@ -247,6 +255,7 @@ class Conversation < ApplicationRecord
handle_resolved_status_change
notify_status_change
create_activity
invalidate_filtered_unread_count_conversation
notify_conversation_updation
end
@@ -313,10 +322,23 @@ class Conversation < ApplicationRecord
end
def allowed_keys?
(
previous_changes.keys.intersect?(list_of_keys) ||
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
)
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) }
end
def load_attributes_created_by_db_triggers
+5
View File
@@ -28,6 +28,7 @@ class ConversationParticipant < ApplicationRecord
belongs_to :user
before_validation :ensure_account_id
after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy]
private
@@ -38,4 +39,8 @@ 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,6 +48,8 @@ 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
@@ -64,6 +66,27 @@ 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,10 +22,31 @@ 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
+14
View File
@@ -77,10 +77,12 @@ 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') }
@@ -261,6 +263,18 @@ 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
+5
View File
@@ -24,6 +24,7 @@ class InboxMember < ApplicationRecord
after_create :add_agent_to_round_robin
after_destroy :remove_agent_from_round_robin
after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy]
private
@@ -34,6 +35,10 @@ class InboxMember < ApplicationRecord
def remove_agent_from_round_robin
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).remove_agent_from_queue(user_id) if inbox.present?
end
def invalidate_filtered_unread_count_visibility
::Conversations::UnreadCounts::FilteredCountInvalidator.new(inbox&.account).user_visibility_changed!(user_id: user_id)
end
end
InboxMember.include_mod_with('Audit::InboxMember')
+1 -1
View File
@@ -34,7 +34,7 @@
# index_messages_on_conversation_id (conversation_id)
# index_messages_on_created_at (created_at)
# index_messages_on_inbox_id (inbox_id)
# index_messages_on_sender_type_and_sender_id (sender_type,sender_id)
# index_messages_on_sender_and_created (sender_type,sender_id,created_at)
# index_messages_on_source_id (source_id)
#
+16 -1
View File
@@ -25,12 +25,15 @@ class Team < ApplicationRecord
has_many :members, through: :team_members, source: :user
has_many :conversations, dependent: :nullify
before_destroy :capture_filtered_unread_count_member_ids, prepend: true
after_destroy_commit :invalidate_filtered_unread_counts_after_destroy
validates :name,
presence: { message: I18n.t('errors.validations.presence') },
uniqueness: { scope: :account_id }
before_validation do
self.name = name.downcase if attribute_present?('name')
self.name = name.gsub(/[[:cntrl:]]/, '').strip.downcase if attribute_present?('name')
end
# Adds multiple members to the team
@@ -69,6 +72,18 @@ class Team < ApplicationRecord
icon_color: icon_color
}
end
private
def capture_filtered_unread_count_member_ids
@filtered_unread_count_member_ids = team_members.pluck(:user_id)
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_member_ids)
end
end
Team.include_mod_with('Audit::Team')
+8
View File
@@ -18,6 +18,14 @@ class TeamMember < ApplicationRecord
belongs_to :user
belongs_to :team
validates :user_id, uniqueness: { scope: :team_id }
after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy]
private
def invalidate_filtered_unread_count_visibility
::Conversations::UnreadCounts::FilteredCountInvalidator.new(team&.account).user_visibility_changed!(user_id: user_id)
end
end
TeamMember.include_mod_with('Audit::TeamMember')
@@ -1,4 +1,10 @@
module Conversations::UnreadCounts
READY_TTL = 24.hours.to_i
SET_TTL = 25.hours.to_i
FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i
FILTERED_COUNT_STALE_WINDOW = 30.minutes.to_i
FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW
FILTERED_COUNT_VERSION_TTL = SET_TTL
FILTERED_COUNT_MIN_REFRESH_INTERVAL = 30.seconds.to_i
MAX_INLINE_FILTER_BUILDS = 10
end
@@ -14,7 +14,7 @@ class Conversations::UnreadCounts::Counter
end
def perform
return empty_counts if permission_mode == :none
return with_filtered_counts(empty_counts, build: false) if permission_mode == :none
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
@@ -26,11 +26,17 @@ class Conversations::UnreadCounts::Counter
inboxes: inbox_counts,
labels: unread_label_counts,
teams: unread_team_counts
}
}.then { |counts| with_filtered_counts(counts) }
end
private
def with_filtered_counts(counts, build: true)
return counts unless account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
counts.merge(build ? filtered_counter.perform : ::Conversations::UnreadCounts::FilteredCounter.empty_counts)
end
def ensure_base_cache!
ensure_cache_ready!(
ready: -> { store.base_ready?(account.id) },
@@ -200,4 +206,8 @@ class Conversations::UnreadCounts::Counter
def store
::Conversations::UnreadCounts::Store
end
def filtered_counter
@filtered_counter ||= ::Conversations::UnreadCounts::FilteredCounter.new(account: account, user: user)
end
end
@@ -0,0 +1,151 @@
class Conversations::UnreadCounts::FilterQueryCounter < Conversations::FilterService
BOOLEAN_VALUES = %w[0 1 false f n no off on t true y yes].freeze
DATABASE_CAST_ERROR_CLASS_NAMES = %w[
PG::DatetimeFieldOverflow
PG::InvalidDatetimeFormat
PG::InvalidTextRepresentation
PG::NumericValueOutOfRange
].freeze
DAYS_BEFORE_FILTER_OPERATOR = 'days_before'.freeze
MALFORMED_QUERY_ERRORS = [NoMethodError, TypeError].freeze
NUMERIC_ATTRIBUTE_KEYS = %w[assignee_id inbox_id].freeze
TEXT_DATA_TYPES = %w[labels link text text_case_insensitive].freeze
TEXT_FILTER_OPERATORS = %w[contains does_not_contain].freeze
TYPED_DATA_TYPES = %w[boolean date number numeric].freeze
VALID_QUERY_OPERATORS = %w[AND OR].freeze
VALIDATION_DATA_TYPES = (TEXT_DATA_TYPES + TYPED_DATA_TYPES).freeze
VALUELESS_FILTER_OPERATORS = %w[is_present is_not_present].freeze
def initialize(account:, user:, query:)
super(query.with_indifferent_access, user, account)
end
def perform
return unless valid_query?
return unless valid_typed_values?
validate_query_operator
query_builder(@filters['conversations']).count
rescue *MALFORMED_QUERY_ERRORS
nil
rescue ActiveRecord::StatementInvalid => e
raise unless database_cast_error?(e)
nil
end
def base_relation
Conversations::PermissionFilterService.new(unread_conversations, @user, @account).perform
end
private
def valid_query?
@params[:payload].is_a?(Array) && valid_query_operator_positions?
end
def database_cast_error?(error)
DATABASE_CAST_ERROR_CLASS_NAMES.include?(error.cause&.class&.name)
end
def valid_query_operator_positions?
@params[:payload].each_with_index.all? do |query_hash, index|
query_operator_position_valid?(query_hash[:query_operator], last_query?(index))
end
end
def query_operator_position_valid?(query_operator, last_query)
return query_operator.blank? if last_query
VALID_QUERY_OPERATORS.include?(query_operator.to_s.upcase)
end
def last_query?(index)
index == @params[:payload].length - 1
end
def valid_typed_values?
@params[:payload].all? do |query_hash|
next true if VALUELESS_FILTER_OPERATORS.include?(query_hash[:filter_operator])
data_type = validation_data_type(query_hash)
next true if data_type.blank?
next false if text_filter_operator?(query_hash) && TYPED_DATA_TYPES.include?(data_type)
valid_typed_values_for?(query_hash[:values], data_type, query_hash[:filter_operator])
end
end
def validation_data_type(query_hash)
attribute_key = query_hash[:attribute_key]
data_type = filter_data_type(query_hash)
return nil if text_search_on_display_id?(query_hash)
return 'number' if NUMERIC_ATTRIBUTE_KEYS.include?(attribute_key)
return data_type if VALIDATION_DATA_TYPES.include?(data_type)
nil
end
def filter_data_type(query_hash)
attribute_key = query_hash[:attribute_key]
data_type = @filters.dig('conversations', attribute_key, 'data_type')
return data_type.to_s.downcase if data_type.present?
custom_attribute_data_type(query_hash)
end
def custom_attribute_data_type(query_hash)
custom_attribute_type = query_hash[:custom_attribute_type].presence || self.class::ATTRIBUTE_MODEL
custom_attribute = @account.custom_attribute_definitions.where(
attribute_model: custom_attribute_type
).find_by(attribute_key: query_hash[:attribute_key])
self.class::ATTRIBUTE_TYPES[custom_attribute&.attribute_display_type].to_s
end
def valid_typed_values_for?(values, data_type, filter_operator)
Array.wrap(values).all? do |value|
valid_typed_value?(value, data_type, filter_operator)
end
end
def text_filter_operator?(query_hash)
TEXT_FILTER_OPERATORS.include?(query_hash[:filter_operator])
end
def valid_typed_value?(value, data_type, filter_operator)
case data_type
when 'boolean'
BOOLEAN_VALUES.include?(value.to_s.downcase)
when 'date'
return Integer(value.to_s, exception: false).present? if filter_operator == DAYS_BEFORE_FILTER_OPERATOR
Date.iso8601(value.to_s).present?
when 'numeric'
BigDecimal(value.to_s, exception: false).present?
when *TEXT_DATA_TYPES
value.is_a?(String)
else
Integer(value.to_s, exception: false).present?
end
rescue ArgumentError
false
end
def unread_conversations
@account.conversations
.joins(:messages)
.merge(Message.incoming.reorder(nil))
.where(messages: { account_id: @account.id })
.where(unread_since_last_seen_condition)
.distinct
end
def unread_since_last_seen_condition
conversations = Conversation.arel_table
messages = Message.arel_table
conversations[:agent_last_seen_at].eq(nil).or(messages[:created_at].gt(conversations[:agent_last_seen_at]))
end
end
@@ -0,0 +1,188 @@
class Conversations::UnreadCounts::FilteredCountInstrumentation
# Centralizes the rollout-critical filtered unread count signals:
# API response duration, counter duration, snapshot build duration, snapshot state distribution,
# refresh claim rate, build lock acquisition rate, and invalidation/version bump rate.
EVENT_NAME = 'FilteredUnreadCounts'.freeze
METRIC_PREFIX = 'Custom/Conversations/UnreadCounts/Filtered'.freeze
SUMMARY_KEY = :filtered_unread_counts_request_summary
AGGREGATED_INCREMENT_OPERATIONS = %i[snapshot_state refresh_claim build_lock].freeze
SNAPSHOT_STATUSES = %i[fresh stale missing expired].freeze
SNAPSHOT_SCOPES = %i[built_in_filter folder_index filter].freeze
SUMMARY_DEFAULTS = begin
defaults = {
snapshot_total_count: 0,
refresh_claimed_count: 0,
refresh_skipped_count: 0,
build_lock_acquired_count: 0,
build_lock_missed_count: 0,
snapshot_build_success_count: 0,
snapshot_build_error_count: 0
}
SNAPSHOT_STATUSES.each { |status| defaults[:"snapshot_#{status}_count"] = 0 }
SNAPSHOT_SCOPES.each do |scope|
defaults[:"#{scope}_snapshot_count"] = 0
defaults[:"#{scope}_refresh_claimed_count"] = 0
defaults[:"#{scope}_refresh_skipped_count"] = 0
defaults[:"#{scope}_build_lock_acquired_count"] = 0
defaults[:"#{scope}_build_lock_missed_count"] = 0
defaults[:"#{scope}_snapshot_build_success_count"] = 0
defaults[:"#{scope}_snapshot_build_error_count"] = 0
end
defaults.freeze
end
private_constant :SUMMARY_KEY, :AGGREGATED_INCREMENT_OPERATIONS, :SNAPSHOT_STATUSES, :SNAPSHOT_SCOPES, :SUMMARY_DEFAULTS
class << self
def summarize_request(account_id:)
previous_summary = current_summary
summary = request_summary(account_id)
Thread.current[SUMMARY_KEY] = summary
started_at = monotonic_time
status = :success
yield
rescue StandardError => e
status = :error
summary[:error_class] = e.class.name
raise
ensure
record_request_summary(summary, status, started_at)
Thread.current[SUMMARY_KEY] = previous_summary
end
def observe(operation, attributes = {})
started_at = monotonic_time
yield.tap do
record_observation(operation, attributes, started_at, status: :success)
end
rescue StandardError => e
record_observation(operation, attributes.merge(error_class: e.class.name), started_at, status: :error)
raise
end
def increment(operation, attributes = {})
record_increment_summary(operation, attributes) if aggregated_increment?(operation)
record_event(operation, attributes) unless aggregated_increment?(operation)
record_metric("#{metric_name(operation)}/count", 1)
end
def record_event(operation, attributes = {})
agent = new_relic_agent
return unless agent.respond_to?(:record_custom_event)
agent.record_custom_event(EVENT_NAME, sanitized_attributes(attributes.merge(operation: operation)))
rescue StandardError
nil
end
private
def record_observation(operation, attributes, started_at, status:)
duration_ms = elapsed_ms_since(started_at)
record_observation_summary(operation, attributes, status: status)
record_metric("#{metric_name(operation)}/duration_ms", duration_ms)
end
def record_request_summary(summary, status, started_at)
duration_ms = elapsed_ms_since(started_at)
summary[:status] = status
summary[:duration_ms] = duration_ms
record_metric("#{metric_name(:api_response)}/duration_ms", duration_ms)
record_event(:request_summary, summary)
end
def record_metric(name, value)
agent = new_relic_agent
return unless agent.respond_to?(:record_metric)
agent.record_metric(name, value)
rescue StandardError
nil
end
def metric_name(operation)
"#{METRIC_PREFIX}/#{operation}"
end
def request_summary(account_id)
SUMMARY_DEFAULTS.dup.merge(account_id: account_id)
end
def current_summary
Thread.current[SUMMARY_KEY]
end
def aggregated_increment?(operation)
operation.in?(AGGREGATED_INCREMENT_OPERATIONS)
end
def record_increment_summary(operation, attributes)
case operation
when :snapshot_state
record_snapshot_state_summary(attributes)
when :refresh_claim
result = attributes[:claimed] ? :claimed : :skipped
increment_summary_count(:"refresh_#{result}_count")
increment_scoped_summary_count(attributes[:snapshot_scope], :"refresh_#{result}_count")
when :build_lock
result = attributes[:acquired] ? :acquired : :missed
increment_summary_count(:"build_lock_#{result}_count")
increment_scoped_summary_count(attributes[:snapshot_scope], :"build_lock_#{result}_count")
end
end
def record_snapshot_state_summary(attributes)
increment_summary_count(:snapshot_total_count)
increment_summary_count(:"snapshot_#{attributes[:snapshot_status]}_count")
increment_scoped_summary_count(attributes[:snapshot_scope], :snapshot_count)
end
def record_observation_summary(operation, attributes, status:)
return unless operation == :snapshot_build
result = status == :success ? :success : :error
increment_summary_count(:"snapshot_build_#{result}_count")
increment_scoped_summary_count(attributes[:snapshot_scope], :"snapshot_build_#{result}_count")
end
def increment_scoped_summary_count(scope, suffix)
return if scope.blank?
increment_summary_count(:"#{scope}_#{suffix}")
end
def increment_summary_count(key)
return if current_summary.blank?
current_summary[key] = current_summary.fetch(key, 0) + 1
end
def sanitized_attributes(attributes)
attributes.compact.transform_values do |value|
case value
when String, Integer, Float, TrueClass, FalseClass
value
else
value.to_s
end
end
end
def elapsed_ms_since(started_at)
((monotonic_time - started_at) * 1000).round(2)
end
def monotonic_time
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def new_relic_agent
return unless defined?(::NewRelic::Agent)
::NewRelic::Agent
end
end
end
@@ -0,0 +1,184 @@
class Conversations::UnreadCounts::FilteredCountInvalidator
FEATURE_FLAG = 'unread_count_for_filters'.freeze
attr_reader :account
def initialize(account)
@account = account
end
def conversation_changed!
return false unless enabled?
version = store.bump_conversation_version!(account.id)
record_invalidation(:conversation, reason: :conversation_changed, version: version)
true
end
def user_visibility_changed!(user_id:)
return false unless enabled? && user_id.present?
version = store.bump_built_in_filter_version!(account_id: account.id, user_id: user_id)
record_invalidation(:built_in_filter, reason: :user_visibility_changed, version: version)
true
end
def users_visibility_changed!(user_ids:)
return false unless enabled?
user_ids = Array(user_ids).compact_blank.uniq
return false if user_ids.blank?
bump_built_in_filter_versions(user_ids).each_value do |version|
record_invalidation(:built_in_filter, reason: :user_visibility_changed, version: version)
end
true
end
def custom_filter_created!(custom_filter)
return false unless conversation_filter?(custom_filter)
bump_folder_index_version!(custom_filter, reason: :custom_filter_created)
bump_filter_version!(custom_filter, reason: :custom_filter_created)
true
end
def custom_filter_updated!(custom_filter)
return false unless enabled? && conversation_filter_before_or_after?(custom_filter)
filter_type_changed = filter_type_changed?(custom_filter)
query_changed = query_changed?(custom_filter)
return false unless filter_type_changed || query_changed
bump_folder_index_version!(custom_filter, reason: :custom_filter_updated) if filter_type_changed
bump_filter_version!(custom_filter, reason: :custom_filter_updated)
store.delete_filter_count!(account_id: account.id, filter_id: custom_filter.id) if moved_out_of_conversation_filters?(custom_filter)
true
end
def custom_filter_destroyed!(custom_filter)
return false unless conversation_filter?(custom_filter)
bump_folder_index_version!(custom_filter, reason: :custom_filter_destroyed)
store.delete_filter_count!(account_id: account.id, filter_id: custom_filter.id)
true
end
def custom_attribute_definition_changed!(custom_attribute_definition)
return false unless enabled? && conversation_attribute_before_or_after?(custom_attribute_definition)
affected_filters = affected_custom_attribute_filters(custom_attribute_definition)
return false if affected_filters.blank?
affected_filters.each { |custom_filter| bump_filter_version!(custom_filter, reason: :custom_attribute_definition_changed) }
true
end
private
def bump_built_in_filter_versions(user_ids)
results = Redis::Alfred.pipelined do |pipeline|
user_ids.each do |user_id|
key = store.built_in_filter_version_key(account.id, user_id)
pipeline.incr(key)
pipeline.expire(key, Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL)
end
end
user_ids.zip(results.each_slice(2).map(&:first)).to_h
end
def enabled?
account&.feature_enabled?(FEATURE_FLAG)
end
def conversation_filter?(custom_filter)
enabled? && custom_filter.conversation?
end
def conversation_filter_before_or_after?(custom_filter)
custom_filter.conversation? || previous_filter_type(custom_filter) == 'conversation'
end
def moved_out_of_conversation_filters?(custom_filter)
filter_type_changed?(custom_filter) && previous_filter_type(custom_filter) == 'conversation' && !custom_filter.conversation?
end
def filter_type_changed?(custom_filter)
custom_filter.previous_changes.key?('filter_type')
end
def query_changed?(custom_filter)
custom_filter.previous_changes.key?('query')
end
def previous_filter_type(custom_filter)
raw_filter_type = custom_filter.previous_changes.dig('filter_type', 0)
return if raw_filter_type.blank?
return raw_filter_type if CustomFilter.filter_types.key?(raw_filter_type)
return CustomFilter.filter_types.key(raw_filter_type) if raw_filter_type.is_a?(Integer)
CustomFilter.filter_types.key(raw_filter_type.to_i) || raw_filter_type.to_s
end
def conversation_attribute_before_or_after?(custom_attribute_definition)
custom_attribute_definition.conversation_attribute? || previous_attribute_model(custom_attribute_definition) == 'conversation_attribute'
end
def previous_attribute_model(custom_attribute_definition)
raw_attribute_model = custom_attribute_definition.previous_changes.dig('attribute_model', 0)
return if raw_attribute_model.blank?
return raw_attribute_model if CustomAttributeDefinition.attribute_models.key?(raw_attribute_model)
return CustomAttributeDefinition.attribute_models.key(raw_attribute_model) if raw_attribute_model.is_a?(Integer)
CustomAttributeDefinition.attribute_models.key(raw_attribute_model.to_i) || raw_attribute_model.to_s
end
def affected_custom_attribute_filters(custom_attribute_definition)
attribute_keys = custom_attribute_keys(custom_attribute_definition)
account.custom_filters.conversation.select do |custom_filter|
custom_filter_references_conversation_attribute?(custom_filter, attribute_keys)
end
end
def custom_attribute_keys(custom_attribute_definition)
[custom_attribute_definition.attribute_key, custom_attribute_definition.previous_changes.dig('attribute_key', 0)].compact_blank.map(&:to_s).uniq
end
def custom_filter_references_conversation_attribute?(custom_filter, attribute_keys)
payload = custom_filter.query.with_indifferent_access[:payload]
Array(payload).any? do |condition|
condition = condition.with_indifferent_access
condition[:attribute_key].to_s.in?(attribute_keys) &&
(condition[:custom_attribute_type].presence || 'conversation_attribute') == 'conversation_attribute'
end
end
def bump_folder_index_version!(custom_filter, reason:)
version = store.bump_folder_index_version!(account_id: account.id, user_id: custom_filter.user_id)
record_invalidation(:folder_index, reason: reason, version: version)
end
def bump_filter_version!(custom_filter, reason:)
version = store.bump_filter_version!(account_id: account.id, filter_id: custom_filter.id)
record_invalidation(:filter, reason: reason, version: version)
end
def record_invalidation(scope, reason:, version:)
instrumentation.increment(
:invalidation,
account_id: account.id,
invalidation_scope: scope,
reason: reason,
version: version
)
end
def store
::Conversations::UnreadCounts::FilteredCountStore
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
end
@@ -0,0 +1,67 @@
class Conversations::UnreadCounts::FilteredCountSnapshotResolver
BUILD_LOCK_TTL = 15.minutes.to_i
attr_reader :account, :now, :store, :lock_manager
def initialize(account:, now:, store:, lock_manager:)
@account = account
@now = now
@store = store
@lock_manager = lock_manager
end
# Version mismatches make a snapshot stale immediately, but refresh_after keeps DB rebuilds throttled.
def resolve(scope:, state:, lock_key:, claim_refresh:, &)
record_snapshot_state(scope, state)
return state.payload if state.fresh?
stale_payload = state.payload if state.stale?
return stale_payload if refresh_not_due?(stale_payload)
return stale_payload unless refresh_claimed?(scope, claim_refresh)
build_with_lock(scope, lock_key, stale_payload, &)
end
private
def record_snapshot_state(scope, state)
instrumentation.increment(
:snapshot_state,
account_id: account.id,
snapshot_scope: scope,
snapshot_status: state.status
)
end
def refresh_not_due?(stale_payload)
stale_payload.present? && !store.refresh_due?(stale_payload, now: now)
end
def refresh_claimed?(scope, claim_refresh)
claimed = claim_refresh.call
instrumentation.increment(:refresh_claim, account_id: account.id, snapshot_scope: scope, claimed: claimed)
claimed
end
def build_with_lock(scope, lock_key, stale_payload, &)
built_payload = nil
lock_acquired = false
begin
lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) do
lock_acquired = true
built_payload = instrumentation.observe(:snapshot_build, account_id: account.id, snapshot_scope: scope, &)
rescue ActiveRecord::StatementInvalid
built_payload = stale_payload
end
ensure
instrumentation.increment(:build_lock, account_id: account.id, snapshot_scope: scope, acquired: lock_acquired)
end
lock_acquired ? built_payload : stale_payload
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
end
@@ -0,0 +1,214 @@
class Conversations::UnreadCounts::FilteredCountStore
extend Conversations::UnreadCounts::FilteredCountStoreKeys
SnapshotResult = Struct.new(:status, :payload, keyword_init: true) do
def fresh? = status == :fresh
def stale? = status == :stale
def expired? = status == :expired
def missing? = status == :missing
end
VERSION_KEY_METHODS = {
conversation: :conversation_version_key,
built_in_filter: :built_in_filter_version_key,
folder_index: :folder_index_version_key,
filter: :filter_version_key
}.freeze
REFRESH_THROTTLE_KEY_METHODS = {
built_in_filter: :built_in_filter_refresh_throttle_key,
folder_index: :folder_index_refresh_throttle_key,
filter: :filter_refresh_throttle_key
}.freeze
private_constant :VERSION_KEY_METHODS, :REFRESH_THROTTLE_KEY_METHODS
class << self
def conversation_version(account_id) = current_version_for(:conversation, account_id)
def built_in_filter_version(account_id:, user_id:) = current_version_for(:built_in_filter, account_id, user_id)
def folder_index_version(account_id:, user_id:) = current_version_for(:folder_index, account_id, user_id)
def filter_version(account_id:, filter_id:) = current_version_for(:filter, account_id, filter_id)
def bump_conversation_version!(account_id) = bump_version_for!(:conversation, account_id)
def bump_built_in_filter_version!(account_id:, user_id:) = bump_version_for!(:built_in_filter, account_id, user_id)
def bump_folder_index_version!(account_id:, user_id:) = bump_version_for!(:folder_index, account_id, user_id)
def bump_filter_version!(account_id:, filter_id:) = bump_version_for!(:filter, account_id, filter_id)
# Keep version dimensions explicit so callers cannot write a snapshot without the freshness contract it depends on.
def write_built_in_filter_counts!(account_id:, user_id:, counts:, account_version:, built_in_filter_version:, built_at: Time.current, meta: {}) # rubocop:disable Metrics/ParameterLists
payload = snapshot_payload(built_at).merge(
account_version: account_version,
built_in_filter_version: built_in_filter_version,
user_id: user_id,
counts: counts,
meta: meta
)
write_snapshot(built_in_filter_counts_key(account_id, user_id), payload)
end
def built_in_filter_counts(account_id:, user_id:)
read_snapshot(built_in_filter_counts_key(account_id, user_id))
end
def built_in_filter_counts_state(account_id:, user_id:, versions: nil, now: Time.current)
snapshot_state(
built_in_filter_counts(account_id: account_id, user_id: user_id),
versions: versions || {
account_version: conversation_version(account_id),
built_in_filter_version: built_in_filter_version(account_id: account_id, user_id: user_id)
},
now: now
)
end
def write_folder_index!(account_id:, user_id:, filter_ids:, folder_index_version:, built_at: Time.current)
payload = snapshot_payload(built_at).merge(
folder_index_version: folder_index_version,
user_id: user_id,
filter_ids: Array(filter_ids).map(&:to_i)
)
write_snapshot(folder_index_key(account_id, user_id), payload)
end
def folder_index(account_id:, user_id:)
read_snapshot(folder_index_key(account_id, user_id))
end
def folder_index_state(account_id:, user_id:, versions: nil, now: Time.current)
snapshot_state(
folder_index(account_id: account_id, user_id: user_id),
versions: versions || { folder_index_version: folder_index_version(account_id: account_id, user_id: user_id) },
now: now
)
end
# Saved folder snapshots depend on account, filter, and owner visibility versions; keep all three visible at the callsite.
def write_filter_count!(account_id:, filter_id:, user_id:, count:, account_version:, filter_version:, owner_built_in_filter_version:, # rubocop:disable Metrics/ParameterLists
built_at: Time.current, meta: {})
payload = snapshot_payload(built_at).merge(
account_version: account_version,
filter_version: filter_version,
owner_built_in_filter_version: owner_built_in_filter_version,
filter_id: filter_id,
user_id: user_id,
count: count,
meta: meta
)
write_snapshot(filter_count_key(account_id, filter_id), payload)
end
def filter_count(account_id:, filter_id:)
read_snapshot(filter_count_key(account_id, filter_id))
end
def filter_count_state(account_id:, filter_id:, owner_user_id: nil, versions: nil, now: Time.current)
snapshot = filter_count(account_id: account_id, filter_id: filter_id)
return SnapshotResult.new(status: :missing, payload: nil) if snapshot.blank?
owner_user_id ||= snapshot[:user_id]
snapshot_state(
snapshot,
versions: versions || {
account_version: conversation_version(account_id),
filter_version: filter_version(account_id: account_id, filter_id: filter_id),
owner_built_in_filter_version: built_in_filter_version(account_id: account_id, user_id: owner_user_id)
},
now: now
)
end
def refresh_due?(snapshot, now: Time.current)
return true if snapshot.blank?
refresh_after = parse_time(snapshot[:refresh_after])
refresh_after.blank? || now >= refresh_after
end
def claim_built_in_filter_refresh!(account_id:, user_id:) = claim_refresh_for!(:built_in_filter, account_id, user_id)
def claim_folder_index_refresh!(account_id:, user_id:) = claim_refresh_for!(:folder_index, account_id, user_id)
def claim_filter_refresh!(account_id:, filter_id:) = claim_refresh_for!(:filter, account_id, filter_id)
def delete_filter_count!(account_id:, filter_id:)
Redis::Alfred.delete(filter_count_key(account_id, filter_id))
end
private
# Keep the public API domain-specific while centralizing direct Redis version/throttle operations.
def current_version_for(scope, *key_args)
current_version(public_send(VERSION_KEY_METHODS.fetch(scope), *key_args))
end
def bump_version_for!(scope, *key_args)
key = public_send(VERSION_KEY_METHODS.fetch(scope), *key_args)
Redis::Alfred.with do |conn|
conn.multi do |transaction|
transaction.incr(key)
transaction.expire(key, Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL)
end.first
end
end
def claim_refresh_for!(scope, *key_args)
claim_refresh_throttle(public_send(REFRESH_THROTTLE_KEY_METHODS.fetch(scope), *key_args))
end
def current_version(key)
Redis::Alfred.get(key).to_i
end
def snapshot_payload(built_at)
# expires_at marks the end of the fresh window. Redis keeps the snapshot for the additional stale window.
{
built_at: built_at.iso8601,
refresh_after: (built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL).iso8601,
expires_at: (built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL).iso8601
}
end
def write_snapshot(key, payload)
Redis::Alfred.setex(key, JSON.generate(payload), Conversations::UnreadCounts::FILTERED_COUNT_REDIS_TTL)
end
def read_snapshot(key)
value = Redis::Alfred.get(key)
return if value.blank?
JSON.parse(value, symbolize_names: true)
end
def snapshot_state(snapshot, versions:, now:)
return SnapshotResult.new(status: :missing, payload: nil) if snapshot.blank?
return SnapshotResult.new(status: :expired, payload: snapshot) unless inside_stale_window?(snapshot, now)
return SnapshotResult.new(status: :fresh, payload: snapshot) if versions_match?(snapshot, versions) && inside_fresh_window?(snapshot, now)
SnapshotResult.new(status: :stale, payload: snapshot)
end
def versions_match?(snapshot, versions)
versions.all? { |key, value| snapshot[key].to_i == value.to_i }
end
def inside_fresh_window?(snapshot, now)
expires_at = parse_time(snapshot[:expires_at])
expires_at.present? && now <= expires_at
end
def inside_stale_window?(snapshot, now)
expires_at = parse_time(snapshot[:expires_at])
expires_at.present? && now <= expires_at + Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW
end
def parse_time(value)
return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String)
Time.zone.parse(value.to_s)
rescue ArgumentError, TypeError
nil
end
def claim_refresh_throttle(key)
Redis::Alfred.set(key, Time.current.to_i, nx: true, ex: Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL) ? true : false
end
end
end
@@ -0,0 +1,67 @@
module Conversations::UnreadCounts::FilteredCountStoreKeys
def conversation_version_key(account_id)
account_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_CONVERSATION_VERSION, account_id)
end
def built_in_filter_version_key(account_id, user_id)
user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_VERSION, account_id, user_id)
end
def built_in_filter_counts_key(account_id, user_id)
user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_COUNTS, account_id, user_id)
end
def built_in_filter_build_lock_key(account_id, user_id)
user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_BUILD_LOCK, account_id, user_id)
end
def built_in_filter_refresh_throttle_key(account_id, user_id)
user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_REFRESH_THROTTLE, account_id, user_id)
end
def folder_index_version_key(account_id, user_id)
user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_VERSION, account_id, user_id)
end
def folder_index_key(account_id, user_id)
user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX, account_id, user_id)
end
def folder_index_build_lock_key(account_id, user_id)
user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_BUILD_LOCK, account_id, user_id)
end
def folder_index_refresh_throttle_key(account_id, user_id)
user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_REFRESH_THROTTLE, account_id, user_id)
end
def filter_version_key(account_id, filter_id)
filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_VERSION, account_id, filter_id)
end
def filter_count_key(account_id, filter_id)
filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_COUNT, account_id, filter_id)
end
def filter_build_lock_key(account_id, filter_id)
filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_BUILD_LOCK, account_id, filter_id)
end
def filter_refresh_throttle_key(account_id, filter_id)
filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_REFRESH_THROTTLE, account_id, filter_id)
end
private
def account_key(format_string, account_id)
format(format_string, account_id: account_id)
end
def user_key(format_string, account_id, user_id)
format(format_string, account_id: account_id, user_id: user_id)
end
def filter_key(format_string, account_id, filter_id)
format(format_string, account_id: account_id, filter_id: filter_id)
end
end
@@ -0,0 +1,29 @@
class Conversations::UnreadCounts::FilteredCountVersionCache
attr_reader :account, :user, :store
def initialize(account:, user:, store:)
@account = account
@user = user
@store = store
end
def built_in_filter = { account_version: account_version, built_in_filter_version: built_in_filter_version }
def folder_index = { folder_index_version: store.folder_index_version(account_id: account.id, user_id: user.id) }
def filter(filter_id)
{
account_version: account_version,
filter_version: filter_version(filter_id),
owner_built_in_filter_version: built_in_filter_version
}
end
private
def account_version = @account_version ||= store.conversation_version(account.id)
def built_in_filter_version = @built_in_filter_version ||= store.built_in_filter_version(account_id: account.id, user_id: user.id)
def filter_version(filter_id) = (@filter_versions ||= {})[filter_id] ||= store.filter_version(account_id: account.id, filter_id: filter_id)
end
@@ -0,0 +1,216 @@
class Conversations::UnreadCounts::FilteredCounter
FEATURE_FLAG = 'unread_count_for_filters'.freeze
EMPTY_COUNTS = {
mentions_count: 0,
participating_count: 0,
unattended_count: 0,
folders: {}
}.freeze
attr_reader :account, :user, :now
def self.empty_counts = EMPTY_COUNTS.deep_dup
def initialize(account:, user:, now: Time.current)
@account = account
@user = user
@now = now
end
def perform = instrumentation.observe(:counter_perform, account_id: account.id) { built_in_counts.merge(folders: folder_counts) }
private
def built_in_counts = counts_from_built_in_snapshot(built_in_counts_snapshot) || self.class.empty_counts.except(:folders)
def built_in_counts_snapshot
versions = version_cache.built_in_filter
snapshot_or_build(
scope: :built_in_filter,
state: store.built_in_filter_counts_state(account_id: account.id, user_id: user.id, versions: versions, now: now),
lock_key: store.built_in_filter_build_lock_key(account.id, user.id),
claim_refresh: -> { store.claim_built_in_filter_refresh!(account_id: account.id, user_id: user.id) }
) { build_built_in_counts!(versions) }
end
def counts_from_built_in_snapshot(snapshot) = snapshot&.fetch(:counts, nil)&.slice(:mentions_count, :participating_count, :unattended_count)
def folder_counts
folder_index = folder_index_snapshot
return {} if folder_index.blank?
@inline_filter_builds = 0
folder_index[:filter_ids].each_with_object({}) do |filter_id, counts|
count = filter_count(filter_id)
counts[filter_id.to_s] = count if count.to_i.positive?
end
end
def folder_index_snapshot
versions = version_cache.folder_index
snapshot_or_build(
scope: :folder_index,
state: store.folder_index_state(account_id: account.id, user_id: user.id, versions: versions, now: now),
lock_key: store.folder_index_build_lock_key(account.id, user.id),
claim_refresh: -> { store.claim_folder_index_refresh!(account_id: account.id, user_id: user.id) }
) { build_folder_index!(versions) }
end
def filter_count(filter_id)
versions = version_cache.filter(filter_id)
snapshot = snapshot_or_build(
scope: :filter,
state: store.filter_count_state(account_id: account.id, filter_id: filter_id, owner_user_id: user.id, versions: versions, now: now),
lock_key: store.filter_build_lock_key(account.id, filter_id),
claim_refresh: -> { filter_build_available? && store.claim_filter_refresh!(account_id: account.id, filter_id: filter_id) }
) do
track_filter_build!
build_filter_count!(filter_id, versions)
end
snapshot&.fetch(:count, nil)
end
def snapshot_or_build(scope:, state:, lock_key:, claim_refresh:, &)
snapshot_resolver.resolve(scope: scope, state: state, lock_key: lock_key, claim_refresh: claim_refresh, &)
end
def filter_build_available? = @inline_filter_builds.to_i < Conversations::UnreadCounts::MAX_INLINE_FILTER_BUILDS
def track_filter_build! = @inline_filter_builds = @inline_filter_builds.to_i + 1
def build_built_in_counts!(versions)
store.write_built_in_filter_counts!(**built_in_count_snapshot_payload(versions))
store.built_in_filter_counts(account_id: account.id, user_id: user.id)
end
def built_in_count_snapshot_payload(versions)
{
account_id: account.id,
user_id: user.id,
counts: built_in_counts_from_database,
account_version: versions.fetch(:account_version),
built_in_filter_version: versions.fetch(:built_in_filter_version),
built_at: now
}
end
def built_in_counts_from_database
{
mentions_count: count_relation(mentioned_unread_conversations),
participating_count: count_relation(participating_unread_conversations),
unattended_count: count_relation(unread_open_accessible_conversations.unattended)
}
end
def mentioned_unread_conversations
unread_open_accessible_conversations
.joins(:mentions)
.where(mentions: { account_id: account.id, user_id: user.id })
end
def participating_unread_conversations
unread_open_accessible_conversations
.joins(:conversation_participants)
.where(conversation_participants: { user_id: user.id })
end
def build_folder_index!(versions)
store.write_folder_index!(
account_id: account.id,
user_id: user.id,
filter_ids: folder_filter_ids_from_database,
folder_index_version: versions.fetch(:folder_index_version),
built_at: now
)
store.folder_index(account_id: account.id, user_id: user.id)
end
def folder_filter_ids_from_database = account.custom_filters.where(user_id: user.id, filter_type: :conversation).pluck(:id)
def build_filter_count!(filter_id, versions)
custom_filter = account.custom_filters.find_by(id: filter_id, user_id: user.id, filter_type: :conversation)
return delete_filter_count!(filter_id) if custom_filter.blank?
count = filter_query_count(custom_filter)
return delete_filter_count!(filter_id) if count.nil?
write_filter_count!(filter_id, count, versions)
store.filter_count(account_id: account.id, filter_id: filter_id)
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue
delete_filter_count!(filter_id)
end
def filter_query_count(custom_filter)
::Conversations::UnreadCounts::FilterQueryCounter.new(
account: account,
user: user,
query: custom_filter.query
).perform
end
def write_filter_count!(filter_id, count, versions)
store.write_filter_count!(
account_id: account.id,
filter_id: filter_id,
user_id: user.id,
count: count,
account_version: versions.fetch(:account_version),
filter_version: versions.fetch(:filter_version),
owner_built_in_filter_version: versions.fetch(:owner_built_in_filter_version),
built_at: now
)
end
def version_cache = @version_cache ||= ::Conversations::UnreadCounts::FilteredCountVersionCache.new(account: account, user: user, store: store)
def delete_filter_count!(filter_id) = store.delete_filter_count!(account_id: account.id, filter_id: filter_id).then { nil }
def unread_open_accessible_conversations
@unread_open_accessible_conversations ||= Conversations::PermissionFilterService.new(
unread_conversations.open,
user,
account
).perform
end
def unread_conversations
account.conversations
.joins(:messages)
.merge(Message.incoming.reorder(nil))
.where(messages: { account_id: account.id })
.where(unread_since_last_seen_condition)
.distinct
end
def unread_since_last_seen_condition
conversations = Conversation.arel_table
messages = Message.arel_table
conversations[:agent_last_seen_at].eq(nil).or(messages[:created_at].gt(conversations[:agent_last_seen_at]))
end
def count_relation(relation) = relation.unscope(:order).count
def lock_manager = @lock_manager ||= Redis::LockManager.new
def snapshot_resolver
@snapshot_resolver ||= ::Conversations::UnreadCounts::FilteredCountSnapshotResolver.new(
account: account,
now: now,
store: store,
lock_manager: lock_manager
)
end
def store
::Conversations::UnreadCounts::FilteredCountStore
end
def instrumentation
::Conversations::UnreadCounts::FilteredCountInstrumentation
end
end

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