Compare commits

..
Author SHA1 Message Date
Shivam Mishra 835fb229da feat: update prompt to ignore unrelated material 2026-06-25 17:28:44 +05:30
Shivam Mishra 6365af872d feat: add exclude path 2026-06-25 17:28:05 +05:30
168 changed files with 469 additions and 7057 deletions
+3 -3
View File
@@ -272,9 +272,9 @@ AZURE_APP_SECRET=
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features (Captain)
# The OpenAI API key and endpoint for Captain are not configured via .env.
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
# AI powered features
## OpenAI key
# OPENAI_API_KEY=
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
+1 -1
View File
@@ -195,7 +195,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.12.0'
gem 'ai-agents', '>= 0.10.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
+4 -4
View File
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.12.0)
ai-agents (0.10.0)
ruby_llm (~> 1.14)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -198,7 +198,7 @@ GEM
crack (1.0.0)
bigdecimal
rexml
crass (1.0.7)
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
@@ -570,7 +570,7 @@ GEM
minitest (5.25.5)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.3)
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
@@ -1058,7 +1058,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.12.0)
ai-agents (>= 0.10.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1,213 +0,0 @@
class V2::Reports::DrilldownBuilder
include DateRangeHelper
include TimezoneHelper
DEFAULT_GROUP_BY = 'day'.freeze
DEFAULT_PAGE = 1
DEFAULT_PER_PAGE = 25
MAX_PER_PAGE = 100
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
MESSAGE_METRICS = {
'incoming_messages_count' => :incoming,
'outgoing_messages_count' => :outgoing
}.freeze
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
pattr_initialize :account, :params
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
def build
records = paginated_records.to_a
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
end
private
def meta
{
metric: metric,
record_type: record_type,
bucket: {
since: bucket_range.begin.to_i,
until: bucket_range.end.to_i
},
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: conversation_count
}
end
def conversation_count
return paginated_records.total_count if conversation_metric?
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
def drilldown_scope
if message_metric?
message_scope
elsif conversation_metric?
conversation_scope
else
reporting_event_scope
end
end
def message_scope
scope.messages
.where(account_id: account.id, created_at: bucket_range)
.public_send(MESSAGE_METRICS.fetch(metric))
.includes(:sender, conversation: [:assignee, :contact, :inbox])
.reorder(created_at: :desc)
end
def conversation_scope
scope.conversations
.where(account_id: account.id, created_at: bucket_range)
.includes(:assignee, :contact, :inbox)
.order(created_at: :desc)
end
def reporting_event_scope
events = scope.reporting_events
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
.order(created_at: :desc)
if raw_count_strategy == :exclude_bot_handoffs
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
elsif raw_count_strategy == :distinct_conversation
events = events.where(id: distinct_conversation_event_ids(events))
end
events
end
def bot_handoff_conversation_ids_subquery
scope.reporting_events
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end
def distinct_conversation_event_ids(events)
events.reorder(nil)
.where.not(conversation_id: nil)
.select('MAX(reporting_events.id)')
.group(:conversation_id)
end
def record_serializer(records)
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
account,
metric,
use_business_hours?,
records
)
end
def bucket_range
@bucket_range ||= begin
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
bucket_end = bucket_end_for(bucket_start)
requested_start = Time.zone.at(params[:since].to_i)
requested_end = Time.zone.at(params[:until].to_i)
[bucket_start, requested_start].max...[bucket_end, requested_end].min
end
end
def bucket_end_for(bucket_start)
{
'hour' => bucket_start + 1.hour,
'day' => bucket_start + 1.day,
'week' => bucket_start + 1.week,
'month' => bucket_start + 1.month,
'year' => bucket_start + 1.year
}.fetch(group_by)
end
def scope
case dimension_type
when 'account' then account
when 'inbox' then inbox
when 'agent' then user
when 'label' then label
when 'team' then team
else
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
end
end
def inbox = @inbox ||= account.inboxes.find(params[:id])
def user = @user ||= account.users.find(params[:id])
def label = @label ||= account.labels.find(params[:id])
def team = @team ||= account.teams.find(params[:id])
def metric
params[:metric].to_s
end
def report_metric
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
end
def raw_event_name
report_metric&.raw_event_name
end
def raw_count_strategy
report_metric&.raw_count_strategy
end
def record_type
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
'conversation'
end
def message_metric?
MESSAGE_METRICS.key?(metric)
end
def conversation_metric?
metric == 'conversations_count'
end
def dimension_type
(params[:type].presence || 'account').to_s
end
def group_by
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
def current_page
[params[:page].to_i, DEFAULT_PAGE].max
end
def per_page
requested_per_page = params[:per_page].to_i
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
[requested_per_page, MAX_PER_PAGE].min
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
end
@@ -1,199 +0,0 @@
class V2::Reports::DrilldownRecordSerializer
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
attr_reader :account, :metric, :use_business_hours, :records
def initialize(account, metric, use_business_hours, records = [])
@account = account
@metric = metric
@use_business_hours = use_business_hours
@records = records
end
def serialize(record)
return serialize_message(record) if record.is_a?(Message)
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
serialize_conversation(record)
end
private
def serialize_message(message, metric_value: nil, occurred_at: nil)
{
record_type: 'message',
conversation: conversation_attributes(message.conversation),
message: message_attributes(message),
metric_value: metric_value,
occurred_at: (occurred_at || message.created_at).to_i
}
end
def serialize_conversation_event(event)
inferred_message = inferred_message_for(event)
if inferred_message.present?
return serialize_message(
inferred_message,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event)
)
end
serialize_conversation(
event.conversation,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event),
event_name: event.name
)
end
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
serialized_record = {
record_type: 'conversation',
conversation: conversation_attributes(conversation),
message: nil,
metric_value: metric_value,
occurred_at: (occurred_at || conversation&.created_at)&.to_i
}
serialized_record[:event_name] = event_name if event_name.present?
serialized_record
end
def conversation_attributes(conversation)
return {} if conversation.blank?
{
id: conversation.id,
display_id: conversation.display_id,
contact_id: conversation.contact_id,
contact_name: conversation.contact&.name,
inbox_id: conversation.inbox_id,
inbox_name: conversation.inbox&.name,
assignee_id: conversation.assignee_id,
assignee_name: conversation.assignee&.name,
status: conversation.status,
created_at: conversation.created_at.to_i,
last_activity_at: conversation.last_activity_at.to_i,
last_message: last_message_attributes(conversation)
}
end
def message_attributes(message)
{
id: message.id,
content: message.content,
message_type: message.message_type,
sender_name: message.sender&.try(:name),
created_at: message.created_at.to_i
}
end
def last_message_attributes(conversation)
message = latest_messages_by_conversation_id[conversation.id]
return if message.blank?
message_attributes(message)
end
def inferred_message_for(event)
return unless MESSAGE_EVENT_METRICS.include?(metric)
return if event.conversation.blank? || event.event_end_time.blank?
inferred_messages_by_event_id[event.id]
end
def first_response_event_with_user?(event)
metric == 'avg_first_response_time' && event.user_id.present?
end
def message_inference_range(event)
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
end
def event_metric_value(event)
use_business_hours ? event.value_in_business_hours : event.value
end
def event_timestamp(event)
event.event_end_time || event.created_at
end
def latest_messages_by_conversation_id
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
{}
else
latest_messages.index_by(&:conversation_id)
end
end
def latest_messages
Message
.where(account_id: account.id, conversation_id: conversation_ids)
.where.not(message_type: :activity)
.select('DISTINCT ON (messages.conversation_id) messages.*')
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
.includes(:sender)
end
def inferred_messages_by_event_id
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
message_matches_event?(message, event)
end
end
end
def inferred_message_candidates
@inferred_message_candidates ||= if inference_events.blank?
[]
else
inferred_messages.to_a
end
end
def inferred_messages
Message
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
.where(created_at: inference_time_range)
.where(message_type: %i[outgoing template])
.includes(:sender)
.reorder(created_at: :desc, id: :desc)
end
def message_matches_event?(message, event)
message.conversation_id == event.conversation_id &&
message.created_at.between?(
message_inference_range(event).begin,
message_inference_range(event).end
) &&
message_sender_matches_event?(message, event)
end
def message_sender_matches_event?(message, event)
return true unless first_response_event_with_user?(event)
message.sender_id == event.user_id && message.sender_type == 'User'
end
def inference_time_range
event_end_times = inference_events.map(&:event_end_time)
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
end
def inference_events
@inference_events ||= records.select do |record|
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
end
end
def conversation_ids
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
end
def conversation_id_for(record)
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
record.id
end
end
@@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
render json: { positions: positions }
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private
@@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save!
render json: preferences_payload
@@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models).compact_blank.presence
existing_models.merge(permitted_captain_models)
end
def merged_captain_features
@@ -61,16 +61,13 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
provider: route[:provider],
source: route[:source]
selected: account_models[feature_key] || config[:default]
)
end
end
@@ -52,9 +52,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
render json: { content: translated_content }
rescue Google::Cloud::Error => e
# `details` carries the clean human message; `message` includes gRPC debug noise
render_could_not_create_error(e.details.presence || e.message)
end
private
@@ -51,13 +51,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
end
def drilldown
return head :unauthorized unless Current.account_user.administrator?
return head :unprocessable_entity unless valid_drilldown_params?
render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build
end
def conversations
return head :unprocessable_entity if params[:type].blank?
@@ -140,22 +133,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
})
end
def drilldown_params
permitted_params = params.permit(
:metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page
).to_h.symbolize_keys
permitted_params.merge(
type: (params[:type].presence || 'account').to_sym,
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
)
end
def valid_drilldown_params?
%i[metric bucket_timestamp since until].all? { |param| params[param].present? } &&
Reports::ReportMetricRegistry.supported?(params[:metric]) &&
V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params)
end
def conversation_params
{
type: params[:type].to_sym,
@@ -35,25 +35,11 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
expected_identifier = hmac_identifier
return false if expected_identifier.blank?
return false unless params[:identifier].to_s == expected_identifier
expected_hash = OpenSSL::HMAC.hexdigest(
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
expected_identifier
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 hmac_identifier
return params[:identifier].to_s if @contact_inbox.blank?
@contact_inbox.contact.identifier.to_s
end
def permitted_params
@@ -35,8 +35,7 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
#
def resource_params
permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params
end
+1 -4
View File
@@ -18,7 +18,6 @@ class AccountDashboard < Administrate::BaseDashboard
# Add all_features last so it appears after manually_managed_features
attributes[:all_features] = AccountFeaturesField
attributes[:captain_models] = CaptainModelOverridesField
attributes
else
@@ -58,7 +57,6 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[custom_attributes limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -81,7 +79,6 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -120,7 +117,7 @@ class AccountDashboard < Administrate::BaseDashboard
# to prevent an error from being raised (wrong number of arguments)
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
def permitted_attributes(action)
attrs = super + [limits: {}, captain_models: {}]
attrs = super + [limits: {}]
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
-12
View File
@@ -22,16 +22,4 @@ module BillingHelper
def agents(account)
account.users.count
end
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
def subscription_period_end(subscription)
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
end
def subscription_ends_on(subscription)
period_end = subscription_period_end(subscription)
return if period_end.blank?
Time.zone.at(period_end)
end
end
@@ -14,10 +14,6 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}subscription`);
}
selectBillingCurrency(currency) {
return axios.post(`${this.url}select_billing_currency`, { currency });
}
getLimits() {
return axios.get(`${this.url}limits`);
}
@@ -31,11 +27,6 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
// Topup packages for the account's billing currency.
getTopupOptions() {
return axios.get(`${this.url}topup_options`);
}
}
export default new EnterpriseAccountAPI();
-36
View File
@@ -31,42 +31,6 @@ class ReportsAPI extends ApiClient {
});
}
getDrilldown({
metric,
bucketTimestamp,
from,
to,
type = 'account',
id,
groupBy,
businessHours,
page,
perPage,
signal,
}) {
const requestConfig = {
params: {
metric,
bucket_timestamp: bucketTimestamp,
since: from,
until: to,
type,
id,
group_by: groupBy,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
page,
per_page: perPage,
},
};
if (signal) {
requestConfig.signal = signal;
}
return axios.get(`${this.url}/drilldown`, requestConfig);
}
// eslint-disable-next-line default-param-last
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
return axios.get(`${this.url}/summary`, {
@@ -1,8 +1,6 @@
import reportsAPI from '../reports';
import ApiClient from '../ApiClient';
const timezoneOffset = () => -new Date().getTimezoneOffset() / 60;
describe('#Reports API', () => {
it('creates correct instance', () => {
expect(reportsAPI).toBeInstanceOf(ApiClient);
@@ -13,7 +11,6 @@ describe('#Reports API', () => {
expect(reportsAPI).toHaveProperty('update');
expect(reportsAPI).toHaveProperty('delete');
expect(reportsAPI).toHaveProperty('getReports');
expect(reportsAPI).toHaveProperty('getDrilldown');
expect(reportsAPI).toHaveProperty('getSummary');
expect(reportsAPI).toHaveProperty('getAgentReports');
expect(reportsAPI).toHaveProperty('getLabelReports');
@@ -45,14 +42,11 @@ describe('#Reports API', () => {
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
params: {
business_hours: undefined,
group_by: undefined,
id: undefined,
metric: 'conversations_count',
since: 1621103400,
until: 1621621800,
type: 'account',
timezone_offset: timezoneOffset(),
timezone_offset: -0,
},
});
});
@@ -65,70 +59,13 @@ describe('#Reports API', () => {
group_by: undefined,
id: undefined,
since: 1621103400,
timezone_offset: timezoneOffset(),
timezone_offset: -0,
type: 'account',
until: 1621621800,
},
});
});
it('#getDrilldown', () => {
reportsAPI.getDrilldown({
metric: 'incoming_messages_count',
bucketTimestamp: 1621103400,
from: 1621103400,
to: 1621621800,
type: 'inbox',
id: 1,
groupBy: 'day',
businessHours: false,
page: 2,
perPage: 25,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
params: {
metric: 'incoming_messages_count',
bucket_timestamp: 1621103400,
since: 1621103400,
until: 1621621800,
type: 'inbox',
id: 1,
group_by: 'day',
business_hours: false,
timezone_offset: timezoneOffset(),
page: 2,
per_page: 25,
},
});
});
it('#getDrilldown with abort signal', () => {
const controller = new AbortController();
reportsAPI.getDrilldown({
metric: 'incoming_messages_count',
bucketTimestamp: 1621103400,
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
params: {
metric: 'incoming_messages_count',
bucket_timestamp: 1621103400,
since: undefined,
until: undefined,
type: 'account',
id: undefined,
group_by: undefined,
business_hours: undefined,
timezone_offset: timezoneOffset(),
page: undefined,
per_page: undefined,
},
signal: controller.signal,
});
});
it('#getAgentReports', () => {
reportsAPI.getAgentReports({
from: 1621103400,
@@ -16,10 +16,6 @@ const props = defineProps({
type: Array,
required: true,
},
contact: {
type: Object,
required: true,
},
});
const { t } = useI18n();
@@ -53,9 +49,7 @@ const unreadMessagesCount = computed(() => {
const hasSlaThreshold = computed(() => {
return (
!props.contact?.blocked &&
slaCardLabelRef.value?.hasSlaThreshold &&
props.conversation?.appliedSla?.id
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
);
});
@@ -126,7 +126,6 @@ const onCardClick = e => {
v-show="!showMessagePreviewWithoutMeta"
ref="cardMessagePreviewWithMetaRef"
:conversation="conversation"
:contact="contact"
:account-labels="accountLabels"
/>
</div>
@@ -51,9 +51,7 @@ const unreadCount = computed(() => props.chat.unread_count);
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() =>
!props.currentContact?.blocked &&
(props.chat?.applied_sla?.id || slaCardLabel.value?.hasSlaThreshold)
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const selectedModel = computed({
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { evaluateSLAStatus } from '@chatwoot/utils';
const props = defineProps({
conversation: {
@@ -19,8 +19,16 @@ const slaStatus = ref({
icon: null,
});
// TODO: Remove this once we update the helper from utils
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
const convertObjectCamelCaseToSnakeCase = object => {
return Object.keys(object).reduce((acc, key) => {
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
return acc;
}, {});
};
const appliedSLA = computed(() => props.conversation?.appliedSla);
const slaEvents = computed(() => props.conversation?.slaEvents);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const hasSlaThreshold = computed(() => {
@@ -33,9 +41,8 @@ const slaStatusText = computed(() => {
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
chat: props.conversation,
slaEvents: slaEvents.value || [],
});
};
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { evaluateSLAStatus } from '@chatwoot/utils';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
@@ -27,7 +27,6 @@ defineOptions({
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
@@ -35,7 +34,6 @@ const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: props.chat,
slaEvents: slaEvents.value || [],
});
};
@@ -1,378 +0,0 @@
<script setup>
import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
import { useEventListener, onKeyStroke, useRafFn } from '@vueuse/core';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// Reorderable list with cross-page drag. It must stay mounted during a page
// fetch — hide the list spinner while `dragging`, or the held item is lost.
const props = defineProps({
items: { type: Array, required: true },
itemKey: { type: String, default: 'id' },
disabled: { type: Boolean, default: false },
currentPage: { type: Number, default: 1 },
totalPages: { type: Number, default: 1 },
});
const emit = defineEmits(['reorder', 'navigatePage', 'dragging']);
const DRAG_THRESHOLD = 5;
const EDGE_BAND = 72; // px from a side that arms a page turn
const AUTO_PAGE_DELAY = 600; // ms hovering an edge before it flips
const SCROLL_BAND = 60; // px from top/bottom that autoscrolls the list
const SCROLL_STEP = 10; // px scrolled per frame while parked at an edge
const PILL = 36; // px, the page-turn arrow button on each edge
const root = ref(null);
const isDragging = ref(false);
const dragged = ref(null);
const pointer = ref({ x: 0, y: 0 });
const insertBefore = ref(null); // key the gap sits before, null = end of list
const activeEdge = ref(null);
const bounds = ref({ left: 0, right: 0, top: 0, bottom: 0 }); // visible list rect
const grab = ref({ dx: 0, dy: 0 }); // cursor offset inside the grabbed card
const dragHeight = ref(0);
let press = null; // pending press, before it becomes a drag
let dwell = null; // timer that flips the page after hovering an edge
let paging = false; // waiting for a flipped page to load
let scroller = null; // scrollable ancestor, found when a drag begins
let scrollDir = 0; // -1 up, +1 down, 0 idle
let sourcePage = 1; // page the drag started on, to resolve cross-page end-drops
const keyOf = item => String(item?.[props.itemKey]);
const others = item => props.items.filter(o => keyOf(o) !== keyOf(item));
const isRTL = () =>
document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl';
const canPage = dir =>
dir === 'next' ? props.currentPage < props.totalPages : props.currentPage > 1;
// Key of the item after `key` in `list`, or null when it is the last one.
const keyAfter = (list, key) => {
const next = list[list.findIndex(o => keyOf(o) === key) + 1];
return next ? keyOf(next) : null;
};
const edges = computed(() =>
['prev', 'next'].filter(canPage).map(dir => {
const onLeft = isRTL() ? dir === 'next' : dir === 'prev';
return {
dir,
onLeft,
icon: onLeft ? 'i-lucide-chevrons-left' : 'i-lucide-chevrons-right',
};
})
);
// Cursor overlay: an edge glow band, a page-turn arrow per edge, and a ghost of
// the dragged row that follows the cursor and shrinks when aimed at an edge.
const EDGE_BAND_W = 80; // px width of the glow band on each pageable edge
const bandStyle = onLeft => {
const x = onLeft ? bounds.value.left : bounds.value.right - EDGE_BAND_W;
return {
width: `${EDGE_BAND_W}px`,
height: `${bounds.value.bottom - bounds.value.top}px`,
transform: `translate(${x}px, ${bounds.value.top}px)`,
};
};
const pillStyle = onLeft => {
const x = onLeft ? bounds.value.left + 12 : bounds.value.right - PILL - 12;
return { transform: `translate(${x}px, ${pointer.value.y - PILL / 2}px)` };
};
const ghostStyle = computed(() => ({
width: `${bounds.value.right - bounds.value.left}px`,
transform: `translate(${pointer.value.x - grab.value.dx}px, ${pointer.value.y - grab.value.dy}px)`,
}));
const scaleStyle = computed(() => ({
transformOrigin: `${grab.value.dx}px ${grab.value.dy}px`,
}));
// The list as shown mid-drag: the dragged row slotted into the gap at `insertBefore`.
const displayItems = computed(() => {
if (!isDragging.value || !dragged.value) return props.items;
const rest = others(dragged.value);
const at = rest.findIndex(o => keyOf(o) === insertBefore.value);
rest.splice(at === -1 ? rest.length : at, 0, dragged.value);
return rest;
});
// Move the gap when the cursor crosses the midpoint of the card it is over.
const aim = () => {
const card = document
.elementFromPoint(pointer.value.x, pointer.value.y)
?.closest('[data-drag-id]');
const key = card?.dataset.dragId;
if (!key || key === keyOf(dragged.value)) return;
const { top, height } = card.getBoundingClientRect();
const above = pointer.value.y < top + height / 2;
insertBefore.value = above ? key : keyAfter(others(dragged.value), key);
};
const flip = dir => {
if (paging || !isDragging.value) return;
paging = true;
emit('navigatePage', props.currentPage + (dir === 'next' ? 1 : -1));
};
const aimEdge = x => {
const rect = root.value?.getBoundingClientRect();
if (!rect) return;
// Clip the list rect to the scroll viewport so the glow band and its top/bottom
// fade always sit on the visible edges, not the far ends of the full content.
const view = scroller?.getBoundingClientRect();
const viewTop = Math.max(view?.top ?? 0, 0);
const viewBottom = Math.min(
view?.bottom ?? window.innerHeight,
window.innerHeight
);
bounds.value = {
left: rect.left,
right: rect.right,
top: Math.max(rect.top, viewTop),
bottom: Math.min(rect.bottom, viewBottom),
};
let dir = null;
if (x <= rect.left + EDGE_BAND) dir = isRTL() ? 'next' : 'prev';
else if (x >= rect.right - EDGE_BAND) dir = isRTL() ? 'prev' : 'next';
if (dir && !canPage(dir)) dir = null;
if (dir === activeEdge.value) return;
activeEdge.value = dir;
clearTimeout(dwell);
if (dir) dwell = setTimeout(() => flip(dir), AUTO_PAGE_DELAY);
};
// Nearest scrollable ancestor, so a drag can reach rows that are off-screen.
const scrollParent = () => {
let el = root.value?.parentElement;
while (el) {
const { overflowY } = getComputedStyle(el);
if (overflowY === 'auto' || overflowY === 'scroll') return el;
el = el.parentElement;
}
return null;
};
// While parked at the top/bottom edge, keep scrolling and re-aim as rows slide by.
const { pause: pauseScroll, resume: resumeScroll } = useRafFn(
() => {
if (!scroller || !scrollDir) return;
scroller.scrollTop += scrollDir * SCROLL_STEP;
aim();
aimEdge(pointer.value.x);
},
{ immediate: false }
);
const updateAutoScroll = y => {
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
const atTop = scroller.scrollTop <= 0;
const atBottom =
scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
if (y < rect.top + SCROLL_BAND && !atTop) scrollDir = -1;
else if (y > rect.bottom - SCROLL_BAND && !atBottom) scrollDir = 1;
else scrollDir = 0;
if (scrollDir) resumeScroll();
else pauseScroll();
};
const reset = () => {
isDragging.value = false;
dragged.value = null;
insertBefore.value = null;
activeEdge.value = null;
clearTimeout(dwell);
paging = false;
scrollDir = 0;
scroller = null;
pauseScroll();
document.body.classList.remove('select-none');
emit('dragging', false);
};
// Positions sit on a gap-of-10 grid; a midpoint (±5 at the ends) slots between two rows.
// Cross-page end-drops are the tricky case: moving an item off its source page pulls the
// target page's boundary row into the vacated slot. So at the leading edge after moving
// DOWN (or the trailing edge after moving UP) we land between the two boundary rows, or
// the item sorts onto the adjacent page and vanishes from view.
const drop = (item, rawBefore) => {
const pos = o => o?.position || 0;
const mid = (a, b) => Math.floor((pos(a) + pos(b)) / 2);
const rest = others(item);
// No other rows to position against — e.g. the lone article on a page dropped
// without crossing to another page. Leave the order untouched.
if (!rest.length) return;
// A page flip can leave the aimed key pointing at a row that is no longer on
// this page. Like displayItems, resolve an unknown key to null (end of list),
// so what the user sees and what we save agree.
const before = rest.some(o => keyOf(o) === rawBefore) ? rawBefore : null;
const movedDown = props.currentPage > sourcePage;
const movedUp = props.currentPage < sourcePage;
let position;
if (before === null) {
position =
movedUp && rest.length >= 2
? mid(rest.at(-2), rest.at(-1))
: pos(rest.at(-1)) + 5;
} else {
const i = rest.findIndex(o => keyOf(o) === before);
if (i > 0) {
position = mid(rest[i - 1], rest[i]);
} else if (movedDown) {
// Top-of-page after moving down: the old first row slid up into the source
// page's gap, so land just after it to stay this page's first.
position = rest.length >= 2 ? mid(rest[0], rest[1]) : pos(rest[0]) + 5;
} else {
position = pos(rest[0]) - 5;
}
}
emit('reorder', { [item[props.itemKey]]: position });
};
const startDrag = () => {
isDragging.value = true;
dragged.value = press.item;
grab.value = { dx: press.dx, dy: press.dy };
dragHeight.value = press.h;
insertBefore.value = keyAfter(props.items, keyOf(press.item));
sourcePage = props.currentPage;
scroller = scrollParent();
document.body.classList.add('select-none');
emit('dragging', true);
};
const onPointerDown = (item, e) => {
if (e.button !== 0 || props.disabled) return;
if (e.target.closest('button, a, input, [role="button"]')) return;
const rect = e.currentTarget.getBoundingClientRect();
press = {
item,
x: e.clientX,
y: e.clientY,
dx: e.clientX - rect.left,
dy: e.clientY - rect.top,
h: rect.height,
};
};
useEventListener(window, 'pointermove', e => {
if (!press) return;
if (!isDragging.value) {
const moved = Math.hypot(e.clientX - press.x, e.clientY - press.y);
if (moved < DRAG_THRESHOLD) return;
startDrag();
}
e.preventDefault();
pointer.value = { x: e.clientX, y: e.clientY };
aim();
aimEdge(e.clientX);
updateAutoScroll(e.clientY);
});
useEventListener(window, 'pointerup', () => {
if (!press) return;
press = null;
if (!isDragging.value) return; // a press without movement is a click
const item = dragged.value;
const before = insertBefore.value;
const flipping = paging;
reset();
if (!flipping) drop(item, before);
});
// When the flipped page loads, re-aim under the held cursor so a parked edge keeps flipping.
watch(
() => props.items,
() => {
if (!isDragging.value || !paging) return;
paging = false;
nextTick(() => {
if (!isDragging.value) return;
aim();
activeEdge.value = null;
aimEdge(pointer.value.x);
});
}
);
onKeyStroke('Escape', () => {
if (!isDragging.value) return;
press = null;
reset();
});
onBeforeUnmount(() => {
clearTimeout(dwell);
document.body.classList.remove('select-none');
});
</script>
<template>
<div ref="root" class="relative w-full h-full">
<ul class="w-full h-full space-y-4">
<li
v-for="(item, index) in displayItems"
:key="keyOf(item)"
:data-drag-id="keyOf(item)"
class="relative list-none"
:class="{ 'cursor-grab': !disabled && !isDragging }"
@pointerdown="onPointerDown(item, $event)"
@dragstart.prevent
>
<div
v-if="isDragging && keyOf(item) === keyOf(dragged)"
:style="{ height: `${dragHeight}px` }"
class="border-2 border-dashed rounded-2xl border-n-brand/50 bg-n-brand/5"
/>
<slot v-else name="item" :item="item" :index="index" />
</li>
</ul>
<Teleport v-if="isDragging" to="body">
<div
v-for="edge in edges"
:key="`band-${edge.dir}`"
:style="bandStyle(edge.onLeft)"
class="fixed top-0 left-0 z-40 pointer-events-none from-n-brand/15 to-transparent transition-opacity duration-200 [mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)] [-webkit-mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)]"
:class="[
edge.onLeft ? 'bg-gradient-to-r' : 'bg-gradient-to-l',
activeEdge === edge.dir ? 'opacity-100' : 'opacity-0',
]"
/>
<div
v-for="edge in edges"
:key="edge.dir"
:style="pillStyle(edge.onLeft)"
class="fixed top-0 left-0 z-50 flex items-center justify-center transition-all duration-150 border rounded-full pointer-events-none size-9 backdrop-blur-sm"
:class="
activeEdge === edge.dir
? 'scale-110 border-n-brand bg-n-brand/20 text-n-brand shadow-md'
: 'opacity-70 border-n-weak/60 bg-n-solid-1/70 text-n-slate-10'
"
>
<Icon
:icon="edge.icon"
class="size-4"
:class="activeEdge === edge.dir && 'animate-pulse'"
/>
</div>
<div
v-if="dragged"
:style="ghostStyle"
class="fixed top-0 left-0 z-50 pointer-events-none select-none"
>
<div
:style="scaleStyle"
class="transition-transform duration-150 shadow-2xl rounded-2xl"
:class="{ 'scale-50': activeEdge }"
>
<slot name="ghost" :item="dragged" />
</div>
</div>
</Teleport>
</div>
</template>
@@ -1,222 +0,0 @@
import { mount } from '@vue/test-utils';
import { h, nextTick } from 'vue';
import DraggableReorderList from '../DraggableReorderList.vue';
// The component is pointer-driven, so we drive it through real pointer events on
// window while mocking the layout APIs jsdom does not implement: elementFromPoint
// (which card is under the cursor) and getBoundingClientRect (its geometry).
const elementAtPoint = { current: null };
const move = (clientX, clientY) =>
window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY }));
const release = () => window.dispatchEvent(new MouseEvent('pointerup'));
// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list.
const stubGeometry = wrapper => {
wrapper.element.getBoundingClientRect = () => ({
left: 0,
right: 500,
top: 0,
bottom: 600,
});
wrapper.findAll('[data-drag-id]').forEach((li, index) => {
const top = index * 50;
li.element.getBoundingClientRect = () => ({
top,
height: 40,
bottom: top + 40,
});
});
};
const mountList = (props = {}) =>
mount(DraggableReorderList, {
props: { items: [], ...props },
slots: {
item: scope => h('div', { class: 'card' }, scope.item.title),
ghost: scope => h('div', { class: 'ghost' }, scope.item.title),
},
global: { stubs: { Icon: true, teleport: true } },
});
describe('DraggableReorderList', () => {
let wrapper;
beforeEach(() => {
elementAtPoint.current = null;
document.elementFromPoint = vi.fn(() => elementAtPoint.current);
});
afterEach(() => {
wrapper?.unmount();
vi.useRealTimers();
});
const startDragging = async id => {
stubGeometry(wrapper);
wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent(
new MouseEvent('pointerdown', {
button: 0,
clientX: 250,
clientY: 20,
bubbles: true,
})
);
await nextTick();
};
it('renders each item through the item slot', () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha' },
{ id: 2, title: 'Beta' },
],
});
const cards = wrapper.findAll('.card');
expect(cards).toHaveLength(2);
expect(cards[0].text()).toBe('Alpha');
expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true);
expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true);
});
it('shows a grab affordance only when enabled', () => {
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] });
expect(wrapper.find('[data-drag-id="1"]').classes()).toContain(
'cursor-grab'
);
wrapper.unmount();
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true });
expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain(
'cursor-grab'
);
});
it('does not start a drag when disabled', async () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha' },
{ id: 2, title: 'Beta' },
],
disabled: true,
});
await startDragging(1);
move(250, 200);
await nextTick();
expect(wrapper.emitted('dragging')).toBeUndefined();
});
it('emits dragging true then false across a drag', async () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha' },
{ id: 2, title: 'Beta' },
],
});
await startDragging(1);
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
move(250, 60);
await nextTick();
expect(wrapper.emitted('dragging')[0]).toEqual([true]);
release();
await nextTick();
expect(wrapper.emitted('dragging')[1]).toEqual([false]);
});
it('emits the midpoint position when dropped between two rows', async () => {
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha', position: 10 },
{ id: 2, title: 'Beta', position: 20 },
{ id: 3, title: 'Gamma', position: 30 },
],
});
await startDragging(1);
// Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap
// sits before Gamma; dropping there lands halfway between Beta and Gamma.
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
move(250, 85);
await nextTick();
release();
await nextTick();
expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 });
});
it('does not reorder when the only row on a page is dropped in place', async () => {
// P1: dragging the lone article on a later page and releasing without
// crossing to another page must be a no-op, not move it to the top.
wrapper = mountList({
items: [{ id: 5, title: 'Solo', position: 260 }],
currentPage: 2,
totalPages: 2,
});
await startDragging(5);
move(250, 300);
await nextTick();
release();
await nextTick();
expect(wrapper.emitted('dragging')).toEqual([[true], [false]]);
expect(wrapper.emitted('reorder')).toBeUndefined();
});
it('turns the page after dwelling on a pageable edge', async () => {
vi.useFakeTimers();
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha', position: 10 },
{ id: 2, title: 'Beta', position: 20 },
],
currentPage: 1,
totalPages: 2,
});
await startDragging(1);
// Drag to the right edge over blank space (no card) and hold.
elementAtPoint.current = null;
move(490, 20);
await nextTick();
vi.advanceTimersByTime(600);
expect(wrapper.emitted('navigatePage')[0]).toEqual([2]);
});
it('can still turn pages after releasing during a pending flip', async () => {
// Releasing while a flip fetch is in flight must clear paging state, or every
// later drag would be stuck unable to navigate.
vi.useFakeTimers();
wrapper = mountList({
items: [
{ id: 1, title: 'Alpha', position: 10 },
{ id: 2, title: 'Beta', position: 20 },
],
currentPage: 1,
totalPages: 2,
});
// First drag: park at the edge to start a flip, then release before the new
// page arrives (items never change here).
await startDragging(1);
elementAtPoint.current = null;
move(490, 20);
await nextTick();
vi.advanceTimersByTime(600);
release();
await nextTick();
// Second drag must be able to flip again.
await startDragging(1);
elementAtPoint.current = null;
move(490, 20);
await nextTick();
vi.advanceTimersByTime(600);
expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]);
});
});
@@ -1,5 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { ref, computed, watch } from 'vue';
import Draggable from 'vuedraggable';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -9,7 +10,6 @@ import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import wootConstants from 'dashboard/constants/globals';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
const props = defineProps({
articles: {
@@ -28,22 +28,9 @@ const props = defineProps({
type: Boolean,
default: false,
},
currentPage: {
type: Number,
default: 1,
},
totalPages: {
type: Number,
default: 1,
},
});
const emit = defineEmits([
'translateArticle',
'toggleSelect',
'navigatePage',
'dragging',
]);
const emit = defineEmits(['translateArticle', 'toggleSelect']);
const { ARTICLE_STATUS_TYPES } = wootConstants;
@@ -52,14 +39,14 @@ const route = useRoute();
const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
const canReorder = props.articles?.length > 1 || props.totalPages > 1;
return (
props.isCategoryArticles &&
!props.isSearching &&
canReorder &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
});
@@ -76,10 +63,6 @@ const handleCardHover = (isHovered, id) => {
const getCategoryById = useMapGetter('categories/categoryById');
const getCategory = categoryId => {
return getCategoryById.value(categoryId) || { name: '', icon: '' };
};
const openArticle = id => {
const { tab, categorySlug, locale } = route.params;
if (props.isCategoryArticles) {
@@ -100,25 +83,38 @@ const openArticle = id => {
}
};
const onReorder = async positionsHash => {
const [movedId] = Object.keys(positionsHash);
// A same-page reorder updates optimistically in the store, so it needs no
// refetch. Only a cross-page drop must refresh, to pull the moved article
// onto this page in its new spot.
const isCrossPage = !props.articles.some(
article => String(article.id) === movedId
);
const onReorder = async reorderedGroup => {
try {
await store.dispatch('articles/reorder', {
reorderedGroup: positionsHash,
reorderedGroup,
portalSlug: route.params.portalSlug,
});
if (isCrossPage) emit('navigatePage', props.currentPage);
} catch {
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
}
};
const onDragEnd = () => {
// Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedArticlePositions = localArticles.value
.map((article, index) => article.position || index + 1)
.sort((a, b) => a - b);
const orderedArticles = localArticles.value.map(article => article.id);
// Create a map of article IDs to their new positions
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
obj[key] = sortedArticlePositions[index];
return obj;
}, {});
onReorder(reorderedGroup);
};
const getCategory = categoryId => {
return getCategoryById.value(categoryId) || { name: '', icon: '' };
};
const getStatusMessage = (status, isSuccess) => {
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
const statusMap = {
@@ -188,46 +184,54 @@ const updateArticle = ({ action, value, id }) => {
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
// Watch for changes in the articles prop and update the localArticles ref
watch(
() => props.articles,
newArticles => {
localArticles.value = newArticles;
},
{ deep: true }
);
</script>
<template>
<DraggableReorderList
:items="articles"
<Draggable
v-model="localArticles"
:disabled="!dragEnabled"
:current-page="currentPage"
:total-pages="totalPages"
@reorder="onReorder"
@navigate-page="page => emit('navigatePage', page)"
@dragging="value => emit('dragging', value)"
item-key="id"
tag="ul"
ghost-class="article-ghost-class"
class="w-full h-full space-y-4"
@end="onDragEnd"
>
<template #item="{ item }">
<ArticleCard
:id="item.id"
:title="item.title"
:status="item.status"
:author="item.author"
:category="getCategory(item.category.id)"
:views="item.views || 0"
:updated-at="item.updatedAt"
:is-selected="selectedArticleIds.has(item.id)"
selectable
:show-selection-control="shouldShowSelectionControl(item.id)"
@open-article="openArticle"
@article-action="updateArticle"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, item.id)"
/>
<template #item="{ element }">
<li class="list-none rounded-2xl">
<ArticleCard
:id="element.id"
:key="element.id"
:title="element.title"
:status="element.status"
:author="element.author"
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
:is-selected="selectedArticleIds.has(element.id)"
selectable
:show-selection-control="shouldShowSelectionControl(element.id)"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, element.id)"
/>
</li>
</template>
<template #ghost="{ item }">
<ArticleCard
:id="item.id"
:title="item.title"
:status="item.status"
:author="item.author"
:category="getCategory(item.category.id)"
:views="item.views || 0"
:updated-at="item.updatedAt"
/>
</template>
</DraggableReorderList>
</Draggable>
</template>
<style lang="scss" scoped>
.article-ghost-class {
@apply opacity-50 bg-n-solid-1;
}
</style>
@@ -70,7 +70,6 @@ const isFeatureEnabledonAccount = useMapGetter(
);
const selectedArticleIds = ref(new Set());
const isArticleDragging = ref(false);
const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const searchQuery = ref(route.query.search || '');
@@ -149,8 +148,6 @@ const articlesCount = computed(() => {
return Number(countMap[tab] || countMap['']);
});
const totalPages = computed(() => Math.ceil(articlesCount.value / 25) || 1);
const showArticleHeaderControls = computed(
() => !props.isCategoryArticles && !isSwitchingPortal.value
);
@@ -346,7 +343,7 @@ watch(
</template>
<template #content>
<div
v-if="isLoading && !isArticleDragging"
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
@@ -456,13 +453,9 @@ watch(
:is-category-articles="isCategoryArticles"
:is-searching="isSearching"
:selected-article-ids="selectedArticleIds"
:current-page="Number(meta.currentPage)"
:total-pages="totalPages"
class="relative z-0"
@translate-article="handleTranslateArticle"
@toggle-select="handleToggleSelect"
@navigate-page="handlePageChange"
@dragging="isArticleDragging = $event"
/>
</template>
<ArticleEmptyState
@@ -29,6 +29,7 @@ const initialState = {
handoffMessage: '',
resolutionMessage: '',
instructions: '',
temperature: 1,
};
const state = reactive({ ...initialState });
@@ -56,6 +57,7 @@ const updateStateFromAssistant = assistant => {
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.temperature = config.temperature || 1;
};
const handleSystemMessagesUpdate = async () => {
@@ -78,6 +80,7 @@ const handleSystemMessagesUpdate = async () => {
...props.assistant.config,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
temperature: state.temperature || 1,
},
};
@@ -128,6 +131,26 @@ watch(
class="z-0"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@@ -123,10 +123,6 @@ onMounted(() => {
a {
@apply p-4;
}
.ProseMirror a {
@apply p-0;
}
}
}
}
@@ -57,9 +57,7 @@ const showMetaSection = computed(() => {
);
});
const hasSlaPolicyId = computed(
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
@@ -93,9 +93,7 @@ const hasMultipleInboxes = computed(
() => store.getters['inboxes/getInboxes'].length > 1
);
const hasSlaPolicyId = computed(
() => props.chat?.applied_sla?.id && !currentContact.value?.blocked
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const copyConversationId = async () => {
try {
@@ -1,7 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
@@ -61,7 +61,6 @@ const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
slaEvents: slaEvents.value || [],
});
};
@@ -1,60 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import ConversationCard from '../ConversationCard.vue';
const defaultChat = {
id: 1,
labels: [],
messages: [],
priority: null,
unread_count: 0,
timestamp: 1700000000,
created_at: 1700000000,
};
const mountComponent = (chat, currentContact = {}) =>
shallowMount(ConversationCard, {
props: {
chat: { ...defaultChat, ...chat },
currentContact: {
name: 'Jane Doe',
thumbnail: '',
availability_status: 'offline',
...currentContact,
},
inbox: { id: 1 },
},
global: {
stubs: {
'fluent-icon': true,
},
},
});
describe('ConversationCard', () => {
it('does not reserve the labels row when only a persisted SLA policy id is present', () => {
const wrapper = mountComponent({ sla_policy_id: 1, applied_sla: null });
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(false);
});
it('shows the labels row when an active applied SLA is present', () => {
const wrapper = mountComponent({
sla_policy_id: 1,
applied_sla: { id: 1 },
});
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(true);
});
it('does not reserve the labels row when the contact is blocked', () => {
const wrapper = mountComponent(
{
sla_policy_id: 1,
applied_sla: { id: 1 },
},
{ blocked: true }
);
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(false);
});
});
@@ -161,7 +161,6 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
// 409 = the call already ended before accept landed (e.g. caller hung up mid-ring).
if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall();
markDismissed(callSid);
@@ -1,35 +0,0 @@
// Single source of truth for billing currencies on the frontend.
// Adding a currency = one entry in BILLING_CURRENCY_CONFIG, add the code to
// SUPPORTED_BILLING_CURRENCIES, and add its label key under
// BILLING_SETTINGS.CURRENCY.OPTIONS in the locale files.
export const DEFAULT_BILLING_CURRENCY = 'usd';
// Order here drives the order of the currency toggle in the UI.
export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl'];
export const BILLING_CURRENCY_CONFIG = {
usd: {
code: 'usd',
intlLocale: 'en-US',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD',
},
brl: {
code: 'brl',
intlLocale: 'pt-BR',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL',
},
};
export const getCurrencyConfig = code =>
BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] ||
BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY];
export const formatCurrencyAmount = (amount, code, options = {}) => {
const { intlLocale, code: currencyCode } = getCurrencyConfig(code);
return new Intl.NumberFormat(intlLocale, {
style: 'currency',
currency: currencyCode.toUpperCase(),
...options,
}).format(amount);
};
+1 -16
View File
@@ -188,16 +188,6 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'insertTable',
];
// [text](url) -> "text: url" (drop label if it equals the URL). Keep serializer
// escapes; the re-parse renders them literally, unescaping would crash it.
const flattenLink = (_match, text, url) => {
const cleanUrl = url
.trim()
.replace(/\s+["'(].*$/, '')
.replace(/^<|>$/g, '');
return text === cleanUrl ? cleanUrl : `${text}: ${cleanUrl}`;
};
/**
* Markdown formatting patterns for stripping unsupported formatting.
*
@@ -274,12 +264,7 @@ export const MARKDOWN_PATTERNS = [
{
type: 'link', // PM: link
patterns: [
// Escape-aware label + URL captures so a \] or \) can't cut the match
// short and leave link markup that crashes the re-parse.
{
pattern: /\[((?:\\.|[^\]\\])*)\]\(((?:\\.|[^)\\])*)\)/g,
replacement: flattenLink,
},
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <user@example.com> -> user@example.com
],
@@ -1,150 +0,0 @@
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
/**
* Formats seconds into a human-readable time string
* @param {number} seconds - The time in seconds (can be negative for overdue)
* @returns {string} Formatted time string like "2h 30m" or "1d 4h"
*/
const formatSLATime = seconds => {
const absSeconds = Math.abs(seconds);
const units = {
y: 31536000,
mo: 2592000,
d: 86400,
h: 3600,
m: 60,
};
if (absSeconds < 60) {
return '1m';
}
const parts = [];
let remaining = absSeconds;
Object.entries(units).forEach(([unit, value]) => {
if (parts.length >= 2) return;
const count = Math.floor(remaining / value);
if (count > 0) {
parts.push(`${count}${unit}`);
remaining -= count * value;
}
});
return parts.join(' ');
};
const toUnixTimestamp = value => {
if (!value || typeof value === 'number') return value;
const numericValue = Number(value);
if (!Number.isNaN(numericValue)) return numericValue;
const parsedTimestamp = Date.parse(value);
return Number.isNaN(parsedTimestamp)
? value
: Math.floor(parsedTimestamp / 1000);
};
/**
* Evaluates SLA status using backend-computed due times
* @param {Object} params - Parameters object
* @param {Object} params.appliedSla - The applied SLA with due_at timestamps
* @param {Object} params.chat - The conversation object
* @param {Array} params.slaEvents - Recorded SLA miss events for this conversation
* @returns {Object} SLA status with type, threshold, icon, and isSlaMissed
*/
export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
const emptyStatus = { type: '', threshold: '', icon: '', isSlaMissed: false };
if (!appliedSla || !chat) {
return emptyStatus;
}
const sla = useCamelCase(appliedSla);
const conversation = useCamelCase(chat);
const events = useCamelCase(slaEvents || []);
const currentTime = Math.floor(Date.now() / 1000);
const slaStatuses = [];
const dueAtByType = {
FRT: sla.slaFrtDueAt,
RT: sla.slaRtDueAt,
};
const slaTypes = ['FRT', 'NRT', 'RT'];
events.forEach(event => {
const type = event.eventType?.toUpperCase();
if (!slaTypes.includes(type)) return;
const missedAt =
type === 'NRT' ? event.createdAt : dueAtByType[type] || event.createdAt;
if (!missedAt) return;
slaStatuses.push({
type,
threshold: missedAt - currentTime,
icon: 'flame',
isSlaMissed: true,
});
});
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
const shouldCheckFirstResponse =
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
// Check FRT - until first reply is made on time
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
const threshold = sla.slaFrtDueAt - currentTime;
slaStatuses.push({
type: 'FRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check NRT - only if first reply made and waiting for response
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
const threshold = sla.slaNrtDueAt - currentTime;
slaStatuses.push({
type: 'NRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check RT - only if conversation is unresolved
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
const threshold = sla.slaRtDueAt - currentTime;
slaStatuses.push({
type: 'RT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
if (slaStatuses.length === 0) {
return emptyStatus;
}
// Show existing breaches before upcoming deadlines, then pick the closest timer.
slaStatuses.sort((a, b) => {
if (a.isSlaMissed !== b.isSlaMissed) {
return a.isSlaMissed ? -1 : 1;
}
return Math.abs(a.threshold) - Math.abs(b.threshold);
});
const mostUrgent = slaStatuses[0];
return {
type: mostUrgent.type,
threshold: formatSLATime(mostUrgent.threshold),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
};
};
@@ -1,9 +1,4 @@
import {
EditorState,
EditorView,
buildMessageSchema,
MessageMarkdownTransformer,
} from '@chatwoot/prosemirror-schema';
import { EditorState, EditorView } from '@chatwoot/prosemirror-schema';
import { FORMATTING } from 'dashboard/constants/editor';
import { Schema } from 'prosemirror-model';
import {
@@ -983,77 +978,13 @@ describe('stripUnsupportedFormatting', () => {
);
});
it('keeps link text and URL when schema does not support links', () => {
it('strips links but keeps text', () => {
expect(
stripUnsupportedFormatting(
'Check [this link](https://example.com)',
emptySchema
)
).toBe('Check this link: https://example.com');
});
it('drops the hidden link title when preserving the URL', () => {
expect(
stripUnsupportedFormatting(
'Check [docs](https://example.com "Docs")',
emptySchema
)
).toBe('Check docs: https://example.com');
expect(
stripUnsupportedFormatting(
'Check [docs](<https://example.com> "Docs")',
emptySchema
)
).toBe('Check docs: https://example.com');
});
// Output is re-parsed before sending, so assert the final text
// (strip + re-parse); the re-parse turns serializer escapes into literals.
describe('links round-trip through re-parse without crashing', () => {
const smsSchema = buildMessageSchema([], []); // no marks, no nodes
const sendAs = md =>
new MessageMarkdownTransformer(smsSchema).parse(
stripUnsupportedFormatting(md, smsSchema)
).textContent;
it('keeps escaped parens/underscores anywhere in the URL', () => {
expect(
sendAs('See [wiki](https://en.wikipedia.org/wiki/Foo\\_\\(bar\\))')
).toBe('See wiki: https://en.wikipedia.org/wiki/Foo_(bar)');
expect(sendAs('See [wiki](https://host/a\\_\\(b\\)c)')).toBe(
'See wiki: https://host/a_(b)c'
);
});
it('drops the label when it equals the URL even when escaped', () => {
expect(
sendAs(
'[www.example.com/Foo\\_\\(bar\\)](www.example.com/Foo\\_\\(bar\\))'
)
).toBe('www.example.com/Foo_(bar)');
});
it('does not reintroduce emphasis from an escaped label', () => {
expect(sendAs('[Use \\_id\\_](https://example.com)')).toBe(
'Use _id_: https://example.com'
);
});
it('flattens a label containing an escaped closing bracket', () => {
expect(sendAs('[FAQ \\[v2\\]](https://example.com)')).toBe(
'FAQ [v2]: https://example.com'
);
});
});
it('leaves bare URLs untouched so channels can auto-link them', () => {
expect(
stripUnsupportedFormatting('Visit www.example.com now', emptySchema)
).toBe('Visit www.example.com now');
expect(
stripUnsupportedFormatting('Visit <https://example.com>', emptySchema)
).toBe('Visit https://example.com');
).toBe('Check this link');
});
it('converts autolinks to plain URLs when schema does not support links', () => {
@@ -1118,7 +1049,7 @@ describe('stripUnsupportedFormatting', () => {
it('handles complex content with multiple formatting types', () => {
const content =
'**Bold** and *italic* with `code` and [link](url)\n- list item';
const expected = 'Bold and italic with code and link: url\nlist item';
const expected = 'Bold and italic with code and link\nlist item';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
});
@@ -1,450 +0,0 @@
import { evaluateSLAStatus } from '../slaHelper';
describe('#SLA Helpers', () => {
const currentTimestamp = 1700000000; // Fixed timestamp for testing
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(currentTimestamp * 1000);
});
afterEach(() => {
vi.useRealTimers();
});
describe('evaluateSLAStatus', () => {
describe('when inputs are invalid', () => {
it('returns empty status when appliedSla is null', () => {
const result = evaluateSLAStatus({ appliedSla: null, chat: {} });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
it('returns empty status when chat is null', () => {
const result = evaluateSLAStatus({ appliedSla: {}, chat: null });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
});
describe('FRT (First Response Time)', () => {
it('returns FRT status when first reply not made and within threshold', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 }; // 1 hour from now
const chat = { first_reply_created_at: null, status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
});
it('returns missed FRT status when threshold is exceeded', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp - 1800 }; // 30 min ago
const chat = { first_reply_created_at: null, status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('30m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return FRT when first reply was made before due time', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 };
const chat = {
first_reply_created_at: currentTimestamp - 1000,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).not.toBe('FRT');
});
it('does not return FRT when first reply was made at due time', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 600,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
});
it('returns missed FRT when first reply was made after due time', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 600,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: currentTimestamp - 300,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('10m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('returns missed FRT when live first reply timestamp is an ISO string after due time', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 600,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: new Date(
(currentTimestamp - 300) * 1000
).toISOString(),
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('10m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('uses the due time for a missed FRT event created after the deadline', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp - 3600 };
const chat = { first_reply_created_at: null, status: 'open' };
const slaEvents = [
{ event_type: 'frt', created_at: currentTimestamp - 1800 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
});
describe('NRT (Next Response Time)', () => {
it('returns NRT status when waiting for response and within threshold', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 }; // 30 min from now
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('30m');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
});
it('returns missed NRT status when threshold is exceeded', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp - 900 }; // 15 min ago
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 2700,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('15m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return NRT when not waiting for response', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: null,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).not.toBe('NRT');
});
it('returns missed NRT when a recorded NRT miss exists after response', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 1800 };
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: null,
status: 'open',
};
const slaEvents = [
{ event_type: 'nrt', created_at: currentTimestamp - 900 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('15m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('uses the recorded event time for a missed NRT with a new active NRT timer', () => {
const appliedSla = {
sla_nrt_due_at: currentTimestamp + 300,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 60,
status: 'open',
};
const slaEvents = [
{ event_type: 'nrt', created_at: currentTimestamp - 1800 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('30m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return NRT when first reply not made', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
const chat = {
first_reply_created_at: null,
waiting_since: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).not.toBe('NRT');
});
});
describe('RT (Resolution Time)', () => {
it('returns RT status when conversation is open and within threshold', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 }; // 2 hours from now
const chat = {
first_reply_created_at: currentTimestamp - 3600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('2h');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
});
it.each(['pending', 'snoozed'])(
'returns RT status when conversation is %s and within threshold',
status => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
const chat = {
first_reply_created_at: currentTimestamp - 3600,
status,
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('2h');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
}
);
it('returns missed RT status when threshold is exceeded', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 }; // 1 hour ago
const chat = {
first_reply_created_at: currentTimestamp - 7200,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('uses the due time for a missed RT event created after the deadline', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 };
const chat = {
first_reply_created_at: currentTimestamp - 7200,
status: 'open',
};
const slaEvents = [
{ event_type: 'rt', created_at: currentTimestamp - 1800 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return RT when conversation is resolved', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
const chat = {
first_reply_created_at: currentTimestamp - 3600,
status: 'resolved',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('');
});
});
describe('priority selection', () => {
it('returns most urgent SLA when multiple are active', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp + 7200, // 2h - less urgent
sla_nrt_due_at: currentTimestamp + 1800, // 30m - most urgent
sla_rt_due_at: currentTimestamp + 3600, // 1h
};
const chat = {
first_reply_created_at: null,
waiting_since: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
// FRT is selected because first_reply_created_at is null
// NRT is not checked when first_reply_created_at is null
expect(result.type).toBe('RT');
expect(result.threshold).toBe('1h');
});
it('returns most urgent missed SLA over upcoming SLA', () => {
const appliedSla = {
sla_nrt_due_at: currentTimestamp - 300, // 5m overdue - most urgent by absolute value
sla_rt_due_at: currentTimestamp + 3600, // 1h remaining
};
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 2100,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('NRT');
expect(result.isSlaMissed).toBe(true);
});
it('returns an existing missed SLA over a closer upcoming SLA', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 7200, // 2h overdue
sla_rt_due_at: currentTimestamp + 300, // 5m remaining
};
const chat = {
first_reply_created_at: null,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('2h');
expect(result.isSlaMissed).toBe(true);
});
});
describe('time formatting', () => {
it('formats time in days and hours', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
const chat = { status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.threshold).toBe('1d 1h');
});
it('formats time less than a minute as 1m', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp + 30 }; // 30 seconds
const chat = { first_reply_created_at: null, status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.threshold).toBe('1m');
});
it('formats months correctly', () => {
const appliedSla = {
sla_rt_due_at: currentTimestamp + 2592000 + 86400,
}; // 1 month + 1 day
const chat = { status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.threshold).toBe('1mo 1d');
});
});
describe('empty status scenarios', () => {
it('returns empty when no SLA thresholds are set', () => {
const appliedSla = {};
const chat = { status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
it('returns empty when all conditions are met', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp + 3600,
sla_nrt_due_at: currentTimestamp + 1800,
sla_rt_due_at: currentTimestamp + 7200,
};
const chat = {
first_reply_created_at: currentTimestamp - 3600, // FRT already hit
waiting_since: null, // Not waiting, so NRT not applicable
status: 'resolved', // RT not applicable
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
});
});
});
+1 -10
View File
@@ -1,7 +1,4 @@
import {
CONTENT_TYPES,
VOICE_CALL_STATUS,
} from 'dashboard/components-next/message/constants';
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types';
@@ -104,7 +101,6 @@ export function handleVoiceCallCreated(
callSid,
callId,
provider,
status,
callDirection,
conversationId,
inboxId,
@@ -112,11 +108,6 @@ export function handleVoiceCallCreated(
senderId,
} = extractCallData(message);
// A voice_call message can be created already terminal when the caller hangs
// up before connect. Only ring while the call is actually ringing; mirrors the
// guard in seedCallsFromHydratedMessages.
if (status !== VOICE_CALL_STATUS.RINGING) return;
if (
!shouldShowCall({
callDirection,
@@ -494,6 +494,10 @@
"PLACEHOLDER": "Enter assistant name",
"ERROR": "The name is required"
},
"TEMPERATURE": {
"LABEL": "Response Temperature",
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter assistant description",
@@ -121,26 +121,6 @@
"CLEAR_FILTER": "Clear filter",
"EMPTY_LIST": "No results found"
},
"DRILLDOWN": {
"TITLE": "{metric} details",
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations",
"RESULT_COUNT_MESSAGE": "{count} message | {count} messages",
"EMPTY": "No records found for this bar.",
"ERROR": "Could not load records. Please try again.",
"ADMIN_ONLY": "Only administrators can drill down into report records.",
"LOAD_MORE": "Load more",
"CLOSE": "Close details",
"PREVIOUS_BUCKET": "Previous bar",
"NEXT_BUCKET": "Next bar",
"UNKNOWN_CONTACT": "Unknown contact",
"UNKNOWN_INBOX": "Unknown inbox",
"UNASSIGNED_AGENT": "Unassigned",
"NO_MESSAGE_CONTENT": "No message content",
"MESSAGE_CREATED_AT": "Message created at {time}",
"EVENT_OCCURRED_AT": "Event occurred at {time}",
"INCOMING_MESSAGE": "Incoming message",
"OUTGOING_MESSAGE": "Outgoing message"
},
"PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results",
"PER_PAGE_TEMPLATE": "{size} / page"
@@ -440,9 +440,7 @@
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts.",
"MODEL_TITLE": "Audio Transcription Model",
"MODEL_DESCRIPTION": "Select the AI model to use for converting audio messages into text transcripts"
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
},
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
@@ -467,18 +465,7 @@
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
"SEAT_COUNT": "Number of seats",
"RENEWS_ON": "Renews on",
"CURRENCY": "Currency"
},
"CURRENCY": {
"SELECT": {
"TITLE": "Choose your billing currency",
"DESCRIPTION": "Select the currency you'd like to be billed in. This can't be changed once your subscription is created."
},
"OPTIONS": {
"USD": "US Dollar (USD)",
"BRL": "Brazilian Real (BRL)"
}
"RENEWS_ON": "Renews on"
},
"VIEW_PRICING": "View Pricing",
"MANAGE_SUBSCRIPTION": {
@@ -514,7 +501,6 @@
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"RETRY": "Retry",
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
@@ -6,7 +6,6 @@ import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
import {
ACCOUNT_EVENTS,
@@ -120,20 +119,16 @@ export default {
handleClose(e) {
this.$emit('close', e);
},
async handleTranslate() {
handleTranslate() {
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
const agentLocale = this.getUISettings?.locale;
const targetLanguage = agentLocale || accountLocale || 'en';
try {
await this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage,
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
} catch (error) {
useAlert(parseAPIErrorResponse(error));
}
this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage,
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
this.handleClose();
},
handleReplyTo() {
@@ -15,8 +15,6 @@ import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
import { getCurrencyConfig } from 'dashboard/constants/billing';
import { useI18n } from 'vue-i18n';
const router = useRouter();
const { currentAccount, isOnChatwootCloud } = useAccount();
@@ -31,7 +29,6 @@ const {
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const { t } = useI18n();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
@@ -39,10 +36,6 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
const isWaitingForBilling = ref(false);
const purchaseCreditsModalRef = ref(null);
// Currency selection shown to new accounts whose locale supports a non-USD currency.
const currencySelectionRequired = ref(false);
const currencyOptions = ref([]);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
@@ -68,13 +61,6 @@ const subscribedQuantity = computed(() => {
return customAttributes.value.subscribed_quantity;
});
const billingCurrency = computed(() => {
if (!customAttributes.value.billing_currency) return '';
return t(
getCurrencyConfig(customAttributes.value.billing_currency).i18nLabelKey
);
});
const subscriptionRenewsOn = computed(() => {
if (!customAttributes.value.subscription_ends_on) return '';
const endDate = new Date(customAttributes.value.subscription_ends_on);
@@ -92,9 +78,7 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
const data = await store.dispatch('accounts/subscription');
currencySelectionRequired.value = !!data?.currency_selection_required;
currencyOptions.value = data?.currency_options || [];
await store.dispatch('accounts/subscription');
}
// Always fetch limits for billing page to show credit usage
fetchLimits();
@@ -113,9 +97,6 @@ const handleBillingPageLogic = async () => {
// If cloud user, fetch account details first
await fetchAccountDetails();
// Waiting on the user to pick a billing currency — don't auto-refresh.
if (currencySelectionRequired.value) return;
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
@@ -137,13 +118,6 @@ const handleBillingPageLogic = async () => {
}
};
const onSelectCurrency = async code => {
await store.dispatch('accounts/selectBillingCurrency', code);
currencySelectionRequired.value = false;
// Currency stored and customer creation kicked off — resume the standard wait flow.
await handleBillingPageLogic();
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
@@ -174,9 +148,7 @@ onMounted(handleBillingPageLogic);
? $t('BILLING_SETTINGS.NO_BILLING_USER')
: $t('ATTRIBUTES_MGMT.LOADING')
"
:no-records-found="
!hasABillingPlan && !isWaitingForBilling && !currencySelectionRequired
"
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
>
<template #header>
@@ -188,30 +160,7 @@ onMounted(handleBillingPageLogic);
/>
</template>
<template #body>
<section v-if="currencySelectionRequired" class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.CURRENCY.SELECT.TITLE')"
:description="$t('BILLING_SETTINGS.CURRENCY.SELECT.DESCRIPTION')"
>
<template #action>
<div class="flex gap-2">
<ButtonV4
v-for="code in currencyOptions"
:key="code"
sm
solid
blue
:is-loading="uiFlags.isCheckoutInProcess"
:disabled="uiFlags.isCheckoutInProcess"
@click="onSelectCurrency(code)"
>
{{ $t(getCurrencyConfig(code).i18nLabelKey) }}
</ButtonV4>
</div>
</template>
</BillingCard>
</section>
<section v-else class="grid gap-4">
<section class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
@@ -239,11 +188,6 @@ onMounted(handleBillingPageLogic);
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
:value="subscriptionRenewsOn"
/>
<DetailItem
v-if="billingCurrency"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.CURRENCY')"
:value="billingCurrency"
/>
</div>
</BillingCard>
<BillingCard
@@ -1,6 +1,4 @@
<script setup>
import { formatCurrencyAmount } from 'dashboard/constants/billing';
defineProps({
credits: {
type: Number,
@@ -35,7 +33,11 @@ const formatCredits = credits => {
};
const formatAmount = (amount, currency) => {
return formatCurrencyAmount(amount, currency, { minimumFractionDigits: 0 });
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(amount);
};
</script>
@@ -4,18 +4,20 @@ import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
import {
formatCurrencyAmount,
DEFAULT_BILLING_CURRENCY,
} from 'dashboard/constants/billing';
const emit = defineEmits(['success']);
const emit = defineEmits(['close', 'success']);
const { t } = useI18n();
const TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12000, amount: 200.0, currency: 'usd' },
];
const POPULAR_CREDITS_AMOUNT = 6000;
const STEP_SELECT = 'select';
const STEP_CONFIRM = 'confirm';
@@ -25,20 +27,16 @@ const selectedCredits = ref(null);
const isLoading = ref(false);
const currentStep = ref(STEP_SELECT);
// Topup packages come from the backend for the account's billing currency.
const topupOptions = ref([]);
const optionsCurrency = ref(DEFAULT_BILLING_CURRENCY);
const isFetchingOptions = ref(false);
const fetchError = ref(false);
const selectedOption = computed(() => {
return topupOptions.value.find(o => o.credits === selectedCredits.value);
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
const formattedAmount = computed(() => {
if (!selectedOption.value) return '';
const { amount, currency } = selectedOption.value;
return formatCurrencyAmount(amount, currency || optionsCurrency.value);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: selectedOption.value.currency.toUpperCase(),
}).format(selectedOption.value.amount);
});
const formattedCredits = computed(() => {
@@ -66,44 +64,24 @@ const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const selectDefaultOption = () => {
const popularOption = topupOptions.value.find(
const open = () => {
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value =
popularOption?.credits || topupOptions.value[0]?.credits || null;
};
const fetchOptions = async () => {
isFetchingOptions.value = true;
fetchError.value = false;
try {
const { data } = await EnterpriseAccountAPI.getTopupOptions();
topupOptions.value = data.options ?? [];
optionsCurrency.value = (
data.currency || DEFAULT_BILLING_CURRENCY
).toLowerCase();
selectDefaultOption();
} catch {
fetchError.value = true;
topupOptions.value = [];
} finally {
isFetchingOptions.value = false;
}
};
const open = () => {
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
currentStep.value = STEP_SELECT;
isLoading.value = false;
selectedCredits.value = null;
dialogRef.value?.open();
fetchOptions();
};
const close = () => {
dialogRef.value?.close();
};
const handleClose = () => {
emit('close');
};
const goToConfirmStep = () => {
if (!selectedOption.value) return;
currentStep.value = STEP_CONFIRM;
@@ -149,58 +127,32 @@ defineExpose({ open, close });
:width="dialogWidth"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<!-- Step 1: Select Credits Package -->
<template v-if="currentStep === STEP_SELECT">
<div
v-if="isFetchingOptions"
class="flex items-center justify-center gap-2 py-10"
>
<Spinner />
<span class="text-sm text-n-slate-11">{{
$t('BILLING_SETTINGS.TOPUP.LOADING')
}}</span>
</div>
<div
v-else-if="fetchError"
class="flex flex-col items-center justify-center gap-3 py-10"
>
<p class="text-sm text-n-slate-11">
{{ $t('BILLING_SETTINGS.TOPUP.FETCH_ERROR') }}
</p>
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.RETRY')"
@click="fetchOptions"
<template v-if="currentStep === 'select'">
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<template v-else>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in topupOptions"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<!-- Step 2: Confirm Purchase -->
@@ -226,7 +178,7 @@ defineExpose({ open, close });
<template #footer>
<!-- Step 1 Footer -->
<div
v-if="currentStep === STEP_SELECT"
v-if="currentStep === 'select'"
class="flex items-center justify-between w-full gap-3"
>
<Button
@@ -240,7 +192,7 @@ defineExpose({ open, close });
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits || isFetchingOptions || fetchError"
:disabled="!selectedCredits"
@click="goToConfirmStep"
/>
</div>
@@ -149,5 +149,9 @@ export default {
:deep(.ProseMirror-woot-style) {
@apply min-h-[12.5rem];
p {
@apply text-base;
}
}
</style>
@@ -153,5 +153,9 @@ export default {
:deep(.ProseMirror-woot-style) {
@apply min-h-[12.5rem];
p {
@apply text-base;
}
}
</style>
@@ -101,9 +101,6 @@ export default {
summary-fetching-key="getBotSummaryFetchingStatus"
:group-by="groupBy"
:report-keys="reportKeys"
:from="from"
:to="to"
:business-hours="businessHours"
/>
</div>
</template>
@@ -121,11 +121,6 @@ export default {
show-group-by
@filter-change="onFilterChange"
/>
<ReportContainer
:group-by="groupBy"
:from="from"
:to="to"
:business-hours="businessHours"
/>
<ReportContainer :group-by="groupBy" />
</div>
</template>
@@ -5,38 +5,16 @@ import { GROUP_BY_FILTER, METRIC_CHART } from './constants';
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import { formatTime } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import ChartStats from './components/ChartElements/ChartStats.vue';
import BarChart from 'shared/components/charts/BarChart.vue';
import ReportDrilldownDrawer from './components/ReportDrilldownDrawer.vue';
export default {
components: { ChartStats, BarChart, ReportDrilldownDrawer },
components: { ChartStats, BarChart },
props: {
groupBy: {
type: Object,
default: () => ({}),
},
from: {
type: Number,
default: 0,
},
to: {
type: Number,
default: 0,
},
reportType: {
type: String,
default: 'account',
},
selectedItemId: {
type: [String, Number],
default: null,
},
businessHours: {
type: Boolean,
default: false,
},
accountSummaryKey: {
type: String,
default: 'getAccountSummary',
@@ -64,27 +42,10 @@ export default {
);
return { calculateTrend, isAverageMetricType };
},
data() {
return {
drilldownRequest: null,
drilldownMetric: null,
drilldownIndex: null,
};
},
computed: {
...mapGetters({
accountReport: 'getAccountReports',
currentRole: 'getCurrentRole',
}),
isAdmin() {
return this.currentRole === 'administrator';
},
canDrilldownPrev() {
return this.findDrillableIndex(this.drilldownIndex - 1, -1) !== null;
},
canDrilldownNext() {
return this.findDrillableIndex(this.drilldownIndex + 1, 1) !== null;
},
metrics() {
const reportKeys = Object.keys(this.reportKeys);
const infoText = {
@@ -178,82 +139,6 @@ export default {
return options;
},
isDrilldownEnabled() {
return !!(this.from && this.to);
},
onChartElementClick(metric, event) {
if (!this.isDrilldownEnabled()) return;
const dataPoint = this.accountReport.data[metric.KEY]?.[event.dataIndex];
if (!this.canOpenDrilldown(metric, dataPoint)) return;
if (!this.isAdmin) {
useAlert(this.$t('REPORT.DRILLDOWN.ADMIN_ONLY'));
return;
}
this.openDrilldownAt(metric, event.dataIndex);
},
openDrilldownAt(metric, dataIndex) {
const dataPoint = this.accountReport.data[metric.KEY]?.[dataIndex];
if (!this.canOpenDrilldown(metric, dataPoint)) return;
const labels = this.getCollection(metric).labels || [];
this.drilldownMetric = metric;
this.drilldownIndex = dataIndex;
this.drilldownRequest = {
metric: metric.KEY,
metricName: metric.NAME,
bucketLabel: labels[dataIndex],
bucketTimestamp: dataPoint.timestamp,
bucketValue: dataPoint.value,
isAverageMetric: this.isAverageMetricType(metric.KEY),
from: this.from,
to: this.to,
type: this.reportType,
id: this.selectedItemId,
groupBy: this.groupBy?.period,
businessHours: this.businessHours,
};
},
navigateDrilldown(direction) {
const nextIndex = this.findDrillableIndex(
this.drilldownIndex + direction,
direction
);
if (nextIndex === null) return;
this.openDrilldownAt(this.drilldownMetric, nextIndex);
},
findDrillableIndex(startIndex, step) {
if (!this.drilldownMetric) return null;
const data = this.accountReport.data[this.drilldownMetric.KEY] || [];
for (
let index = startIndex;
index >= 0 && index < data.length;
index += step
) {
if (this.canOpenDrilldown(this.drilldownMetric, data[index]))
return index;
}
return null;
},
canOpenDrilldown(metric, dataPoint) {
if (!dataPoint) return false;
if (this.isAverageMetricType(metric.KEY)) {
return dataPoint.count > 0;
}
return dataPoint.value > 0;
},
closeDrilldown() {
this.drilldownRequest = null;
this.drilldownMetric = null;
this.drilldownIndex = null;
},
},
};
</script>
@@ -283,8 +168,6 @@ export default {
v-if="accountReport.data[metric.KEY].length"
:collection="getCollection(metric)"
:chart-options="getChartOptions(metric)"
:clickable="isDrilldownEnabled()"
@element-click="onChartElementClick(metric, $event)"
/>
<span v-else class="text-sm text-n-slate-10">
{{ $t('REPORT.NO_ENOUGH_DATA') }}
@@ -293,23 +176,4 @@ export default {
</div>
</div>
</div>
<ReportDrilldownDrawer
:id="drilldownRequest?.id"
:open="!!drilldownRequest"
:metric="drilldownRequest?.metric"
:metric-name="drilldownRequest?.metricName"
:bucket-label="drilldownRequest?.bucketLabel"
:bucket-timestamp="drilldownRequest?.bucketTimestamp"
:bucket-value="drilldownRequest?.bucketValue"
:is-average-metric="drilldownRequest?.isAverageMetric"
:from="drilldownRequest?.from"
:to="drilldownRequest?.to"
:type="drilldownRequest?.type"
:group-by="drilldownRequest?.groupBy"
:business-hours="drilldownRequest?.businessHours"
:can-prev="canDrilldownPrev"
:can-next="canDrilldownNext"
@navigate="navigateDrilldown"
@close="closeDrilldown"
/>
</template>
@@ -1,279 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { formatTime } from '@chatwoot/utils';
import format from 'date-fns/format';
import fromUnixTime from 'date-fns/fromUnixTime';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
const props = defineProps({
record: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const route = useRoute();
const conversation = computed(() => props.record.conversation || {});
const message = computed(() => props.record.message || {});
const isMessageRecord = computed(() => props.record.record_type === 'message');
const isEventBackedConversationRecord = computed(
() => !isMessageRecord.value && !!props.record.event_name
);
const conversationDisplayId = computed(() => conversation.value.display_id);
const conversationNumber = computed(() => `#${conversationDisplayId.value}`);
const messageDirection = computed(() => message.value.message_type);
const formatTimestamp = timestamp => {
if (!timestamp) return '';
return format(fromUnixTime(timestamp), 'dd MMM yyyy, h:mm a');
};
const compactTimestamp = timestamp => {
if (!timestamp) return '';
return shortTimestamp(dynamicTime(timestamp)).trim();
};
const metricValue = computed(() => {
const value = props.record.metric_value;
if (value === null || value === undefined) return '';
return formatTime(value) || `${value}`;
});
const previewText = computed(() => {
if (message.value.content) return message.value.content;
if (conversation.value.last_message?.content) {
return conversation.value.last_message.content;
}
return t('REPORT.DRILLDOWN.NO_MESSAGE_CONTENT');
});
const showPreview = computed(() => {
return isMessageRecord.value || conversation.value.last_message;
});
const messageCreatedTooltip = computed(() =>
t('REPORT.DRILLDOWN.MESSAGE_CREATED_AT', {
time: formatTimestamp(message.value.created_at),
})
);
const eventOccurredTooltip = computed(() =>
t('REPORT.DRILLDOWN.EVENT_OCCURRED_AT', {
time: formatTimestamp(props.record.occurred_at),
})
);
const directionDetails = computed(() => {
const direction = messageDirection.value;
if (!direction) return null;
const isIncoming = direction === 'incoming';
return {
icon: isIncoming ? 'i-lucide-arrow-down-left' : 'i-lucide-arrow-up-right',
tooltip: isIncoming
? t('REPORT.DRILLDOWN.INCOMING_MESSAGE')
: t('REPORT.DRILLDOWN.OUTGOING_MESSAGE'),
};
});
const conversationPath = computed(() => {
if (!conversationDisplayId.value) return '';
const path = conversationUrl({
accountId: route.params.accountId,
id: conversationDisplayId.value,
});
const params =
isMessageRecord.value && message.value.id
? { messageId: message.value.id }
: null;
return frontendURL(path, params);
});
const contactPath = computed(() => {
if (!conversation.value.contact_id) return '';
return frontendURL(
`accounts/${route.params.accountId}/contacts/${conversation.value.contact_id}`
);
});
const inboxPath = computed(() => {
if (!conversation.value.inbox_id) return '';
return frontendURL(
`accounts/${route.params.accountId}/inbox/${conversation.value.inbox_id}`
);
});
const agentPath = computed(() => {
if (!conversation.value.assignee_id) return '';
return frontendURL(
`accounts/${route.params.accountId}/reports/agents/${conversation.value.assignee_id}`
);
});
const metadataItems = computed(() => [
{
key: 'contact',
icon: 'i-lucide-contact',
label:
conversation.value.contact_name || t('REPORT.DRILLDOWN.UNKNOWN_CONTACT'),
path: contactPath.value,
},
{
key: 'inbox',
icon: 'i-lucide-inbox',
label: conversation.value.inbox_name || t('REPORT.DRILLDOWN.UNKNOWN_INBOX'),
path: inboxPath.value,
},
{
key: 'agent',
icon: 'i-lucide-user-round',
label:
conversation.value.assignee_name ||
t('REPORT.DRILLDOWN.UNASSIGNED_AGENT'),
path: agentPath.value,
},
]);
const metadataAttributes = item => {
if (!item.path) return {};
return {
href: item.path,
target: '_blank',
rel: 'noopener noreferrer',
};
};
const metadataItemClass = item => [
'flex min-w-0 items-center gap-1 text-n-slate-10',
item.path ? 'group hover:text-n-blue-11 hover:underline' : '',
];
const metadataIconClass = item => [
'size-3 shrink-0 text-n-slate-9',
item.path ? 'group-hover:text-n-blue-11' : '',
];
const stopMetadataLinkClick = (event, item) => {
if (item.path) {
event.stopPropagation();
}
};
const openInNewTab = url => {
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
};
const openRecord = () => {
openInNewTab(conversationPath.value);
};
</script>
<template>
<article
role="link"
tabindex="0"
class="cursor-pointer rounded-md border border-n-weak bg-n-solid-2 p-3 hover:bg-n-alpha-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand"
@click="openRecord"
@keydown.enter.self.prevent="openRecord"
@keydown.space.self.prevent="openRecord"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div
class="flex items-center gap-2 text-sm font-medium leading-5 text-n-slate-12"
>
<span>{{ conversationNumber }}</span>
<span
v-if="conversation.status"
class="rounded bg-n-alpha-2 px-1.5 py-0.5 text-xs capitalize text-n-slate-11"
>
{{ conversation.status }}
</span>
<span
v-if="directionDetails"
v-tooltip.top="directionDetails.tooltip"
:aria-label="directionDetails.tooltip"
class="flex size-5 items-center justify-center rounded bg-n-alpha-2 text-n-slate-11"
>
<Icon :icon="directionDetails.icon" class="size-3" />
</span>
<span
v-if="metricValue"
class="rounded bg-n-alpha-2 px-1.5 py-0.5 text-xs text-n-slate-11"
>
{{ metricValue }}
</span>
</div>
</div>
<div
class="ms-2 flex shrink-0 items-center justify-end gap-1 text-end text-xs leading-4 text-n-slate-10"
>
<span
v-if="isMessageRecord"
v-tooltip.left="messageCreatedTooltip"
:aria-label="messageCreatedTooltip"
class="whitespace-nowrap"
>
{{ compactTimestamp(message.created_at) }}
</span>
<TimeAgo
v-else
:is-auto-refresh-enabled="false"
:conversation-id="conversation.id"
:last-activity-timestamp="conversation.last_activity_at"
:created-at-timestamp="conversation.created_at"
class="font-440 !text-xs !text-n-slate-10"
/>
<span
v-if="isEventBackedConversationRecord"
v-tooltip.left="eventOccurredTooltip"
:aria-label="eventOccurredTooltip"
class="whitespace-nowrap rounded bg-n-alpha-2 px-1 py-0.5 text-[11px] leading-4 text-n-slate-10"
>
{{ compactTimestamp(record.occurred_at) }}
</span>
</div>
</div>
<p
v-if="showPreview"
class="mt-2 line-clamp-1 text-sm leading-5 text-n-slate-12"
>
{{ previewText }}
</p>
<div class="mt-2 grid grid-cols-3 gap-2">
<component
:is="item.path ? 'a' : 'span'"
v-for="item in metadataItems"
:key="item.key"
class="text-body-main"
v-bind="metadataAttributes(item)"
:class="metadataItemClass(item)"
@click="stopMetadataLinkClick($event, item)"
>
<Icon :icon="item.icon" :class="metadataIconClass(item)" />
<span class="truncate">{{ item.label }}</span>
</component>
</div>
</article>
</template>
@@ -1,315 +0,0 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { formatTime } from '@chatwoot/utils';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import { useReportDrilldown } from '../composables/useReportDrilldown';
import ReportDrilldownCard from './ReportDrilldownCard.vue';
const props = defineProps({
open: { type: Boolean, default: false },
metric: { type: String, default: '' },
metricName: { type: String, default: '' },
bucketLabel: { type: String, default: '' },
bucketTimestamp: { type: Number, default: null },
from: { type: Number, default: null },
to: { type: Number, default: null },
type: { type: String, default: 'account' },
id: { type: [String, Number], default: null },
groupBy: { type: String, default: '' },
businessHours: { type: Boolean, default: false },
bucketValue: { type: Number, default: null },
isAverageMetric: { type: Boolean, default: false },
canPrev: { type: Boolean, default: false },
canNext: { type: Boolean, default: false },
});
const emit = defineEmits(['close', 'navigate']);
const { t } = useI18n();
const drawerRef = ref(null);
const {
records,
meta,
isFetching,
isFetchingMore,
hasError,
hasRecords,
hasMore,
open: openDrilldown,
close,
loadMore,
} = useReportDrilldown();
let previousActiveElement = null;
const isOpen = computed(() => props.open);
const title = computed(() => props.metricName || '');
const bucketValue = computed(() => {
if (props.bucketValue === null) return '';
return props.isAverageMetric
? formatTime(props.bucketValue)
: `${props.bucketValue}`;
});
// The headline stat already shows the conversation count for conversation-count
// metrics (e.g. conversations_count), so the subtitle count would be redundant.
const isStatConversationCount = computed(
() =>
!props.isAverageMetric &&
meta.value.record_type === 'conversation' &&
props.bucketValue === meta.value.conversation_count
);
const conversationCount = computed(() => {
if (!meta.value.conversation_count || isStatConversationCount.value)
return '';
return t('REPORT.DRILLDOWN.RESULT_COUNT_CONVERSATION', {
count: meta.value.conversation_count,
});
});
// Timing metrics (e.g. reply time) show a duration as the stat, so the underlying
// message count adds context. Skip it when it just mirrors the conversation count
// (e.g. first response time has one response message per conversation).
const messageCount = computed(() => {
if (
!props.isAverageMetric ||
meta.value.record_type !== 'message' ||
!meta.value.total_count ||
meta.value.total_count === meta.value.conversation_count
) {
return '';
}
return t('REPORT.DRILLDOWN.RESULT_COUNT_MESSAGE', {
count: meta.value.total_count,
});
});
const subtitle = computed(() =>
[props.bucketLabel, conversationCount.value, messageCount.value]
.filter(Boolean)
.join(' ⋅ ')
);
const restoreFocus = () => {
if (previousActiveElement?.isConnected) {
previousActiveElement.focus();
}
previousActiveElement = null;
};
const closeDrawer = () => {
close();
emit('close');
restoreFocus();
};
const recordKey = record =>
`${record.record_type}-${record.message?.id || record.conversation?.id}-${
record.occurred_at
}`;
const rememberActiveElement = () => {
if (previousActiveElement) return;
previousActiveElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
};
const focusDrawer = () => {
nextTick(() => drawerRef.value?.focus());
};
const fetchDrilldown = () => {
openDrilldown({
metric: props.metric,
bucketTimestamp: props.bucketTimestamp,
from: props.from,
to: props.to,
type: props.type,
id: props.id,
groupBy: props.groupBy,
businessHours: props.businessHours,
});
};
const navigate = direction => {
if (direction < 0 && !props.canPrev) return;
if (direction > 0 && !props.canNext) return;
emit('navigate', direction);
};
const onKeydown = event => {
if (!isOpen.value) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeDrawer();
} else if (event.key === 'ArrowLeft') {
navigate(-1);
} else if (event.key === 'ArrowRight') {
navigate(1);
}
};
useEventListener(document, 'keydown', onKeydown);
watch(
() => props.open,
isDrawerOpen => {
if (!isDrawerOpen) {
close();
restoreFocus();
return;
}
rememberActiveElement();
fetchDrilldown();
focusDrawer();
},
{ immediate: true }
);
watch(
() => [props.metric, props.bucketTimestamp],
() => {
if (props.open) fetchDrilldown();
}
);
onBeforeUnmount(() => {
restoreFocus();
});
</script>
<template>
<TeleportWithDirection to="body">
<Transition name="report-drilldown-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 bg-black/30"
role="presentation"
@click.self="closeDrawer"
>
<aside
ref="drawerRef"
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
>
<header
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
>
<div class="min-w-0">
<h2 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h2>
<p
v-if="bucketValue"
class="mt-1 text-xl font-semibold text-n-slate-12"
>
{{ bucketValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': bucketValue,
'mt-1': !bucketValue,
}"
>
{{ subtitle }}
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<Button
ghost
slate
size="sm"
icon="i-ph-caret-left"
class="rtl:rotate-180"
:disabled="!canPrev"
:aria-label="$t('REPORT.DRILLDOWN.PREVIOUS_BUCKET')"
@click="navigate(-1)"
/>
<Button
ghost
slate
size="sm"
icon="i-ph-caret-right"
class="rtl:rotate-180"
:disabled="!canNext"
:aria-label="$t('REPORT.DRILLDOWN.NEXT_BUCKET')"
@click="navigate(1)"
/>
<Button
ghost
slate
size="sm"
icon="i-ph-x"
:aria-label="$t('REPORT.DRILLDOWN.CLOSE')"
@click="closeDrawer"
/>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<div
v-if="isFetching"
class="flex h-40 items-center justify-center"
>
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('REPORT.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('REPORT.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('REPORT.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</div>
</aside>
</div>
</Transition>
</TeleportWithDirection>
</template>
@@ -69,9 +69,6 @@ export default {
isAgentType() {
return this.type === 'agent';
},
selectedFilterId() {
return this.selectedFilter?.id || null;
},
reportKeys() {
return {
CONVERSATIONS: 'conversations_count',
@@ -184,10 +181,5 @@ export default {
v-if="filterItemsList.length"
:group-by="groupBy"
:report-keys="reportKeys"
:from="from"
:to="to"
:report-type="type"
:selected-item-id="selectedFilterId"
:business-hours="businessHours"
/>
</template>
@@ -1,195 +0,0 @@
import { mount } from '@vue/test-utils';
import ReportDrilldownCard from '../ReportDrilldownCard.vue';
vi.mock('vue-router', () => ({
useRoute: () => ({
params: {
accountId: 1,
},
}),
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key === 'REPORT.DRILLDOWN.MESSAGE_CREATED_AT') {
return `Message created at ${params.time}`;
}
if (key === 'REPORT.DRILLDOWN.EVENT_OCCURRED_AT') {
return `Event occurred at ${params.time}`;
}
if (key === 'REPORT.DRILLDOWN.INCOMING_MESSAGE') {
return 'Incoming message';
}
if (key === 'REPORT.DRILLDOWN.OUTGOING_MESSAGE') {
return 'Outgoing message';
}
return key;
},
}),
}));
vi.mock('shared/helpers/timeHelper', () => ({
dynamicTime: timestamp => {
const timestamps = {
1621103500: '2 minutes ago',
1621103400: '4 days ago',
1621103700: '4 days ago',
};
return timestamps[timestamp] || 'less than a minute ago';
},
shortTimestamp: time => {
const timestamps = {
'2 minutes ago': '2m',
'4 days ago': '4d',
};
return timestamps[time] || 'now';
},
dateFormat: timestamp => `date-${timestamp}`,
}));
describe('ReportDrilldownCard.vue', () => {
const record = {
record_type: 'message',
conversation: {
id: 10,
display_id: 42,
contact_id: 11,
contact_name: 'Jane',
inbox_id: 12,
inbox_name: 'Website',
assignee_id: 13,
assignee_name: 'Alex',
status: 'open',
created_at: 1621103400,
last_activity_at: 1621103700,
last_message: {
id: 100,
content: 'Latest reply',
message_type: 'outgoing',
created_at: 1621103600,
},
},
message: {
id: 99,
content: 'Need help',
message_type: 'incoming',
created_at: 1621103500,
},
metric_value: null,
occurred_at: 1621103500,
};
const mountCard = (props = {}) =>
mount(ReportDrilldownCard, {
props: {
record,
...props,
},
global: {
mocks: {
$t: key => key,
},
},
});
beforeEach(() => {
vi.spyOn(window, 'open').mockImplementation(() => {});
});
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it('opens the card conversation link in a new tab', async () => {
const wrapper = mountCard();
expect(wrapper.text()).toContain('#42');
expect(wrapper.text()).toContain('Need help');
expect(wrapper.find('.i-lucide-arrow-down-left').exists()).toBe(true);
expect(wrapper.find('[aria-label="Incoming message"]').exists()).toBe(true);
await wrapper.find('[role="link"]').trigger('click');
expect(window.open).toHaveBeenCalledWith(
'/app/accounts/1/conversations/42?messageId=99',
'_blank',
'noopener,noreferrer'
);
});
it('renders only message created timestamp for message rows', () => {
const wrapper = mountCard();
const messageCreatedLabel = wrapper
.findAll('[aria-label]')
.map(timestamp => timestamp.attributes('aria-label'))
.find(label => label.includes('Message created at'));
expect(wrapper.text()).toContain('2m');
expect(wrapper.text()).not.toContain('4d • 4d');
expect(messageCreatedLabel).toContain('Message created at');
});
it('renders separate contact, inbox, and agent links', async () => {
const wrapper = mountCard();
const links = wrapper.findAll('a');
expect(links.map(link => link.attributes('href'))).toEqual([
'/app/accounts/1/contacts/11',
'/app/accounts/1/inbox/12',
'/app/accounts/1/reports/agents/13',
]);
expect(links.every(link => link.attributes('target') === '_blank')).toBe(
true
);
expect(
links.every(link => link.classes().includes('text-n-slate-10'))
).toBe(true);
expect(
links.every(link => !link.classes().includes('text-n-blue-11'))
).toBe(true);
expect(wrapper.find('.i-lucide-contact').exists()).toBe(true);
expect(wrapper.find('.i-lucide-inbox').exists()).toBe(true);
expect(wrapper.find('.i-lucide-user-round').exists()).toBe(true);
await links[0].trigger('click');
expect(window.open).not.toHaveBeenCalled();
});
it('renders the last message for conversation rows', () => {
const wrapper = mountCard({
record: {
...record,
record_type: 'conversation',
message: null,
occurred_at: 1621103500,
},
});
expect(wrapper.text()).toContain('Latest reply');
expect(wrapper.text()).toContain('4d • 4d');
});
it('renders event time alongside TimeAgo for event-backed conversation rows', () => {
const wrapper = mountCard({
record: {
...record,
record_type: 'conversation',
message: null,
event_name: 'conversation_bot_handoff',
occurred_at: 1621103500,
},
});
const eventOccurredLabel = wrapper
.findAll('[aria-label]')
.map(timestamp => timestamp.attributes('aria-label'))
.find(label => label.includes('Event occurred at'));
expect(wrapper.text()).toContain('Latest reply');
expect(wrapper.text()).toContain('4d • 4d');
expect(wrapper.text()).toContain('2m');
expect(eventOccurredLabel).toContain('Event occurred at');
});
});
@@ -1,352 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { formatTime } from '@chatwoot/utils';
import ReportsAPI from 'dashboard/api/reports';
import ReportDrilldownDrawer from '../ReportDrilldownDrawer.vue';
vi.mock('dashboard/api/reports', () => ({
default: {
getDrilldown: vi.fn(),
},
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key === 'REPORT.DRILLDOWN.TITLE') {
return `${params.metric} details`;
}
if (key === 'REPORT.DRILLDOWN.RESULT_COUNT_CONVERSATION') {
return `${params.count} conversations`;
}
if (key === 'REPORT.DRILLDOWN.RESULT_COUNT_MESSAGE') {
return `${params.count} messages`;
}
return key;
},
}),
}));
describe('ReportDrilldownDrawer.vue', () => {
const request = {
metric: 'incoming_messages_count',
metricName: 'Messages received',
bucketLabel: '20-May',
bucketTimestamp: 1621103400,
from: 1621103400,
to: 1621621800,
type: 'account',
groupBy: 'day',
businessHours: false,
};
const payload = [
{
record_type: 'message',
conversation: {
id: 10,
display_id: 42,
contact_id: 11,
contact_name: 'Jane',
inbox_id: 12,
inbox_name: 'Website',
assignee_id: 13,
assignee_name: 'Alex',
status: 'open',
created_at: 1621103400,
last_activity_at: 1621103700,
last_message: {
id: 100,
content: 'Latest reply',
message_type: 'outgoing',
created_at: 1621103600,
},
},
message: {
id: 99,
content: 'Need help',
message_type: 'incoming',
created_at: 1621103500,
},
metric_value: null,
occurred_at: 1621103500,
},
];
const mountDrawer = options =>
mount(ReportDrilldownDrawer, {
props: { open: true, ...request, ...options?.props },
attachTo: options?.attachTo,
global: {
stubs: {
TeleportWithDirection: {
template: '<div><slot /></div>',
},
Transition: false,
Spinner: true,
Button: {
props: ['label'],
emits: ['click'],
template:
'<button @click="$emit(\'click\')">{{ label }}<slot /></button>',
},
ReportDrilldownCard: {
props: ['record'],
template:
'<div data-testid="drilldown-card">#{{ record.conversation.display_id }}</div>',
},
},
mocks: {
$t: key => key,
},
},
});
beforeEach(() => {
ReportsAPI.getDrilldown.mockResolvedValue({
data: {
meta: {
total_count: 1,
current_page: 1,
record_type: 'message',
conversation_count: 1,
},
payload,
},
});
});
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it('loads and renders drilldown cards for the request', async () => {
const wrapper = mountDrawer();
await flushPromises();
expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith(
expect.objectContaining({
metric: 'incoming_messages_count',
bucketTimestamp: 1621103400,
page: 1,
})
);
expect(wrapper.text()).toContain('Messages received');
expect(wrapper.text()).toContain('1 conversations');
expect(wrapper.find('[data-testid="drilldown-card"]').text()).toBe('#42');
});
it('shows the bucket aggregate value for average metrics', async () => {
const wrapper = mountDrawer({
props: {
metric: 'avg_first_response_time',
metricName: 'First response time',
isAverageMetric: true,
bucketValue: 2580,
},
});
await flushPromises();
expect(wrapper.text()).toContain(formatTime(2580));
});
it('shows both conversation and message counts when they differ (reply time)', async () => {
ReportsAPI.getDrilldown.mockResolvedValue({
data: {
meta: {
total_count: 8,
current_page: 1,
record_type: 'message',
conversation_count: 5,
},
payload,
},
});
const wrapper = mountDrawer({
props: {
metric: 'reply_time',
isAverageMetric: true,
bucketValue: 2580,
},
});
await flushPromises();
expect(wrapper.text()).toContain('5 conversations');
expect(wrapper.text()).toContain('8 messages');
});
it('hides the message count when it matches the conversation count (first response time)', async () => {
ReportsAPI.getDrilldown.mockResolvedValue({
data: {
meta: {
total_count: 5,
current_page: 1,
record_type: 'message',
conversation_count: 5,
},
payload,
},
});
const wrapper = mountDrawer({
props: {
metric: 'avg_first_response_time',
isAverageMetric: true,
bucketValue: 2580,
},
});
await flushPromises();
expect(wrapper.text()).toContain('5 conversations');
expect(wrapper.text()).not.toContain('messages');
});
it('shows the plain count as the bucket value for count metrics', async () => {
const wrapper = mountDrawer({ props: { bucketValue: 128 } });
await flushPromises();
expect(wrapper.text()).toContain('128');
expect(wrapper.text()).not.toContain(formatTime(128));
});
it('hides the redundant subtitle count for conversation-count metrics', async () => {
ReportsAPI.getDrilldown.mockResolvedValue({
data: {
meta: {
total_count: 5,
current_page: 1,
record_type: 'conversation',
conversation_count: 5,
},
payload,
},
});
const wrapper = mountDrawer({
props: { metric: 'conversations_count', bucketValue: 5 },
});
await flushPromises();
expect(wrapper.text()).toContain('5');
expect(wrapper.text()).not.toContain('conversations');
});
it('keeps the subtitle count when it differs from the stat value', async () => {
ReportsAPI.getDrilldown.mockResolvedValue({
data: {
meta: {
total_count: 8,
current_page: 1,
record_type: 'conversation',
conversation_count: 5,
},
payload,
},
});
const wrapper = mountDrawer({
props: { metric: 'resolutions_count', bucketValue: 8 },
});
await flushPromises();
expect(wrapper.text()).toContain('5 conversations');
});
it('anchors the drawer to the inline-end edge so it flips in RTL', async () => {
const wrapper = mountDrawer();
await flushPromises();
const drawer = wrapper.get('[role="dialog"]');
expect(drawer.classes()).toContain('end-0');
expect(drawer.classes()).not.toContain('right-0');
});
it('flips the navigation caret icons in RTL', async () => {
const wrapper = mountDrawer({ props: { canPrev: true, canNext: true } });
await flushPromises();
expect(
wrapper.get('[aria-label="REPORT.DRILLDOWN.PREVIOUS_BUCKET"]').classes()
).toContain('rtl:rotate-180');
expect(
wrapper.get('[aria-label="REPORT.DRILLDOWN.NEXT_BUCKET"]').classes()
).toContain('rtl:rotate-180');
});
it('emits close when the drawer close button is clicked', async () => {
const wrapper = mountDrawer();
await flushPromises();
await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click');
expect(wrapper.emitted('close')).toBeTruthy();
});
it('emits navigate when the next button is clicked', async () => {
const wrapper = mountDrawer({ props: { canNext: true } });
await flushPromises();
await wrapper
.get('[aria-label="REPORT.DRILLDOWN.NEXT_BUCKET"]')
.trigger('click');
expect(wrapper.emitted('navigate')).toStrictEqual([[1]]);
});
it('does not emit navigate past the available range', async () => {
const wrapper = mountDrawer({ props: { canPrev: false } });
await flushPromises();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
expect(wrapper.emitted('navigate')).toBeUndefined();
});
it('moves focus into the drawer when opened', async () => {
const target = document.createElement('div');
document.body.appendChild(target);
const wrapper = mountDrawer({ attachTo: target });
await flushPromises();
await nextTick();
expect(document.activeElement).toBe(
wrapper.find('[role="dialog"]').element
);
wrapper.unmount();
target.remove();
});
it('closes on Escape even when focus is outside the drawer', async () => {
const target = document.createElement('div');
document.body.appendChild(target);
const wrapper = mountDrawer({ attachTo: target });
await flushPromises();
document.body.focus();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(wrapper.emitted('close')).toBeTruthy();
wrapper.unmount();
target.remove();
});
it('restores focus to the previously focused element when closed', async () => {
const opener = document.createElement('button');
const target = document.createElement('div');
document.body.appendChild(opener);
document.body.appendChild(target);
opener.focus();
const wrapper = mountDrawer({ attachTo: target });
await flushPromises();
await nextTick();
await wrapper.get('[aria-label="REPORT.DRILLDOWN.CLOSE"]').trigger('click');
expect(document.activeElement).toBe(opener);
wrapper.unmount();
target.remove();
opener.remove();
});
});
@@ -1,124 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils';
import ReportsAPI from 'dashboard/api/reports';
import { useReportDrilldown } from '../useReportDrilldown';
vi.mock('dashboard/api/reports', () => ({
default: {
getDrilldown: vi.fn(),
},
}));
const deferredPromise = () => {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
const drilldownRequest = overrides => ({
metric: 'conversations_count',
bucketTimestamp: 1,
from: 1621103400,
to: 1621621800,
type: 'account',
groupBy: 'day',
businessHours: false,
...overrides,
});
describe('useReportDrilldown', () => {
const mountComposable = () =>
mount({
setup() {
return useReportDrilldown();
},
template: '<div />',
});
afterEach(() => {
vi.clearAllMocks();
});
it('does not request drilldown again for an identical active request', async () => {
const request = deferredPromise();
ReportsAPI.getDrilldown.mockReturnValue(request.promise);
const wrapper = mountComposable();
wrapper.vm.open(drilldownRequest());
wrapper.vm.open(drilldownRequest());
expect(ReportsAPI.getDrilldown).toHaveBeenCalledTimes(1);
});
it('aborts an in-flight request when a newer request is opened', async () => {
const firstRequest = deferredPromise();
const secondRequest = deferredPromise();
let firstSignal;
ReportsAPI.getDrilldown
.mockImplementationOnce(({ signal }) => {
firstSignal = signal;
return firstRequest.promise;
})
.mockReturnValueOnce(secondRequest.promise);
const wrapper = mountComposable();
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 1 }));
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 2 }));
expect(firstSignal.aborted).toBe(true);
});
it('passes an abort signal to drilldown requests', async () => {
const request = deferredPromise();
ReportsAPI.getDrilldown.mockReturnValue(request.promise);
const wrapper = mountComposable();
wrapper.vm.open(drilldownRequest());
expect(ReportsAPI.getDrilldown).toHaveBeenCalledWith(
expect.objectContaining({
page: 1,
signal: expect.any(AbortSignal),
})
);
});
it('ignores stale responses when a newer request is opened first', async () => {
const firstRequest = deferredPromise();
const secondRequest = deferredPromise();
ReportsAPI.getDrilldown
.mockReturnValueOnce(firstRequest.promise)
.mockReturnValueOnce(secondRequest.promise);
const wrapper = mountComposable();
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 1 }));
wrapper.vm.open(drilldownRequest({ bucketTimestamp: 2 }));
secondRequest.resolve({
data: {
meta: { current_page: 1, total_count: 1 },
payload: [{ id: 'second' }],
},
});
await flushPromises();
expect(wrapper.vm.records).toEqual([{ id: 'second' }]);
expect(wrapper.vm.meta).toEqual({ current_page: 1, total_count: 1 });
firstRequest.resolve({
data: {
meta: { current_page: 1, total_count: 1 },
payload: [{ id: 'first' }],
},
});
await flushPromises();
expect(wrapper.vm.records).toEqual([{ id: 'second' }]);
expect(wrapper.vm.meta).toEqual({ current_page: 1, total_count: 1 });
});
});
@@ -1,138 +0,0 @@
import { computed, ref } from 'vue';
import ReportsAPI from 'dashboard/api/reports';
export function useReportDrilldown() {
const activeRequest = ref(null);
const records = ref([]);
const meta = ref({});
const isFetching = ref(false);
const isFetchingMore = ref(false);
const hasError = ref(false);
let requestToken = 0;
let activeRequestController = null;
let activeRequestFingerprint = null;
const hasRecords = computed(() => records.value.length > 0);
const hasMore = computed(() => {
return records.value.length < (meta.value.total_count || 0);
});
const isCurrentRequest = token =>
token === requestToken && !!activeRequest.value;
const requestFingerprint = request =>
JSON.stringify({
metric: request.metric,
bucketTimestamp: request.bucketTimestamp,
from: request.from,
to: request.to,
type: request.type,
id: request.id,
groupBy: request.groupBy,
businessHours: request.businessHours,
});
const abortActiveRequest = () => {
if (!activeRequestController) return;
activeRequestController.abort();
activeRequestController = null;
};
const isAbortError = error =>
error?.name === 'AbortError' ||
error?.name === 'CanceledError' ||
error?.code === 'ERR_CANCELED';
const fetchPage = async (page, token = requestToken) => {
if (!activeRequest.value) return;
const request = activeRequest.value;
const controller = new AbortController();
const loadingState = page === 1 ? isFetching : isFetchingMore;
activeRequestController = controller;
loadingState.value = true;
hasError.value = false;
try {
const response = await ReportsAPI.getDrilldown({
...request,
page,
signal: controller.signal,
});
if (!isCurrentRequest(token)) return;
meta.value = response.data.meta || {};
records.value =
page === 1
? response.data.payload || []
: [...records.value, ...(response.data.payload || [])];
} catch (error) {
if (!isCurrentRequest(token) || isAbortError(error)) return;
hasError.value = true;
} finally {
if (activeRequestController === controller) {
activeRequestController = null;
}
if (isCurrentRequest(token)) {
loadingState.value = false;
}
}
};
const open = async request => {
const fingerprint = requestFingerprint(request);
if (activeRequestFingerprint === fingerprint) return;
abortActiveRequest();
requestToken += 1;
activeRequestFingerprint = fingerprint;
activeRequest.value = request;
records.value = [];
meta.value = {};
hasError.value = false;
isFetchingMore.value = false;
await fetchPage(1, requestToken);
};
const close = () => {
abortActiveRequest();
requestToken += 1;
activeRequestFingerprint = null;
activeRequest.value = null;
records.value = [];
meta.value = {};
hasError.value = false;
isFetching.value = false;
isFetchingMore.value = false;
};
const loadMore = () => {
if (
!activeRequest.value ||
!hasMore.value ||
isFetching.value ||
isFetchingMore.value
) {
return;
}
fetchPage((meta.value.current_page || 1) + 1, requestToken);
};
return {
activeRequest,
records,
meta,
isFetching,
isFetchingMore,
hasError,
hasRecords,
hasMore,
open,
close,
loadMore,
};
}
@@ -1,179 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { useAlert } from 'dashboard/composables';
import ReportContainer from '../ReportContainer.vue';
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(),
}));
vi.mock('dashboard/composables/useReportMetrics', () => ({
useReportMetrics: () => ({
calculateTrend: () => 0,
isAverageMetricType: key =>
['avg_first_response_time', 'avg_resolution_time', 'reply_time'].includes(
key
),
}),
}));
describe('ReportContainer.vue', () => {
const mountComponent = ({
dataPoint = { value: 2, timestamp: 1621103400 },
data,
reportKey = 'conversations_count',
role = 'administrator',
} = {}) =>
shallowMount(ReportContainer, {
props: {
from: 1621103400,
to: 1621621800,
groupBy: { period: 'day' },
reportType: 'inbox',
selectedItemId: 1,
businessHours: true,
reportKeys: {
CONVERSATIONS: reportKey,
},
},
global: {
mocks: {
$t: key => key,
$store: {
getters: {
getAccountReports: {
isFetching: {
[reportKey]: false,
},
data: {
[reportKey]: data || [dataPoint],
},
},
getCurrentRole: role,
},
},
},
stubs: {
ChartStats: true,
ReportDrilldownDrawer: {
name: 'ReportDrilldownDrawer',
props: [
'open',
'metric',
'metricName',
'bucketLabel',
'bucketTimestamp',
'bucketValue',
'isAverageMetric',
'from',
'to',
'type',
'id',
'groupBy',
'businessHours',
'canPrev',
'canNext',
],
emits: ['navigate', 'close'],
template: '<div />',
},
BarChart: {
name: 'BarChart',
props: ['collection', 'chartOptions', 'clickable'],
emits: ['elementClick'],
template:
'<button data-test-id="bar-chart" @click="$emit(\'elementClick\', { dataIndex: 0, label: \'20-May\', value: 2 })" />',
},
},
},
});
afterEach(() => {
vi.clearAllMocks();
});
it('opens a drilldown request with report context when a non-zero bar is clicked', async () => {
const wrapper = mountComponent();
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
const drawer = wrapper.findComponent({ name: 'ReportDrilldownDrawer' });
expect(drawer.props('open')).toBe(true);
expect(drawer.props()).toMatchObject({
metric: 'conversations_count',
metricName: 'REPORT.METRICS.CONVERSATIONS.NAME',
bucketLabel: '15-May',
bucketTimestamp: 1621103400,
from: 1621103400,
to: 1621621800,
type: 'inbox',
id: 1,
groupBy: 'day',
businessHours: true,
});
});
it('shows an alert and does not open drilldown for non-admin users', async () => {
const wrapper = mountComponent({ role: 'agent' });
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
expect(useAlert).toHaveBeenCalledWith('REPORT.DRILLDOWN.ADMIN_ONLY');
expect(
wrapper.findComponent({ name: 'ReportDrilldownDrawer' }).props('open')
).toBe(false);
});
it('does not open drilldown for zero-value count bars', async () => {
const wrapper = mountComponent({
dataPoint: { value: 0, timestamp: 1621103400 },
});
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
expect(
wrapper.findComponent({ name: 'ReportDrilldownDrawer' }).props('open')
).toBe(false);
});
it('opens average metric drilldown when the bucket has contributing records', async () => {
const wrapper = mountComponent({
reportKey: 'avg_first_response_time',
dataPoint: { value: 90, count: 2, timestamp: 1621103400 },
});
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
const drawer = wrapper.findComponent({ name: 'ReportDrilldownDrawer' });
expect(drawer.props('open')).toBe(true);
expect(drawer.props()).toMatchObject({
metric: 'avg_first_response_time',
bucketTimestamp: 1621103400,
});
});
it('navigates to adjacent drillable buckets within the report range', async () => {
const wrapper = mountComponent({
data: [
{ value: 2, timestamp: 1621103400 },
{ value: 0, timestamp: 1621189800 },
{ value: 5, timestamp: 1621276200 },
],
});
await wrapper.find('[data-test-id="bar-chart"]').trigger('click');
const drawer = wrapper.findComponent({ name: 'ReportDrilldownDrawer' });
// Opened on the first bucket: no previous, but a later drillable bucket exists.
expect(drawer.props('bucketTimestamp')).toBe(1621103400);
expect(drawer.props('canPrev')).toBe(false);
expect(drawer.props('canNext')).toBe(true);
// Skips the zero-value middle bucket and lands on the last drillable one.
drawer.vm.$emit('navigate', 1);
await wrapper.vm.$nextTick();
expect(drawer.props('bucketTimestamp')).toBe(1621276200);
expect(drawer.props('canPrev')).toBe(true);
expect(drawer.props('canNext')).toBe(false);
});
});
@@ -144,20 +144,7 @@ export const actions = {
subscription: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
const response = await EnterpriseAccountAPI.subscription();
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false });
}
},
selectBillingCurrency: async ({ commit }, currency) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
await EnterpriseAccountAPI.selectBillingCurrency(currency);
await EnterpriseAccountAPI.subscription();
} catch (error) {
throwErrorMessage(error);
} finally {
@@ -2,10 +2,14 @@ import MessageApi from '../../../../api/inbox/message';
export default {
async translateMessage(_, { conversationId, messageId, targetLanguage }) {
await MessageApi.translateMessage(
conversationId,
messageId,
targetLanguage
);
try {
await MessageApi.translateMessage(
conversationId,
messageId,
targetLanguage
);
} catch (error) {
// ignore error
}
},
};
@@ -157,13 +157,11 @@ export const actions = {
// Update positions in the store immediately so subsequent mutations preserve correct positions
commit(types.SET_ARTICLE_POSITIONS, reorderedGroup);
try {
const { data } = await articlesAPI.reorderArticles({
await articlesAPI.reorderArticles({
portalSlug,
reorderedGroup,
categorySlug,
});
// Adopt the backend's re-spaced positions so the next reorder isn't computed from stale local values.
if (data?.positions) commit(types.SET_ARTICLE_POSITIONS, data.positions);
} catch (error) {
commit(types.SET_ARTICLE_POSITIONS, oldPositions);
throw error;
@@ -314,25 +314,6 @@ describe('#actions', () => {
);
});
it('adopts the backend re-spaced positions when the response returns them', async () => {
const serverPositions = { 1: 10, 2: 30, 3: 20 };
axios.post.mockResolvedValue({ data: { positions: serverPositions } });
await actions.reorder(
{ commit, state },
{
portalSlug: 'test-portal',
categorySlug: 'test-category',
reorderedGroup: { 3: 25 },
}
);
expect(commit).toHaveBeenCalledWith(
types.default.SET_ARTICLE_POSITIONS,
serverPositions
);
});
it('rolls back positions and throws when API call fails', async () => {
axios.post.mockRejectedValue({ message: 'Network error' });
const reorderedGroup = { 1: 1, 2: 2 };
@@ -19,14 +19,8 @@ const props = defineProps({
type: Object,
default: () => ({}),
},
clickable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['elementClick']);
ChartJS.register(Title, Tooltip, BarElement, CategoryScale, LinearScale);
const fontFamily =
@@ -73,39 +67,8 @@ const defaultChartOptions = {
},
};
const handleClick = (event, elements, chart) => {
props.chartOptions.onClick?.(event, elements, chart);
if (!props.clickable || !elements.length) return;
const { datasetIndex, index } = elements[0];
const dataset = props.collection.datasets?.[datasetIndex] || {};
emit('elementClick', {
datasetIndex,
dataIndex: index,
dataset,
label: props.collection.labels?.[index],
value: dataset.data?.[index],
});
};
const handleHover = (event, elements, chart) => {
props.chartOptions.onHover?.(event, elements, chart);
if (!event?.native?.target) return;
event.native.target.style.cursor =
props.clickable && elements.length ? 'pointer' : 'default';
};
const options = computed(() => {
return {
...defaultChartOptions,
...props.chartOptions,
onClick: handleClick,
onHover: handleHover,
};
return { ...defaultChartOptions, ...props.chartOptions };
});
</script>
@@ -1,52 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import BarChart from '../charts/BarChart.vue';
vi.mock('vue-chartjs', () => ({
Bar: {
name: 'Bar',
props: ['data', 'options'],
template: '<canvas />',
},
}));
describe('BarChart.vue', () => {
it('emits the clicked chart element when clickable', () => {
const wrapper = shallowMount(BarChart, {
props: {
clickable: true,
collection: {
labels: ['20-May'],
datasets: [{ type: 'bar', data: [3] }],
},
},
});
const options = wrapper.findComponent({ name: 'Bar' }).props('options');
options.onClick({}, [{ datasetIndex: 0, index: 0 }], {});
expect(wrapper.emitted('elementClick')[0][0]).toEqual({
datasetIndex: 0,
dataIndex: 0,
dataset: { type: 'bar', data: [3] },
label: '20-May',
value: 3,
});
});
it('does not emit when chart is not clickable', () => {
const wrapper = shallowMount(BarChart, {
props: {
clickable: false,
collection: {
labels: ['20-May'],
datasets: [{ type: 'bar', data: [3] }],
},
},
});
const options = wrapper.findComponent({ name: 'Bar' }).props('options');
options.onClick({}, [{ datasetIndex: 0, index: 0 }], {});
expect(wrapper.emitted('elementClick')).toBeUndefined();
});
});
+1 -27
View File
@@ -137,41 +137,15 @@ class Article < ApplicationRecord
end
def self.update_positions(portal:, positions_hash:)
return {} if positions_hash.blank?
moved_ids = positions_hash.keys.map(&:to_i)
return if positions_hash.blank?
transaction do
positions_hash.each do |article_id, new_position|
portal.articles.find(article_id).update!(position: new_position)
end
# Re-space touched categories to clean gaps and return the final positions
rebalance_positions(portal, moved_ids)
end
end
def self.rebalance_positions(portal, moved_ids)
category_ids = portal.articles.where(id: moved_ids).distinct.pluck(:category_id).compact
category_ids.each_with_object({}) do |category_id, positions|
resequence_category(portal, category_id, moved_ids, positions)
end
end
def self.resequence_category(portal, category_id, moved_ids, positions)
ordered = portal.articles.where(category_id: category_id)
.sort_by { |article| [article.position || 0, moved_ids.include?(article.id) ? 1 : 0, article.id] }
return if ordered.length < 2 # a lone article can't collide, leave it as-is
ordered.each_with_index do |article, index|
new_position = (index + 1) * 10
positions[article.id] = new_position
next if article.position == new_position
article.update_column(:position, new_position) # rubocop:disable Rails/SkipsModelValidations
end
end
private_class_method :rebalance_positions, :resequence_category
private
def category_id_changed_action
+1 -18
View File
@@ -4,7 +4,6 @@ module CaptainFeaturable
extend ActiveSupport::Concern
included do
before_validation :normalize_captain_models
validate :validate_captain_models
# Dynamically define accessor methods for each captain feature
@@ -47,27 +46,11 @@ module CaptainFeaturable
return if captain_models.blank?
captain_models.each do |feature_key, model_name|
unless Llm::Models.feature?(feature_key)
errors.add(:captain_models, "'#{feature_key}' is not a known feature")
next
end
next if model_name.blank?
next if Llm::Models.valid_model_for?(feature_key, model_name)
allowed_models = Llm::Models.models_for(feature_key)
errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}")
end
end
def normalize_captain_models
return unless captain_models.is_a?(Hash)
normalized_models = captain_models.each_with_object({}) do |(feature_key, model_name), result|
next if model_name.blank?
result[feature_key.to_s] = model_name.to_s
end
self.captain_models = normalized_models.presence
end
end
-8
View File
@@ -23,10 +23,6 @@ class AccountPolicy < ApplicationPolicy
@account_user.administrator?
end
def select_billing_currency?
@account_user.administrator?
end
def checkout?
@account_user.administrator?
end
@@ -38,8 +34,4 @@ class AccountPolicy < ApplicationPolicy
def topup_checkout?
@account_user.administrator?
end
def topup_options?
@account_user.administrator?
end
end
@@ -78,14 +78,6 @@ class Crm::BaseProcessorService
contact.save!
end
def clear_external_id(contact)
return if contact.additional_attributes.blank?
return if contact.additional_attributes['external'].blank?
contact.additional_attributes['external'].delete("#{crm_name}_id")
contact.save!
end
def store_conversation_metadata(conversation, metadata)
# Initialize additional_attributes if it's nil
conversation.additional_attributes = {} if conversation.additional_attributes.nil?
@@ -64,7 +64,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
# may not be marked as unique, same with the phone number field
# So we just use the update API if we already have a lead ID
if lead_id.present?
with_stale_lead_recovery(contact, lead_id) { |id| @lead_client.update_lead(lead_data, id) }
@lead_client.update_lead(lead_data, lead_id)
else
new_lead_id = @lead_client.create_or_update_lead(lead_data)
store_external_id(contact, new_lead_id)
@@ -82,9 +82,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
return if lead_id.blank?
activity_code = get_activity_code(activity_code_key)
activity_id = with_stale_lead_recovery(conversation.contact, lead_id) do |id|
@activity_client.post_activity(id, activity_code, activity_note)
end
activity_id = @activity_client.post_activity(lead_id, activity_code, activity_note)
return if activity_id.blank?
metadata = {}
@@ -96,31 +94,6 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
log_activity_error(e, activity_type, conversation)
end
# The cached lead id can become stale when the lead is deleted/merged in LeadSquared,
# making LeadSquared reject the call with "Lead not found". When that happens, clear the
# stored id, re-resolve the contact to a fresh lead, and run the operation again once.
def with_stale_lead_recovery(contact, lead_id)
yield(lead_id)
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
raise unless lead_not_found_error?(e)
Rails.logger.warn("LeadSquared stale lead #{lead_id} for contact ##{contact.id}, clearing and retrying")
clear_external_id(contact)
fresh_lead_id = get_lead_id(contact)
raise if fresh_lead_id.blank? || fresh_lead_id == lead_id
yield(fresh_lead_id)
end
def lead_not_found_error?(error)
return false if error.response.blank?
parsed = error.response.parsed_response
parsed.is_a?(Hash) && parsed['ExceptionType'] == 'MXInvalidEntityReferenceException'
rescue StandardError
false
end
def log_activity_error(error, activity_type, conversation, payload: nil)
ChatwootExceptionTracker.new(error, account: @account).capture_exception
context = "account_id=#{conversation.account_id}, conversation_display_id=#{conversation.display_id}"
@@ -143,7 +116,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
unless identifiable_contact?(contact)
Rails.logger.info("Contact not identifiable. Skipping activity for ##{contact.id}")
return nil
nil
end
lead_id = @lead_finder.find_or_create(contact)
@@ -1,53 +0,0 @@
module Reports::DrilldownTimestampValidator
extend TimezoneHelper
TIMESTAMP_PARAMS = %i[bucket_timestamp since until].freeze
DEFAULT_GROUP_BY = V2::Reports::DrilldownBuilder::DEFAULT_GROUP_BY
SUPPORTED_GROUP_BY = V2::Reports::DrilldownBuilder::SUPPORTED_GROUP_BY
module_function
def valid?(params)
timestamps = TIMESTAMP_PARAMS.index_with { |param| integer_param(params[param]) }
return false if timestamps.values.any?(&:nil?)
return false unless timestamps[:since] < timestamps[:until]
bucket_overlaps_requested_range?(params, timestamps)
end
def integer_param(value)
return unless value.to_s.match?(/\A\d+\z/)
value.to_i
end
def bucket_overlaps_requested_range?(params, timestamps)
bucket_start = Time.zone.at(timestamps[:bucket_timestamp]).in_time_zone(timezone(params))
bucket_end = bucket_end_for(bucket_start, group_by(params))
requested_start = Time.zone.at(timestamps[:since])
requested_end = Time.zone.at(timestamps[:until])
bucket_start < requested_end && bucket_end > requested_start
rescue ArgumentError, RangeError
false
end
def bucket_end_for(bucket_start, group_by)
{
'hour' => bucket_start + 1.hour,
'day' => bucket_start + 1.day,
'week' => bucket_start + 1.week,
'month' => bucket_start + 1.month,
'year' => bucket_start + 1.year
}.fetch(group_by)
end
def group_by(params)
group = params[:group_by].to_s
SUPPORTED_GROUP_BY.include?(group) ? group : DEFAULT_GROUP_BY
end
def timezone(params)
timezone_name_from_offset(params[:timezone_offset])
end
end
@@ -58,6 +58,5 @@ json.last_non_activity_message conversation.messages.where(account_id: conversat
json.last_activity_at conversation.last_activity_at.to_i
json.priority conversation.priority
json.waiting_since conversation.waiting_since.to_i.to_i
sla_applicable = !conversation.respond_to?(:sla_applicable?) || conversation.sla_applicable?
json.sla_policy_id sla_applicable ? conversation.sla_policy_id : nil
json.sla_policy_id conversation.sla_policy_id
json.partial! 'enterprise/api/v1/conversations/partials/conversation', conversation: conversation if ChatwootApp.enterprise?
@@ -6,7 +6,6 @@ if resource.custom_attributes.present?
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
json.subscription_status resource.custom_attributes['subscription_status']
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
json.billing_currency resource.billing_currency if resource.respond_to?(:billing_currency) && Enterprise::Billing::Currencies.enabled?
json.website resource.custom_attributes['website'] if resource.custom_attributes['website'].present?
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
+1 -23
View File
@@ -224,30 +224,8 @@ class Rack::Attack
match_data[:account_id] if match_data.present?
end
reports_api_user_level_limit = ENV.fetch('RATE_LIMIT_REPORTS_API_USER_LEVEL', '100').to_i
reports_drilldown_api_user_level_limit = ENV.fetch(
'RATE_LIMIT_REPORTS_DRILLDOWN_API_USER_LEVEL',
[(reports_api_user_level_limit / 10), 1].max
).to_i
# Throttle drilldown requests by individual user (based on uid)
throttle('/api/v2/accounts/:account_id/reports/drilldown/user',
limit: reports_drilldown_api_user_level_limit, period: 1.minute) do |req|
match_data = %r{\A/api/v2/accounts/(?<account_id>\d+)/reports/drilldown\z}.match(req.path_without_extensions)
next unless match_data.present? && req.get?
# Extract user identification (uid for web, api_access_token for API requests)
user_uid = req.get_header('HTTP_UID')
api_access_token = req.get_header('HTTP_API_ACCESS_TOKEN') || req.get_header('api_access_token')
# Use uid if present, otherwise fallback to api_access_token for tracking
user_identifier = user_uid.presence || api_access_token.presence
"#{user_identifier}:#{match_data[:account_id]}" if user_identifier.present?
end
# Throttle by individual user (based on uid)
throttle('/api/v2/accounts/:account_id/reports/user', limit: reports_api_user_level_limit, period: 1.minute) do |req|
throttle('/api/v2/accounts/:account_id/reports/user', limit: ENV.fetch('RATE_LIMIT_REPORTS_API_USER_LEVEL', '100').to_i, period: 1.minute) do |req|
match_data = %r{/api/v2/accounts/(?<account_id>\d+)/reports}.match(req.path)
# Extract user identification (uid for web, api_access_token for API requests)
user_uid = req.get_header('HTTP_UID')
-11
View File
@@ -253,17 +253,6 @@
display_title: 'Cloud Plans'
value:
description: 'Config to store stripe plans for cloud'
- name: CAPTAIN_TOPUP_OPTIONS
display_title: 'Captain Topup Options'
value: {}
description: 'Currency-keyed AI credit top-up packages, e.g. {"usd":[{"credits":1000,"amount":20.0}],"brl":[{"credits":1000,"amount":100.0}]}'
type: code
- name: ENABLE_MULTI_CURRENCY_BILLING
display_title: 'Enable Multi-currency Billing'
value: false
locked: false
description: 'Bill new accounts in their local currency (e.g. BRL) and show currency-aware credit top-ups; when off, everyone is billed in USD'
type: boolean
- name: MARKETING_CONVERSION_TRACKING_CONFIG
value:
display_title: 'Marketing Conversion Tracking Config'
+5 -32
View File
@@ -59,10 +59,6 @@ models:
provider: openai
display_name: 'Whisper'
credit_multiplier: 1
gpt-4o-mini-transcribe:
provider: openai
display_name: 'GPT-4o Mini Transcribe'
credit_multiplier: 1
text-embedding-3-small:
provider: openai
display_name: 'Text Embedding 3 Small'
@@ -86,7 +82,6 @@ features:
assistant:
models:
[
gpt-4.1-mini,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
@@ -96,11 +91,10 @@ features:
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1
default: gpt-5.1
copilot:
models:
[
gpt-4.1-mini,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
@@ -110,11 +104,11 @@ features:
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1
default: gpt-5.1
label_suggestion:
models:
[gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5]
default: gpt-4.1-mini
default: gpt-4.1-nano
document_faq_generation:
models:
[
@@ -132,30 +126,9 @@ features:
pdf_faq_generation:
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
default: gpt-4.1-mini
help_center_article_generation:
models:
[
gpt-4.1-mini,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
gpt-5.2,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-5.2
onboarding_content_generation:
models:
[gpt-4.1, gpt-4.1-mini, gpt-5-mini, gpt-5.1, gpt-5.2]
default: gpt-4.1
help_center_query_translation:
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini]
default: gpt-4.1-nano
audio_transcription:
models: [gpt-4o-mini-transcribe, whisper-1]
default: gpt-4o-mini-transcribe
models: [whisper-1]
default: whisper-1
help_center_search:
models: [text-embedding-3-small]
default: text-embedding-3-small
-26
View File
@@ -134,7 +134,6 @@ en:
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
calls:
not_enabled: 'Calling is not enabled for this inbox'
already_ended: 'This call has already ended'
no_recording: 'No recording file provided'
no_message: 'Call has no associated message'
sdp_offer_required: 'sdp_offer is required'
@@ -173,9 +172,6 @@ en:
invalid_token: Invalid or expired MFA token
invalid_credentials: Invalid credentials or verification code
feature_unavailable: MFA feature is not available. Please configure encryption keys.
billing:
invalid_currency: Invalid billing currency
currency_locked: Billing currency cannot be changed after billing has been set up
topup:
credits_required: Credits amount is required
invalid_credits: Invalid credits amount
@@ -578,28 +574,6 @@ en:
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
super_admin:
captain_model_overrides:
form:
helper_text: 'Leave a model blank to use the YAML default for that AI feature.'
use_default: 'Use default: %{model} (%{model_id})'
show:
summary: 'View model routing'
provider: 'Provider'
model: 'Model'
sources:
account_override: 'Account override'
default: 'Default'
features:
editor: 'Editor'
assistant: 'Assistant'
copilot: 'Copilot'
label_suggestion: 'Label suggestion'
document_faq_generation: 'Document FAQ generation'
help_center_article_generation: 'Help center article generation'
onboarding_content_generation: 'Onboarding content generation'
help_center_query_translation: 'Help center query translation'
audio_transcription: 'Audio transcription'
help_center_search: 'Help center search'
push_diagnostics:
user_not_found: 'User not found.'
no_subscriptions_to_test: 'Select at least one subscription to test.'
-3
View File
@@ -501,7 +501,6 @@ Rails.application.routes.draw do
get :conversations
get :conversations_summary
get :conversation_traffic
get :drilldown
get :bot_metrics
get :inbox_label_matrix
get :first_response_time_distribution
@@ -528,11 +527,9 @@ Rails.application.routes.draw do
member do
post :checkout
post :subscription
post :select_billing_currency
get :limits
post :toggle_deletion
post :topup_checkout
get :topup_options
end
end
end
@@ -47,7 +47,7 @@ class Api::V1::Accounts::AppliedSlasController < Api::V1::Accounts::EnterpriseAc
end
def set_applied_slas
initial_query = Current.account.applied_slas.with_sla_applicable_conversation.includes(:conversation)
initial_query = Current.account.applied_slas.includes(:conversation)
@applied_slas = apply_filters(initial_query)
end
@@ -13,7 +13,6 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
Voice::CallErrors::AlreadyAccepted,
Voice::CallErrors::CallFailed,
with: :render_call_error
rescue_from Voice::CallErrors::CallAlreadyEnded, with: :render_call_ended
rescue_from Voice::CallErrors::NoCallPermission, with: :render_permission_request
def show; end
@@ -106,14 +105,9 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
def create_outbound_call
contact_phone = @conversation.contact.phone_number.delete('+')
# Claim for the caller only if unassigned at trigger time (before the round-trip); wins over auto-assignment.
claim_for_caller = @conversation.assignee_id.nil?
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
@conversation.with_lock { @conversation.update!(assignee: Current.user) } if claim_for_caller
Current.account.calls.create!(
provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact,
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
@@ -196,9 +190,4 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
def render_call_error(error)
render_could_not_create_error(error.message)
end
# 409 (not 422) so the FE can tell "already ended" from a generic failure and dismiss the ringing UI.
def render_call_ended
render json: { error: I18n.t('errors.whatsapp.calls.already_ended') }, status: :conflict
end
end
@@ -2,24 +2,13 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options]
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
def subscription
return render json: currency_selection_payload if @account.billing_currency_selection_required?
ensure_stripe_customer
head :no_content
end
def select_billing_currency
return render_could_not_create_error(I18n.t('errors.billing.currency_locked')) if currency_locked?
return render_could_not_create_error(I18n.t('errors.billing.invalid_currency')) unless @account.billing_currency_selection_required?
currency = Enterprise::Billing::Currencies.normalize(params[:currency])
return render_could_not_create_error(I18n.t('errors.billing.invalid_currency')) unless Enterprise::Billing::Currencies.supported?(currency)
@account.update!(custom_attributes: @account.custom_attributes.merge('billing_currency' => currency))
ensure_stripe_customer
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
@account.update(custom_attributes: { is_creating_customer: true })
Enterprise::CreateStripeCustomerJob.perform_later(@account)
end
head :no_content
end
@@ -82,32 +71,12 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
render_could_not_create_error(e.message)
end
def topup_options
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
render json: { id: @account.id, currency: @account.billing_currency, options: service.available_options }
end
private
def check_cloud_env
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
end
def ensure_stripe_customer
return if stripe_customer_id.present? || @account.custom_attributes['is_creating_customer'].present?
@account.update!(custom_attributes: @account.custom_attributes.merge('is_creating_customer' => true))
Enterprise::CreateStripeCustomerJob.perform_later(@account)
end
def currency_selection_payload
{
currency_selection_required: true,
currency_options: Enterprise::Billing::Currencies::SUPPORTED,
suggested_currency: Enterprise::Billing::Currencies.for_locale(@account.locale)
}
end
def default_limits
{
'conversation' => {},
@@ -129,12 +98,6 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
@account.custom_attributes['stripe_customer_id']
end
# Currency is fixed once a customer exists or creation is already in flight,
# so a second click can't bill a different currency than setup started with.
def currency_locked?
stripe_customer_id.present? || @account.custom_attributes['is_creating_customer'].present?
end
def mark_for_deletion
reason = 'manual_deletion'
@@ -1,56 +0,0 @@
require 'administrate/field/base'
class CaptainModelOverridesField < Administrate::Field::Base
def feature_rows
Llm::Models.feature_keys.map do |feature_key|
route = Llm::FeatureRouter.resolve(feature: feature_key, account: resource)
{
key: feature_key,
name: feature_name(feature_key),
provider: provider_label(route[:provider]),
provider_id: route[:provider],
model: model_label(route[:model]),
model_id: route[:model],
default_model: model_label(default_model_id(feature_key)),
default_model_id: default_model_id(feature_key),
source: route[:source],
source_label: source_label(route[:source]),
selected_override: selected_override(feature_key),
options: model_options(feature_key)
}
end
end
private
def selected_override(feature_key)
resource.captain_models&.[](feature_key).presence
end
def default_model_id(feature_key)
Llm::Models.default_model_for(feature_key)
end
def model_options(feature_key)
Llm::Models.feature_config(feature_key)[:models].map do |model|
[model[:display_name] || model[:id], model[:id]]
end
end
def model_label(model_id)
Llm::Models.model_config(model_id)&.dig('display_name') || model_id
end
def provider_label(provider_id)
Llm::Models.providers.dig(provider_id, 'display_name') || provider_id
end
def feature_name(feature_key)
I18n.t("super_admin.captain_model_overrides.features.#{feature_key}", default: feature_key.humanize)
end
def source_label(source)
I18n.t("super_admin.captain_model_overrides.sources.#{source}")
end
end
@@ -1,7 +1,5 @@
module Enterprise::ConversationFinder
def conversations_base_query
return super unless current_account.feature_enabled?('sla')
super.includes(:applied_sla, :sla_events, inbox: :working_hours)
current_account.feature_enabled?('sla') ? super.includes(:applied_sla, :sla_events) : super
end
end
@@ -96,7 +96,7 @@ module Captain::ChatHelper
end
def temperature
@assistant&.config&.[]('temperature').presence&.to_f || 0.5
@assistant&.config&.[]('temperature').to_f || 1
end
def resolved_account_id
@@ -1,21 +1,6 @@
class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
queue_as :low
# Catch-all so no exception type can wedge the generation in "generating".
# Declared FIRST because ActiveJob searches rescue handlers bottom-to-top:
# this puts StandardError at the bottom of the search order, so the specific
# retry_on/discard_on handlers declared below match first for their types.
#
# Without this, any error that isn't FirecrawlError or ArticleBuildFailed
# (e.g. ActiveRecord::RecordInvalid, SSL errors) falls through to ActiveJob's
# default retries, exhausts them, and lands in the dead set without ever
# calling finalize -> state stays "generating" at total - 1 until the 7-day
# Redis TTL expires. on_writer_failure logs the error, so code bugs are still
# visible; it just also progresses the state.
discard_on StandardError do |job, error|
job.send(:on_writer_failure, error)
end
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
job.send(:on_writer_failure, error)
end
@@ -2,7 +2,7 @@ class Sla::ProcessAccountAppliedSlasJob < ApplicationJob
queue_as :medium
def perform(account)
account.applied_slas.with_sla_applicable_conversation.where(sla_status: %w[active active_with_misses]).each do |applied_sla|
account.applied_slas.where(sla_status: %w[active active_with_misses]).each do |applied_sla|
Sla::ProcessAppliedSlaJob.perform_later(applied_sla)
end
end
+1 -59
View File
@@ -40,13 +40,10 @@ class AppliedSla < ApplicationRecord
joins(:conversation).where(conversations: { assignee_id: assigned_agent_id }) if assigned_agent_id.present?
}
scope :missed, -> { where(sla_status: %i[missed active_with_misses]) }
scope :with_sla_applicable_conversation, -> { where(conversation_id: Conversation.with_sla_applicable_contact.select(:id)) }
after_update_commit :push_conversation_event
def push_event_data
sla_due_at_values = due_at_values
{
id: id,
sla_id: sla_policy_id,
@@ -58,65 +55,10 @@ class AppliedSla < ApplicationRecord
sla_first_response_time_threshold: sla_policy.first_response_time_threshold,
sla_next_response_time_threshold: sla_policy.next_response_time_threshold,
sla_only_during_business_hours: sla_policy.only_during_business_hours,
sla_resolution_time_threshold: sla_policy.resolution_time_threshold,
sla_frt_due_at: sla_due_at_values[:frt],
sla_nrt_due_at: sla_due_at_values[:nrt],
sla_rt_due_at: sla_due_at_values[:rt]
sla_resolution_time_threshold: sla_policy.resolution_time_threshold
}
end
def due_at_values
working_hours_by_day_cache = conversation.inbox.working_hours.index_by(&:day_of_week) if sla_policy.only_during_business_hours?
{
frt: frt_due_at(working_hours_by_day_cache: working_hours_by_day_cache),
nrt: nrt_due_at(working_hours_by_day_cache: working_hours_by_day_cache),
rt: rt_due_at(working_hours_by_day_cache: working_hours_by_day_cache)
}
end
def frt_due_at(working_hours_by_day_cache: nil)
return nil if sla_policy.first_response_time_threshold.blank?
calculate_due_at(
conversation.created_at,
sla_policy.first_response_time_threshold,
working_hours_by_day_cache: working_hours_by_day_cache
)
end
def nrt_due_at(working_hours_by_day_cache: nil)
return nil if sla_policy.next_response_time_threshold.blank?
return nil if conversation.waiting_since.blank?
calculate_due_at(
conversation.waiting_since,
sla_policy.next_response_time_threshold,
working_hours_by_day_cache: working_hours_by_day_cache
)
end
def rt_due_at(working_hours_by_day_cache: nil)
return nil if sla_policy.resolution_time_threshold.blank?
calculate_due_at(
conversation.created_at,
sla_policy.resolution_time_threshold,
working_hours_by_day_cache: working_hours_by_day_cache
)
end
def calculate_due_at(start_time, threshold_seconds, working_hours_by_day_cache: nil)
return (start_time + threshold_seconds.to_i.seconds).to_i unless sla_policy.only_during_business_hours?
Sla::BusinessHoursService.new(
inbox: conversation.inbox,
start_time: start_time,
threshold_seconds: threshold_seconds,
working_hours_by_day_cache: working_hours_by_day_cache
).deadline.to_i
end
private
def push_conversation_event
+1 -10
View File
@@ -1,15 +1,13 @@
module Concerns::Agentable
extend ActiveSupport::Concern
DEFAULT_TEMPERATURE = 0.5
def agent
Agents::Agent.new(
name: agent_name,
instructions: ->(context) { agent_instructions(context) },
tools: agent_tools,
model: agent_model,
temperature: temperature.presence&.to_f || DEFAULT_TEMPERATURE,
temperature: temperature.to_f || 0.7,
response_schema: agent_response_schema
)
end
@@ -21,7 +19,6 @@ module Concerns::Agentable
state = context.context[:state] || {}
config = state[:assistant_config] || {}
enhanced_context = enhanced_context.merge(
current_time: format_current_time(state[:timezone]),
conversation: state[:conversation] || {},
contact: config['feature_contact_attributes'].present? ? state[:contact] : nil,
campaign: state[:campaign] || {}
@@ -60,12 +57,6 @@ module Concerns::Agentable
Captain::ResponseSchema
end
def format_current_time(timezone)
tz = ActiveSupport::TimeZone[timezone] if timezone.present?
time = tz ? Time.current.in_time_zone(tz) : Time.current
time.strftime('%A, %B %d, %Y %I:%M %p %Z')
end
def prompt_context
raise NotImplementedError, "#{self.class} must implement prompt_context"
end
@@ -68,30 +68,6 @@ module Enterprise::Account
saml_settings&.saml_enabled? || false
end
def billing_currency
# Feature off => everyone is billed in USD (legacy behaviour).
return Enterprise::Billing::Currencies::DEFAULT unless Enterprise::Billing::Currencies.enabled?
stored = custom_attributes&.dig('billing_currency')
return Enterprise::Billing::Currencies.normalize(stored) if Enterprise::Billing::Currencies.supported?(stored)
# Existing Stripe customers stay on USD (webhook backfills the real currency);
# only brand-new accounts infer from locale, so existing pt_BR users aren't charged BRL.
return Enterprise::Billing::Currencies::DEFAULT if custom_attributes&.dig('stripe_customer_id').present?
Enterprise::Billing::Currencies.for_locale(locale)
end
# New accounts whose locale maps to a non-USD currency get to pick USD or that
# currency before the Stripe customer is created; everyone else proceeds in USD.
def billing_currency_selection_required?
return false unless Enterprise::Billing::Currencies.enabled?
return false if custom_attributes&.dig('stripe_customer_id').present?
return false if Enterprise::Billing::Currencies.supported?(custom_attributes&.dig('billing_currency'))
Enterprise::Billing::Currencies.for_locale(locale) != Enterprise::Billing::Currencies::DEFAULT
end
private
def sync_assignment_features
@@ -67,7 +67,7 @@ module Enterprise::Concerns::Article
{ role: 'system', content: article_to_search_terms_prompt },
{ role: 'user', content: "title: #{title} \n description: #{description} \n content: #{content}" }
]
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{openai_api_key}" }
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', nil)}" }
body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json
Rails.logger.info "Requesting Chat GPT with body: #{body}"
response = HTTParty.post(openai_api_url, headers: headers, body: body)
@@ -77,12 +77,8 @@ module Enterprise::Concerns::Article
private
def openai_api_key
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value.presence || raise(I18n.t('captain.api_key_missing'))
end
def openai_api_url
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1/chat/completions"
end
@@ -15,17 +15,13 @@ module Enterprise::Concerns::Contact
def should_associate_company?
# Only trigger if:
# 1. Contact has an email
# 2. Contact doesn't have a company yet
# 2. Contact doesn't have a compan yet
# 3. Email was just set/changed
# 4. Email was previously nil (first time getting email)
# 5. The account has the Companies feature enabled
# Feature check is last so unrelated contact updates short-circuit on the
# cheap in-memory guards before touching the account (hot message-ingest path).
email.present? &&
company_id.nil? &&
saved_change_to_email? &&
saved_change_to_email.first.nil? &&
account.feature_enabled?('companies')
saved_change_to_email.first.nil?
end
def associate_company_from_email
@@ -7,16 +7,10 @@ module Enterprise::Concerns::Conversation
has_many :sla_events, dependent: :destroy_async
has_many :calls, dependent: :destroy_async
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
scope :with_sla_applicable_contact, -> { left_joins(:contact).where(contacts: { blocked: [false, nil] }) }
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
end
def sla_applicable?
!contact&.blocked?
end
private
def validate_sla_policy
@@ -26,11 +20,6 @@ module Enterprise::Concerns::Conversation
return
end
unless sla_applicable?
errors.add(:sla_policy, 'cannot be assigned to conversations with blocked contacts')
return
end
if changes[:sla_policy_id].first.present?
errors.add(:sla_policy, 'conversation already has a different sla')
return
@@ -1,13 +1,13 @@
module Enterprise::Conversations::EventDataPresenter
def push_data
return super unless account.feature_enabled?('sla')
sla_applicable = sla_applicable?
super.merge(
applied_sla: sla_applicable ? applied_sla&.push_event_data : nil,
sla_events: sla_applicable ? sla_events.map(&:push_event_data) : [],
sla_policy_id: sla_applicable ? sla_policy_id : nil
)
if account.feature_enabled?('sla')
super.merge(
applied_sla: applied_sla&.push_event_data,
sla_events: sla_events.map(&:push_event_data),
sla_policy_id: sla_policy_id
)
else
super
end
end
end
@@ -29,7 +29,7 @@ class Captain::Assistant::AgentRunnerService
def generate_response(message_history: [])
message_to_process, context = run_payload(message_history)
result = runner.run(message_to_process, context: context, max_turns: 10)
result = runner.run(message_to_process, context: context, max_turns: 100)
process_agent_result(result)
rescue StandardError => e
@@ -115,8 +115,7 @@ class Captain::Assistant::AgentRunnerService
state = {
account_id: @assistant.account_id,
assistant_id: @assistant.id,
assistant_config: @assistant.config,
timezone: @conversation&.inbox&.timezone.presence || 'UTC'
assistant_config: @assistant.config
}
state[:source] = @source if @source.present?
@@ -156,7 +155,7 @@ class Captain::Assistant::AgentRunnerService
span_attributes: {
ATTR_LANGFUSE_TAGS => ['captain_v2'].to_json
},
attribute_provider: Captain::Assistant::InstrumentationAttributeProvider.new(self)
attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) }
)
register_trace_input_callback(runner)
end
@@ -169,6 +168,7 @@ class Captain::Assistant::AgentRunnerService
{
ATTR_LANGFUSE_USER_ID => state[:account_id],
format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id],
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
format(ATTR_LANGFUSE_METADATA, 'source') => state[:source],
@@ -1,32 +0,0 @@
# frozen_string_literal: true
class Captain::Assistant::InstrumentationAttributeProvider
include Integrations::LlmInstrumentationConstants
def initialize(service)
@service = service
end
def call(context_wrapper)
@service.send(:dynamic_trace_attributes, context_wrapper)
end
def generation_attributes(_context_wrapper, _chat, message)
{
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message)
}
end
private
def generation_stage(message)
message_has_tool_calls?(message) ? 'tool_call' : 'final_response'
end
def message_has_tool_calls?(message)
return false unless message.respond_to?(:tool_calls)
tool_calls = message.tool_calls
tool_calls.respond_to?(:any?) && tool_calls.any?
end
end
@@ -6,7 +6,7 @@ class Captain::Llm::ArticleTranslationService < Captain::BaseTaskService
def perform
raise ArgumentError, "Invalid type: #{type}" unless TYPES.include?(type)
response = make_api_call(feature: 'help_center_article_generation', model: translation_model, messages: messages)
response = make_api_call(model: translation_model, messages: messages)
return response if response[:error]
response.merge(message: response[:message].strip)
@@ -6,7 +6,7 @@ class Captain::Llm::ArticleWriterService < Captain::BaseTaskService
pattr_initialize [:account!, :source_pages!, { hint_title: nil }]
def perform
response = make_api_call(feature: 'help_center_article_generation', messages: messages, schema: RESPONSE_SCHEMA)
response = make_api_call(model: writer_model, messages: messages, schema: RESPONSE_SCHEMA)
return response if response[:error]
response.merge(message: extract_payload(response[:message]))
@@ -92,6 +92,10 @@ class Captain::Llm::ArticleWriterService < Captain::BaseTaskService
false
end
def writer_model
'gpt-5.2'
end
def build_follow_up_context?
false
end
@@ -6,7 +6,7 @@ class Captain::Llm::EmbeddingService
def initialize(account_id: nil)
Llm::Config.initialize!
@account_id = account_id
@embedding_model = self.class.embedding_model
@embedding_model = InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || LlmConstants::DEFAULT_EMBEDDING_MODEL
end
def self.embedding_model
@@ -9,7 +9,7 @@ class Captain::Llm::HelpCenterCurationService < Captain::BaseTaskService
pattr_initialize [:account!, :links!]
def perform
response = make_api_call(feature: 'onboarding_content_generation', model: CURATION_MODEL, messages: messages, schema: RESPONSE_SCHEMA)
response = make_api_call(model: CURATION_MODEL, messages: messages, schema: RESPONSE_SCHEMA)
return response if response[:error]
response.merge(message: extract_payload(response[:message]))
@@ -13,6 +13,8 @@ class Captain::Llm::SystemPromptsService
**Substance over chrome**: Treat as source content only what is actual product, procedural, conceptual, or factual information. Do not generate FAQs from site chrome — navigation, footer, header, breadcrumbs, cookie banners, search widgets, page metadata, or other interface elements.
**Support-relevant content only**: Only generate FAQs from content that helps a customer use, set up, troubleshoot, or understand the product or service. Ignore non-support material even when it is substantive — marketing and sales copy, blog or news posts, press releases, company/about/team pages, careers or job listings, investor relations, testimonials or case studies, and legal boilerplate such as terms of service, privacy policies, and cookie notices. If the page is entirely such content, return `{"faqs": []}`.
**Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material.
**Structure**: Format output as valid JSON using this exact structure:
@@ -43,7 +45,7 @@ class Captain::Llm::SystemPromptsService
1. Read the entire provided content carefully
2. Identify all key information points: procedures, examples, code, identifiers, limits, definitions, warnings, and explanations
3. For each candidate section, verify the source contains the substance that would answer the question. If the source only points to where the substance lives, skip the section.
4. Disregard interface chrome (navigation, footer, header, cookie banners, breadcrumbs, page metadata).
4. Disregard interface chrome (navigation, footer, header, cookie banners, breadcrumbs, page metadata) and non-support pages (marketing, blog/news, company info, legal/privacy boilerplate).
5. Create questions that cover each remaining substantive information point
6. Write self-contained answers that preserve all relevant details from the source. Be concise where possible, but never trade away steps, examples, warnings, code, IDs, limits, or definitions for brevity.
7. Verify the combined FAQs represent the complete substantive source content (excluding redirect-only sections and chrome).
@@ -1,4 +1,6 @@
class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
MODEL = 'gpt-4.1-nano'.freeze
pattr_initialize [:account!]
def translate(query, target_language:)
@@ -9,7 +11,7 @@ class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
{ role: 'user', content: query }
]
response = make_api_call(feature: 'help_center_query_translation', messages: messages)
response = make_api_call(model: MODEL, messages: messages)
return query if response[:error]
response[:message].strip

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