Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835fb229da | ||
|
|
6365af872d |
+3
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-7
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
-1
@@ -126,7 +126,6 @@ const onCardClick = e => {
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:contact="contact"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-3
@@ -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({
|
||||
|
||||
+11
-4
@@ -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 || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
-378
@@ -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>
|
||||
-222
@@ -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]]);
|
||||
});
|
||||
});
|
||||
+72
-68
@@ -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>
|
||||
|
||||
+1
-8
@@ -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
|
||||
|
||||
+23
@@ -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
-2
@@ -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 || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
-60
@@ -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);
|
||||
};
|
||||
@@ -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,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
|
||||
|
||||
+5
-3
@@ -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>
|
||||
|
||||
|
||||
+43
-91
@@ -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>
|
||||
|
||||
-279
@@ -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>
|
||||
-315
@@ -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>
|
||||
|
||||
-195
@@ -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');
|
||||
});
|
||||
});
|
||||
-352
@@ -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();
|
||||
});
|
||||
});
|
||||
-124
@@ -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 });
|
||||
});
|
||||
});
|
||||
-138
@@ -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,
|
||||
};
|
||||
}
|
||||
-179
@@ -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 {
|
||||
|
||||
+9
-5
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user