Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c440df9b18 | ||
|
|
68da47753e | ||
|
|
d054a60a65 | ||
|
|
8b7def05dc | ||
|
|
f126230f73 | ||
|
|
e42b0be530 | ||
|
|
33b3a56d4c | ||
|
|
6693a4d6f7 | ||
|
|
69a44ccf4d | ||
|
|
95727bc608 | ||
|
|
a5c50354fc | ||
|
|
a452ce9e84 | ||
|
|
2fab77d7c7 | ||
|
|
7ad8011a1b | ||
|
|
c83546e67a | ||
|
|
1154f9ccb4 | ||
|
|
e7d5924ca1 | ||
|
|
89e53e24db | ||
|
|
47ecd349cc | ||
|
|
28bf9fa5f9 | ||
|
|
73a90f2841 | ||
|
|
a90ffe6264 | ||
|
|
b8543c09fb | ||
|
|
412b72db7c | ||
|
|
e37e9fd814 | ||
|
|
e6161d2878 | ||
|
|
4e2924538f | ||
|
|
e71bda7112 | ||
|
|
31e3e9c1ff | ||
|
|
8aa49f69d2 | ||
|
|
10f1c23742 | ||
|
|
3fd14942f1 | ||
|
|
7661104262 | ||
|
|
38b5402623 | ||
|
|
3dd29080b5 | ||
|
|
e56b4b2539 | ||
|
|
5ede10c6df | ||
|
|
8a4c1ec07e | ||
|
|
550b408656 | ||
|
|
ac7a02067e | ||
|
|
2e591afaf6 | ||
|
|
32e923dba0 | ||
|
|
b824f8ba47 | ||
|
|
57d40c373d | ||
|
|
3c5cd46d2e | ||
|
|
d34da0cbf8 | ||
|
|
32b1e16e74 | ||
|
|
7e14153784 | ||
|
|
914e70568f | ||
|
|
13860b9163 | ||
|
|
42d7f1768c | ||
|
|
911f211427 | ||
|
|
ea5779a712 | ||
|
|
9ecf5f2504 | ||
|
|
b103747584 | ||
|
|
d6d38cdd7d | ||
|
|
199dcd382e | ||
|
|
c6f82783ba | ||
|
|
9b3f0029a4 | ||
|
|
6e46be36c8 | ||
|
|
87f5af4caa | ||
|
|
dbe35252bc | ||
|
|
de8aa48b83 |
+256
@@ -0,0 +1,256 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'airbnb-base/legacy',
|
||||
'prettier',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:vitest-globals/recommended',
|
||||
// use recommended-legacy when upgrading the plugin to v4
|
||||
'plugin:@intlify/vue-i18n/recommended',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.spec.{j,t}s?(x)'],
|
||||
env: {
|
||||
'vitest-globals/env': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.story.vue'],
|
||||
rules: {
|
||||
'vue/no-undef-components': [
|
||||
'error',
|
||||
{
|
||||
ignorePatterns: ['Variant', 'Story'],
|
||||
},
|
||||
],
|
||||
// Story files can have static strings, it doesn't need to handle i18n always.
|
||||
'vue/no-bare-strings-in-template': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['html', 'prettier'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': ['error'],
|
||||
camelcase: 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
'import/prefer-default-export': 'off',
|
||||
'import/no-named-as-default': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/label-has-associated-control': 'off',
|
||||
'jsx-a11y/label-has-for': 'off',
|
||||
'jsx-a11y/anchor-is-valid': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'vue/html-indent': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/next-tick-style': ['error', 'callback'],
|
||||
'vue/block-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['script', 'template', 'style'],
|
||||
},
|
||||
],
|
||||
'vue/component-name-in-template-casing': [
|
||||
'error',
|
||||
'PascalCase',
|
||||
{
|
||||
registeredComponentsOnly: true,
|
||||
},
|
||||
],
|
||||
'vue/component-options-name-casing': ['error', 'PascalCase'],
|
||||
'vue/custom-event-name-casing': ['error', 'camelCase'],
|
||||
'vue/define-emits-declaration': ['error'],
|
||||
'vue/define-macros-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['defineProps', 'defineEmits'],
|
||||
defineExposeLast: false,
|
||||
},
|
||||
],
|
||||
'vue/define-props-declaration': ['error', 'runtime'],
|
||||
'vue/match-component-import-name': ['error'],
|
||||
'vue/no-bare-strings-in-template': [
|
||||
'error',
|
||||
{
|
||||
allowlist: [
|
||||
'(',
|
||||
')',
|
||||
',',
|
||||
'.',
|
||||
'&',
|
||||
'+',
|
||||
'-',
|
||||
'=',
|
||||
'*',
|
||||
'/',
|
||||
'#',
|
||||
'%',
|
||||
'!',
|
||||
'?',
|
||||
':',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
'<',
|
||||
'>',
|
||||
'⌘',
|
||||
'📄',
|
||||
'🎉',
|
||||
'🚀',
|
||||
'💬',
|
||||
'👥',
|
||||
'📥',
|
||||
'🔖',
|
||||
'❌',
|
||||
'✅',
|
||||
'\u00b7',
|
||||
'\u2022',
|
||||
'\u2010',
|
||||
'\u2013',
|
||||
'\u2014',
|
||||
'\u2212',
|
||||
'|',
|
||||
],
|
||||
attributes: {
|
||||
'/.+/': [
|
||||
'title',
|
||||
'aria-label',
|
||||
'aria-placeholder',
|
||||
'aria-roledescription',
|
||||
'aria-valuetext',
|
||||
],
|
||||
input: ['placeholder'],
|
||||
},
|
||||
directives: ['v-text'],
|
||||
},
|
||||
],
|
||||
'vue/no-empty-component-block': 'error',
|
||||
'vue/no-multiple-objects-in-class': 'error',
|
||||
'vue/no-root-v-if': 'warn',
|
||||
'vue/no-static-inline-styles': [
|
||||
'error',
|
||||
{
|
||||
allowBinding: false,
|
||||
},
|
||||
],
|
||||
'vue/no-template-target-blank': [
|
||||
'error',
|
||||
{
|
||||
allowReferrer: false,
|
||||
enforceDynamicLinks: 'always',
|
||||
},
|
||||
],
|
||||
'vue/no-required-prop-with-default': [
|
||||
'error',
|
||||
{
|
||||
autofix: false,
|
||||
},
|
||||
],
|
||||
'vue/no-this-in-before-route-enter': 'error',
|
||||
'vue/no-undef-components': [
|
||||
'error',
|
||||
{
|
||||
ignorePatterns: [
|
||||
'^woot-',
|
||||
'^fluent-',
|
||||
'^multiselect',
|
||||
'^router-link',
|
||||
'^router-view',
|
||||
'^ninja-keys',
|
||||
'^FormulateForm',
|
||||
'^FormulateInput',
|
||||
'^highlightjs',
|
||||
],
|
||||
},
|
||||
],
|
||||
'vue/no-unused-emit-declarations': 'error',
|
||||
'vue/no-unused-refs': 'error',
|
||||
'vue/no-use-v-else-with-v-for': 'error',
|
||||
'vue/prefer-true-attribute-shorthand': 'error',
|
||||
'vue/no-useless-v-bind': [
|
||||
'error',
|
||||
{
|
||||
ignoreIncludesComment: false,
|
||||
ignoreStringEscape: false,
|
||||
},
|
||||
],
|
||||
'vue/no-v-text': 'error',
|
||||
'vue/padding-line-between-blocks': ['error', 'always'],
|
||||
'vue/prefer-separate-static-class': 'error',
|
||||
'vue/require-explicit-slots': 'error',
|
||||
'vue/require-macro-variable-name': [
|
||||
'error',
|
||||
{
|
||||
defineProps: 'props',
|
||||
defineEmits: 'emit',
|
||||
defineSlots: 'slots',
|
||||
useSlots: 'slots',
|
||||
useAttrs: 'attrs',
|
||||
},
|
||||
],
|
||||
'vue/no-unused-properties': [
|
||||
'error',
|
||||
{
|
||||
groups: ['props'],
|
||||
deepData: false,
|
||||
ignorePublicMembers: false,
|
||||
unreferencedOptions: [],
|
||||
},
|
||||
],
|
||||
'vue/max-attributes-per-line': [
|
||||
'error',
|
||||
{
|
||||
singleline: {
|
||||
max: 20,
|
||||
},
|
||||
multiline: {
|
||||
max: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
'vue/html-self-closing': [
|
||||
'error',
|
||||
{
|
||||
html: {
|
||||
void: 'always',
|
||||
normal: 'always',
|
||||
component: 'always',
|
||||
},
|
||||
svg: 'always',
|
||||
math: 'always',
|
||||
},
|
||||
],
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/component-definition-name-casing': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'import/extensions': ['off'],
|
||||
'no-console': 'error',
|
||||
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
|
||||
'@intlify/vue-i18n/no-unused-keys': [
|
||||
'warn',
|
||||
{
|
||||
extensions: ['.js', '.vue'],
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
'vue-i18n': {
|
||||
localeDir: './app/javascript/*/i18n/**.json',
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
globals: {
|
||||
bus: true,
|
||||
vi: true,
|
||||
},
|
||||
};
|
||||
@@ -104,7 +104,7 @@ class ContactIdentifyAction
|
||||
# blank identifier or email will throw unique index error
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save!
|
||||
@contact.save! if @contact.changed?
|
||||
enqueue_avatar_job
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class V2::ReportBuilder
|
||||
include DateRangeHelper
|
||||
include ReportHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
@@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group('assignee_id').count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.map do |account_user|
|
||||
build_agent_stats(account_user)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class V2::Reports::BaseSummaryBuilder
|
||||
include DateRangeHelper
|
||||
include TimezoneHelper
|
||||
|
||||
def build
|
||||
load_data
|
||||
@@ -9,37 +10,13 @@ class V2::Reports::BaseSummaryBuilder
|
||||
private
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
results = data_source.summary
|
||||
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
# Override this method
|
||||
@conversations_count = results.transform_values { |data| data[:conversations_count] }
|
||||
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
|
||||
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
|
||||
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
|
||||
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
@@ -50,7 +27,27 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
def data_source
|
||||
@data_source ||= Reports::DataSource.for(
|
||||
account: account,
|
||||
metric: nil,
|
||||
dimension_type: summary_dimension_type,
|
||||
dimension_id: nil,
|
||||
scope: nil,
|
||||
range: range,
|
||||
group_by: 'day',
|
||||
timezone: timezone_name_from_params(params[:timezone], params[:timezone_offset]),
|
||||
timezone_offset: params[:timezone_offset],
|
||||
business_hours: params[:business_hours]
|
||||
)
|
||||
end
|
||||
|
||||
def summary_dimension_type
|
||||
{
|
||||
'account_id' => 'account',
|
||||
'user_id' => 'agent',
|
||||
'inbox_id' => 'inbox',
|
||||
'conversations.team_id' => 'team'
|
||||
}.fetch(group_by_key.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder
|
||||
|
||||
private
|
||||
|
||||
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
|
||||
COUNT_METRICS = %w[
|
||||
conversations_count
|
||||
incoming_messages_count
|
||||
outgoing_messages_count
|
||||
resolutions_count
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].freeze
|
||||
|
||||
def builder_class(metric)
|
||||
case metric
|
||||
when *AVG_METRICS
|
||||
V2::Reports::Timeseries::AverageReportBuilder
|
||||
when *COUNT_METRICS
|
||||
V2::Reports::Timeseries::CountReportBuilder
|
||||
end
|
||||
return unless Reports::ReportMetricRegistry.supported?(metric)
|
||||
|
||||
V2::Reports::Timeseries::ReportBuilder
|
||||
end
|
||||
|
||||
def log_invalid_metric
|
||||
|
||||
@@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(group_by_key).count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.inboxes.map do |inbox|
|
||||
build_inbox_stats(inbox)
|
||||
@@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
def group_by_key
|
||||
:inbox_id
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,8 +7,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
@account = account
|
||||
@params = params
|
||||
|
||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
|
||||
@timezone = timezone_name_from_params(params[:timezone], params[:timezone_offset])
|
||||
end
|
||||
# rubocop:enable Lint/MissingSuper
|
||||
|
||||
|
||||
@@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(:team_id).count
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.map do |team|
|
||||
build_team_stats(team)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_average_time = reporting_events.average(average_value_key)
|
||||
grouped_event_count = reporting_events.count
|
||||
grouped_average_time.each_with_object([]) do |element, arr|
|
||||
event_date, average_time = element
|
||||
arr << {
|
||||
value: average_time,
|
||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
||||
count: grouped_event_count[event_date]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.average(average_value_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
metric_to_event_name = {
|
||||
avg_first_response_time: :first_response,
|
||||
avg_resolution_time: :conversation_resolved,
|
||||
reply_time: :reply_time
|
||||
}
|
||||
metric_to_event_name[params[:metric].to_sym]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -1,12 +1,13 @@
|
||||
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
include TimezoneHelper
|
||||
include DateRangeHelper
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
def scope
|
||||
case params[:type].to_sym
|
||||
case dimension_type.to_sym
|
||||
when :account
|
||||
account
|
||||
when :inbox
|
||||
@@ -20,6 +21,21 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def data_source
|
||||
@data_source ||= Reports::DataSource.for(
|
||||
account: account,
|
||||
metric: params[:metric],
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: params[:id],
|
||||
scope: scope,
|
||||
range: range,
|
||||
group_by: group_by,
|
||||
timezone: timezone,
|
||||
timezone_offset: params[:timezone_offset],
|
||||
business_hours: params[:business_hours]
|
||||
)
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find(params[:id])
|
||||
end
|
||||
@@ -41,6 +57,12 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
end
|
||||
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
@timezone ||= timezone_name_from_params(params[:timezone], params[:timezone_offset])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dimension_type
|
||||
(params[:type].presence || 'account').to_s
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_count.each_with_object([]) do |element, arr|
|
||||
event_date, event_count = element
|
||||
|
||||
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
|
||||
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
|
||||
# because it converts the date to 12:00 AM server timezone.
|
||||
# The desired output should be 12:00 AM in the specified timezone.
|
||||
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metric
|
||||
@metric ||= params[:metric]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
send("scope_for_#{metric}")
|
||||
end
|
||||
|
||||
def scope_for_conversations_count
|
||||
scope.conversations.where(account_id: account.id, created_at: range)
|
||||
end
|
||||
|
||||
def scope_for_incoming_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_outgoing_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_bot_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_handoffs_count
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_bot_handoff,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
|
||||
# It converts timestamps to the target timezone before grouping, which means
|
||||
# the same event can fall into different day buckets depending on timezone
|
||||
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
).count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
data_source.timeseries
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
data_source.aggregate
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
||||
before_action :check_admin_authorization?, only: [:register_webhook]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def register_webhook
|
||||
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
||||
|
||||
render json: { message: 'Webhook registered successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
before_action :check_authorization, except: [:show]
|
||||
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@@ -70,23 +71,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -98,12 +82,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
@@ -200,18 +178,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def get_channel_attributes(channel_type)
|
||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
contact = @contact
|
||||
end
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
|
||||
|
||||
identify_contact(contact)
|
||||
end
|
||||
|
||||
@@ -112,6 +112,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
common_params.merge({
|
||||
since: range[:current][:since],
|
||||
until: range[:current][:until],
|
||||
timezone: params[:timezone],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
@@ -120,6 +121,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
common_params.merge({
|
||||
since: range[:previous][:since],
|
||||
until: range[:previous][:until],
|
||||
timezone: params[:timezone],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
@@ -129,6 +131,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
metric: params[:metric],
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
timezone: params[:timezone],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
|
||||
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
|
||||
end
|
||||
|
||||
def team
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder)
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
|
||||
end
|
||||
|
||||
def inbox
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
|
||||
end
|
||||
|
||||
def label
|
||||
@@ -36,15 +36,18 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
until: permitted_params[:until],
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
|
||||
}
|
||||
@builder_params[:timezone] = permitted_params[:timezone] if permitted_params[:timezone].present?
|
||||
@builder_params[:timezone_offset] = permitted_params[:timezone_offset] if permitted_params[:timezone_offset].present?
|
||||
end
|
||||
|
||||
def render_report_with(builder_class)
|
||||
builder = builder_class.new(account: Current.account, params: @builder_params)
|
||||
def render_report_with(builder_class, type: nil)
|
||||
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
|
||||
builder = builder_class.new(account: Current.account, params: builder_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:since, :until, :business_hours)
|
||||
params.permit(:since, :until, :business_hours, :timezone, :timezone_offset)
|
||||
end
|
||||
|
||||
def date_range_too_long?
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
module TimezoneHelper
|
||||
def timezone_name_from_params(timezone, offset)
|
||||
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
|
||||
|
||||
timezone_name_from_offset(offset)
|
||||
end
|
||||
|
||||
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
||||
# would return the timezone without considering day light savings. To get the correct timezone,
|
||||
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
||||
|
||||
@@ -13,6 +13,7 @@ class ApiClient {
|
||||
return `${this.baseUrl()}/${this.resource}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get accountIdFromRoute() {
|
||||
const isInsideAccountScopedURLs =
|
||||
window.location.pathname.includes('/app/accounts');
|
||||
|
||||
@@ -8,6 +8,7 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
this.dataManager = new DataManager(this.accountIdFromRoute);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
throw new Error('cacheModelName is not defined');
|
||||
}
|
||||
@@ -24,10 +25,12 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data.payload;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: { payload: dataToParse } };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class ChangelogApi extends ApiClient {
|
||||
super('changelog', { apiVersion: 'v1' });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
fetchFromHub() {
|
||||
return axios.get(CHANGELOG_API_URL);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class VoiceAPI extends ApiClient {
|
||||
super('voice', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
initiateCall(contactId, inboxId) {
|
||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
filter(page = 1, sortAttr = 'name', queryPayload) {
|
||||
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
|
||||
return axios.post(requestURL, queryPayload);
|
||||
|
||||
@@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
|
||||
registerWebhook(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/register_webhook`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
|
||||
@@ -6,6 +6,7 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'inbox';
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ class LabelsAPI extends CacheEnabledApiClient {
|
||||
super('labels', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'label';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
class ReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
@@ -26,11 +27,13 @@ class ReportsAPI extends ApiClient {
|
||||
id,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
|
||||
return axios.get(`${this.url}/summary`, {
|
||||
params: {
|
||||
@@ -40,6 +43,7 @@ class ReportsAPI extends ApiClient {
|
||||
id,
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
@@ -104,6 +108,8 @@ class ReportsAPI extends ApiClient {
|
||||
type: 'account',
|
||||
group_by: groupBy,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import reportsAPI from '../reports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#Reports API', () => {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
it('creates correct instance', () => {
|
||||
expect(reportsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(reportsAPI.apiVersion).toBe('/api/v2');
|
||||
@@ -46,6 +48,7 @@ describe('#Reports API', () => {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
type: 'account',
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
},
|
||||
});
|
||||
@@ -59,6 +62,7 @@ describe('#Reports API', () => {
|
||||
group_by: undefined,
|
||||
id: undefined,
|
||||
since: 1621103400,
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
type: 'account',
|
||||
until: 1621621800,
|
||||
@@ -140,6 +144,8 @@ describe('#Reports API', () => {
|
||||
type: 'account',
|
||||
group_by: 'date',
|
||||
business_hours: true,
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import summaryReportsAPI from '../summaryReports';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#Summary Reports API', () => {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
it('creates correct instance', () => {
|
||||
expect(summaryReportsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(summaryReportsAPI.apiVersion).toBe('/api/v2');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
});
|
||||
|
||||
it('includes timezone data in summary report requests', () => {
|
||||
summaryReportsAPI.getAgentReports({
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
businessHours: true,
|
||||
});
|
||||
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/summary_reports/agent',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
business_hours: true,
|
||||
timezone,
|
||||
timezone_offset: -0,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
const getTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
class SummaryReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
|
||||
@@ -12,6 +15,8 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -22,6 +27,8 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -32,6 +39,8 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -42,6 +51,8 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone: getTimeZone(),
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,14 +7,17 @@ export class TeamsAPI extends CacheEnabledApiClient {
|
||||
super('teams', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'team';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: dataToParse };
|
||||
}
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ const state = reactive({ ...initialState });
|
||||
const urlValidators = {
|
||||
shouldBeAValidURLPattern: value => {
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
new URLPattern(value);
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
class="bg-n-surface-1"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4 p-px">
|
||||
|
||||
@@ -18,10 +18,18 @@ const newMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const formatMessagesForApi = () => {
|
||||
return messages.value.map(message => ({
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
}));
|
||||
return messages.value.map(message => {
|
||||
const payload = {
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
};
|
||||
|
||||
if (message.sender === 'assistant' && message.agentName) {
|
||||
payload.agent_name = message.agentName;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
};
|
||||
|
||||
const resetConversation = () => {
|
||||
@@ -62,6 +70,7 @@ const sendMessage = async () => {
|
||||
messages.value.push({
|
||||
content: data.response,
|
||||
sender: 'assistant',
|
||||
agentName: data.agent_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -71,6 +80,12 @@ const sendMessage = async () => {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,7 +119,7 @@ const sendMessage = async () => {
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keyup.enter="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
|
||||
@@ -29,6 +29,12 @@ const handleInput = () => {
|
||||
nextTick(adjustHeight);
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(adjustHeight);
|
||||
});
|
||||
@@ -43,7 +49,7 @@ onMounted(() => {
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
|
||||
rows="1"
|
||||
@input="handleInput"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<button
|
||||
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||
|
||||
@@ -99,6 +99,7 @@ import { useBranding } from 'shared/composables/useBranding';
|
||||
* @property {number} inboxId - The ID of the inbox to which the message belongs
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line vue/define-macros-order
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
messageType: {
|
||||
@@ -123,12 +124,12 @@ const props = defineProps({
|
||||
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
groupWithNext: { type: Boolean, default: false },
|
||||
inboxId: { type: Number, default: null },
|
||||
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
|
||||
sender: { type: Object, default: null },
|
||||
senderId: { type: Number, default: null },
|
||||
senderType: { type: String, default: null },
|
||||
|
||||
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
|
||||
emit('keydown');
|
||||
|
||||
// Handle Enter key to send message (Shift+Enter for new line)
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// Skip if IME composition is active (CJK character confirmation)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
return true; // Prevent ProseMirror's default Enter handling
|
||||
|
||||
@@ -18,7 +18,9 @@ const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
// eslint-disable-next-line default-param-last
|
||||
plugins = [],
|
||||
// eslint-disable-next-line default-param-last
|
||||
methods = {},
|
||||
enabledMenuOptions
|
||||
) => {
|
||||
|
||||
@@ -35,7 +35,8 @@ export default {
|
||||
default: '00:00',
|
||||
},
|
||||
// inbox prop is used in /mixins/inboxMixin,
|
||||
// remove this props when refactoring to composable if not neededs
|
||||
// remove this props when refactoring to composable if not needed
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import lamejs from '@breezystack/lamejs';
|
||||
|
||||
const writeString = (view, offset, string) => {
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
@@ -28,8 +29,9 @@ const bufferToWav = async (buffer, numChannels, sampleRate) => {
|
||||
|
||||
// WAV Data
|
||||
const offset = 44;
|
||||
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let channel = 0; channel < numChannels; channel++) {
|
||||
const sample = Math.max(
|
||||
-1,
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
|
||||
});
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
'agents/getAgents': agents,
|
||||
'agents/getVerifiedAgents': agents,
|
||||
'campaigns/getAllCampaigns': campaigns,
|
||||
'contacts/getContacts': contacts,
|
||||
'inboxes/getInboxes': inboxes,
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: mockLabels },
|
||||
'teams/getTeams': { value: mockTeams },
|
||||
'agents/getAgents': { value: mockAgents },
|
||||
'agents/getVerifiedAgents': { value: mockAgents },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: [] },
|
||||
'teams/getTeams': { value: [] },
|
||||
'agents/getAgents': { value: [] },
|
||||
'agents/getVerifiedAgents': { value: [] },
|
||||
});
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
export default function useAutomationValues() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const agents = useMapGetter('agents/getVerifiedAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useMacros = () => {
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
const teams = computed(() => getters['teams/getTeams'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
|
||||
/**
|
||||
* Get dropdown values based on the specified type
|
||||
|
||||
@@ -37,10 +37,12 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onReconnect = () => {
|
||||
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onDisconnected = () => {
|
||||
emitter.emit(BUS_EVENTS.WEBSOCKET_DISCONNECT);
|
||||
};
|
||||
@@ -88,6 +90,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('updateConversation', data);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onLogout = () => AuthAPI.logout();
|
||||
|
||||
onMessageCreated = data => {
|
||||
@@ -103,6 +106,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onReload = () => window.location.reload();
|
||||
|
||||
onStatusChange = data => {
|
||||
@@ -157,6 +161,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
fetchConversationStats = () => {
|
||||
emitter.emit('fetch_conversation_stats');
|
||||
};
|
||||
|
||||
@@ -212,8 +212,9 @@ export const isCustomAttribute = (attrs, key) => {
|
||||
};
|
||||
|
||||
export const generateCustomAttributes = (
|
||||
// eslint-disable-next-line default-param-last
|
||||
conversationAttributes = [],
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
contactAttributes = [],
|
||||
conversationlabel,
|
||||
contactlabel
|
||||
|
||||
@@ -61,6 +61,7 @@ export class EmailQuoteExtractor {
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const selector of QUOTE_INDICATORS) {
|
||||
if (tempDiv.querySelector(selector)) {
|
||||
return true;
|
||||
|
||||
@@ -18,6 +18,7 @@ export const verifyServiceWorkerExistence = (callback = () => {}) => {
|
||||
.register('/sw.js')
|
||||
.then(registration => callback(registration))
|
||||
.catch(registrationError => {
|
||||
// eslint-disable-next-line
|
||||
console.log('SW registration failed: ', registrationError);
|
||||
});
|
||||
};
|
||||
@@ -68,6 +69,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
|
||||
onSuccess();
|
||||
})
|
||||
.catch(error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Push subscription registration failed:', error);
|
||||
useAlert('This browser does not support desktop notification');
|
||||
});
|
||||
@@ -75,6 +77,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
|
||||
|
||||
export const requestPushPermissions = ({ onSuccess }) => {
|
||||
if (!('Notification' in window)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Notification is not supported');
|
||||
useAlert('This browser does not support desktop notification');
|
||||
} else if (Notification.permission === 'granted') {
|
||||
|
||||
@@ -288,9 +288,10 @@ describe('templateHelper', () => {
|
||||
});
|
||||
|
||||
it('should handle special characters in template variables', () => {
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
const templateText =
|
||||
'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
|
||||
|
||||
/* eslint-enable no-template-curly-in-string */
|
||||
const processedParams = {
|
||||
body: {
|
||||
user_name: 'John & Jane',
|
||||
|
||||
@@ -685,6 +685,16 @@
|
||||
"SANDBOX": "Sandbox",
|
||||
"LIVE": "Live"
|
||||
}
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"TITLE": "Webhook Configuration",
|
||||
"DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers",
|
||||
"ACTION_REQUIRED": "Webhook not configured",
|
||||
"REGISTER_BUTTON": "Register Webhook",
|
||||
"REGISTER_SUCCESS": "Webhook registered successfully",
|
||||
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
|
||||
"CONFIGURED_SUCCESS": "Webhook configured successfully",
|
||||
"URL_MISMATCH": "Webhook URL mismatch"
|
||||
}
|
||||
},
|
||||
"SETTINGS": "Settings",
|
||||
|
||||
@@ -99,6 +99,7 @@ export default {
|
||||
healthData: null,
|
||||
isLoadingHealth: false,
|
||||
healthError: null,
|
||||
isRegisteringWebhook: false,
|
||||
widgetBubblePosition: 'right',
|
||||
widgetBubbleType: 'standard',
|
||||
widgetBubbleLauncherTitle: '',
|
||||
@@ -424,6 +425,23 @@ export default {
|
||||
this.isLoadingHealth = false;
|
||||
}
|
||||
},
|
||||
async registerWebhook() {
|
||||
if (!this.inbox) return;
|
||||
|
||||
try {
|
||||
this.isRegisteringWebhook = true;
|
||||
await InboxHealthAPI.registerWebhook(this.inbox.id);
|
||||
useAlert(this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_SUCCESS'));
|
||||
await this.fetchHealthData();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_ERROR')
|
||||
);
|
||||
} finally {
|
||||
this.isRegisteringWebhook = false;
|
||||
}
|
||||
},
|
||||
handleFeatureFlag(e) {
|
||||
this.selectedFeatureFlags = this.toggleInput(
|
||||
this.selectedFeatureFlags,
|
||||
@@ -1162,7 +1180,11 @@ export default {
|
||||
<BotConfiguration :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'whatsapp-health'">
|
||||
<AccountHealth :health-data="healthData" />
|
||||
<AccountHealth
|
||||
:health-data="healthData"
|
||||
:is-registering-webhook="isRegisteringWebhook"
|
||||
@register-webhook="registerWebhook"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
/* eslint-env browser */
|
||||
/* global FB */
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
@@ -10,8 +10,14 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isRegisteringWebhook: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['registerWebhook']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const QUALITY_COLORS = {
|
||||
@@ -133,6 +139,28 @@ const formatModeDisplay = mode =>
|
||||
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
|
||||
|
||||
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
|
||||
const showWebhookSection = computed(
|
||||
() => props.healthData?.webhook_configuration !== undefined
|
||||
);
|
||||
|
||||
const webhookUrl = computed(
|
||||
() =>
|
||||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
|
||||
props.healthData?.webhook_configuration?.application
|
||||
);
|
||||
|
||||
const webhookConfigured = computed(() => !!webhookUrl.value);
|
||||
|
||||
const webhookUrlMismatch = computed(
|
||||
() =>
|
||||
webhookConfigured.value &&
|
||||
webhookUrl.value !== props.healthData?.expected_webhook_url
|
||||
);
|
||||
|
||||
const handleRegisterWebhook = () => {
|
||||
emit('registerWebhook');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -211,6 +239,55 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook configuration card -->
|
||||
<div
|
||||
v-if="showWebhookSection"
|
||||
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-body-main font-medium text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.TITLE') }}
|
||||
</span>
|
||||
<Icon
|
||||
v-tooltip.top="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.DESCRIPTION')"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
v-if="webhookConfigured && !webhookUrlMismatch"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-teal-11"
|
||||
>
|
||||
<Icon icon="i-lucide-check-circle" class="w-3.5 h-3.5" />
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.CONFIGURED_SUCCESS') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-amber-11"
|
||||
>
|
||||
<Icon icon="i-lucide-alert-triangle" class="w-3.5 h-3.5" />
|
||||
{{
|
||||
webhookUrlMismatch
|
||||
? t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.URL_MISMATCH')
|
||||
: t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.ACTION_REQUIRED')
|
||||
}}
|
||||
</span>
|
||||
<ButtonV4
|
||||
v-if="!webhookConfigured || webhookUrlMismatch"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:loading="isRegisteringWebhook"
|
||||
:disabled="isRegisteringWebhook"
|
||||
class="flex-shrink-0"
|
||||
@click="handleRegisterWebhook"
|
||||
>
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="pt-8">
|
||||
|
||||
@@ -10,6 +10,7 @@ export const getAudioContext = () => {
|
||||
return audioCtx;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
export const getAlertAudio = async (baseUrl = '', requestContext) => {
|
||||
const audioCtx = getAudioContext();
|
||||
const playSound = audioBuffer => {
|
||||
@@ -33,6 +34,7 @@ export const getAlertAudio = async (baseUrl = '', requestContext) => {
|
||||
.then(response => response.arrayBuffer())
|
||||
.then(buffer => {
|
||||
audioCtx.decodeAudioData(buffer).then(playSound);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
return new Promise(res => res());
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -74,8 +74,10 @@ class BaseActionCableConnector {
|
||||
}, RECONNECT_INTERVAL);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onReconnect = () => {};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onDisconnected = () => {};
|
||||
|
||||
disconnect() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
export class DuplicateContactException extends Error {
|
||||
constructor(data) {
|
||||
super('DUPLICATE_CONTACT');
|
||||
|
||||
@@ -48,6 +48,7 @@ export default {
|
||||
}`;
|
||||
},
|
||||
avatarUrl() {
|
||||
// eslint-disable-next-line
|
||||
const displayImage = this.useInboxAvatarForBot
|
||||
? this.inboxAvatarUrl
|
||||
: '/assets/images/chatwoot_bot.png';
|
||||
|
||||
@@ -96,6 +96,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('agent/updatePresence', data.users);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onContactMerge = data => {
|
||||
const { pubsub_token: pubsubToken } = data;
|
||||
ActionCableConnector.refreshConnector(pubsubToken);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const getUuid = () =>
|
||||
'xxxxxxxx4xxx'.replace(/[xy]/g, c => {
|
||||
// eslint-disable-next-line
|
||||
const r = (Math.random() * 16) | 0;
|
||||
// eslint-disable-next-line
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
|
||||
@@ -3,23 +3,25 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
def conversation_resolved(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||
event_end_time = event.timestamp
|
||||
time_to_resolve = event_end_time.to_i - conversation.created_at.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_resolved',
|
||||
value: time_to_resolve,
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
|
||||
conversation.updated_at),
|
||||
event_end_time),
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
|
||||
create_bot_resolved_event(conversation, reporting_event)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def first_reply_created(event)
|
||||
@@ -41,6 +43,7 @@ class ReportingEventListener < BaseListener
|
||||
)
|
||||
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def reply_created(event)
|
||||
@@ -65,45 +68,59 @@ class ReportingEventListener < BaseListener
|
||||
event_end_time: message.created_at
|
||||
)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_bot_handoff(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
event_end_time = event.timestamp
|
||||
|
||||
# check if a conversation_bot_handoff event exists for this conversation
|
||||
# Best-effort guard: raw report reads count bot handoffs with DISTINCT conversation_id,
|
||||
# while rollup counts assume one conversation_bot_handoff event per conversation.
|
||||
# That uniqueness is not currently enforced at the database level.
|
||||
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
|
||||
return if bot_handoff_event.present?
|
||||
|
||||
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||
time_to_handoff = event_end_time.to_i - conversation.created_at.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_bot_handoff',
|
||||
value: time_to_handoff,
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, event_end_time),
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_captain_inference_resolved(event)
|
||||
create_captain_inference_event(event, 'conversation_captain_inference_resolved')
|
||||
end
|
||||
|
||||
def conversation_captain_inference_handoff(event)
|
||||
create_captain_inference_event(event, 'conversation_captain_inference_handoff')
|
||||
end
|
||||
|
||||
def conversation_opened(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
event_end_time = event.timestamp
|
||||
|
||||
# Find the most recent resolved event for this conversation
|
||||
last_resolved_event = ReportingEvent.where(
|
||||
conversation_id: conversation.id,
|
||||
name: 'conversation_resolved'
|
||||
).order(event_end_time: :desc).first
|
||||
).where('event_end_time <= ?', event_end_time).order(event_end_time: :desc).first
|
||||
|
||||
# For first-time openings, value is 0
|
||||
# For reopenings, calculate time since resolution
|
||||
if last_resolved_event
|
||||
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
|
||||
time_since_resolved = event_end_time.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time)
|
||||
start_time = last_resolved_event.event_end_time
|
||||
else
|
||||
time_since_resolved = 0
|
||||
@@ -111,12 +128,12 @@ class ReportingEventListener < BaseListener
|
||||
start_time = conversation.created_at
|
||||
end
|
||||
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_opened',
|
||||
value: time_since_resolved,
|
||||
@@ -126,11 +143,27 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: start_time,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
end
|
||||
|
||||
def create_captain_inference_event(event, event_name)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_event = event.timestamp.to_i - conversation.created_at.to_i
|
||||
|
||||
ReportingEvent.create!(
|
||||
name: event_name,
|
||||
value: time_to_event,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event.timestamp
|
||||
)
|
||||
end
|
||||
|
||||
def create_bot_resolved_event(conversation, reporting_event)
|
||||
return unless conversation.inbox.active_bot?
|
||||
# We don't want to create a bot_resolved event if there is user interaction on the conversation
|
||||
@@ -139,5 +172,16 @@ class ReportingEventListener < BaseListener
|
||||
bot_resolved_event = reporting_event.dup
|
||||
bot_resolved_event.name = 'conversation_bot_resolved'
|
||||
bot_resolved_event.save!
|
||||
safe_rollup(bot_resolved_event)
|
||||
end
|
||||
|
||||
def safe_rollup(reporting_event)
|
||||
# Rollups are derived from the raw reporting event. If a transient rollup write
|
||||
# failure bubbles out here, Sidekiq retries the dispatcher job and can insert the
|
||||
# same raw event again. That can temporarily under-report rollups, but the source
|
||||
# event is preserved and rollup data can be rebuilt or re-applied later.
|
||||
ReportingEvents::RollupService.perform(reporting_event)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: reporting_event.account).capture_exception
|
||||
end
|
||||
end
|
||||
|
||||
+11
-2
@@ -41,7 +41,7 @@ class Account < ApplicationRecord
|
||||
'audio_transcriptions': { 'type': %w[boolean null] },
|
||||
'auto_resolve_label': { 'type': %w[string null] },
|
||||
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
|
||||
'captain_disable_auto_resolve': { 'type': %w[boolean null] },
|
||||
'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] },
|
||||
'conversation_required_attributes': {
|
||||
'type': %w[array null],
|
||||
'items': { 'type': 'string' }
|
||||
@@ -85,13 +85,16 @@ class Account < ApplicationRecord
|
||||
validates_with JsonSchemaValidator,
|
||||
schema: SETTINGS_PARAMS_SCHEMA,
|
||||
attribute_resolver: ->(record) { record.settings }
|
||||
validate :validate_reporting_timezone
|
||||
|
||||
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
||||
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||
store_accessor :settings, :captain_models, :captain_features
|
||||
store_accessor :settings, :reporting_timezone
|
||||
store_accessor :settings, :keep_pending_on_bot_failure
|
||||
store_accessor :settings, :captain_disable_auto_resolve
|
||||
store_accessor :settings, :captain_auto_resolve_mode
|
||||
include AccountCaptainAutoResolve
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
@@ -214,6 +217,12 @@ class Account < ApplicationRecord
|
||||
# method overridden in enterprise module
|
||||
end
|
||||
|
||||
def validate_reporting_timezone
|
||||
return if reporting_timezone.blank? || ActiveSupport::TimeZone[reporting_timezone].present?
|
||||
|
||||
errors.add(:reporting_timezone, I18n.t('errors.account.reporting_timezone.invalid'))
|
||||
end
|
||||
|
||||
def remove_account_sequences
|
||||
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS camp_dpid_seq_#{id}")
|
||||
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS conv_dpid_seq_#{id}")
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
module AccountCaptainAutoResolve
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
VALID_CAPTAIN_AUTO_RESOLVE_MODES = %w[evaluated legacy disabled].freeze
|
||||
|
||||
included do
|
||||
VALID_CAPTAIN_AUTO_RESOLVE_MODES.each do |mode|
|
||||
define_method("captain_auto_resolve_#{mode}?") do
|
||||
captain_auto_resolve_mode == mode
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def captain_auto_resolve_mode
|
||||
mode = settings&.[]('captain_auto_resolve_mode')
|
||||
return mode if VALID_CAPTAIN_AUTO_RESOLVE_MODES.include?(mode)
|
||||
return 'disabled' if settings&.[]('captain_disable_auto_resolve') == true
|
||||
|
||||
feature_enabled?('captain_tasks') ? 'evaluated' : 'legacy'
|
||||
end
|
||||
end
|
||||
@@ -9,9 +9,9 @@ module AutoAssignmentHandler
|
||||
private
|
||||
|
||||
def run_auto_assignment
|
||||
# Round robin kicks in on conversation create & update
|
||||
# run it only when conversation status changes to open
|
||||
return unless conversation_status_changed_to_open?
|
||||
# Assignment V2: Also trigger assignment when conversation is resolved or snoozed,
|
||||
# bypassing the open-only condition so the AssignmentJob can redistribute capacity.
|
||||
return unless conversation_status_changed_to_open? || conversation_status_changed_to_resolved_or_snoozed?
|
||||
return unless should_run_auto_assignment?
|
||||
|
||||
if inbox.auto_assignment_v2_enabled?
|
||||
@@ -25,6 +25,10 @@ module AutoAssignmentHandler
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_status_changed_to_resolved_or_snoozed?
|
||||
inbox.auto_assignment_v2_enabled? && saved_change_to_status? && (resolved? || snoozed?)
|
||||
end
|
||||
|
||||
def team_member_ids_with_capacity
|
||||
return [] if team.blank? || team.allow_auto_assign.blank?
|
||||
|
||||
@@ -33,6 +37,9 @@ module AutoAssignmentHandler
|
||||
|
||||
def should_run_auto_assignment?
|
||||
return false unless inbox.enable_auto_assignment?
|
||||
# Assignment V2: Resolved/snoozed conversations still have an assignee, so bypass the
|
||||
# assignee-blank check below. The AssignmentJob needs to run to rebalance assignments.
|
||||
return true if conversation_status_changed_to_resolved_or_snoozed?
|
||||
|
||||
# run only if assignee is blank or doesn't have access to inbox
|
||||
assignee.blank? || inbox.members.exclude?(assignee)
|
||||
|
||||
+19
-13
@@ -81,6 +81,8 @@ class Message < ApplicationRecord
|
||||
|
||||
# when you have a temperory id in your frontend and want it echoed back via action cable
|
||||
attr_accessor :echo_id
|
||||
# Transient flag used to skip waiting_since clearing for specific bot/system messages.
|
||||
attr_accessor :preserve_waiting_since
|
||||
|
||||
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
|
||||
enum content_type: {
|
||||
@@ -125,7 +127,7 @@ class Message < ApplicationRecord
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation, touch: true
|
||||
belongs_to :conversation
|
||||
belongs_to :sender, polymorphic: true, optional: true
|
||||
|
||||
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
|
||||
@@ -323,20 +325,24 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
def update_waiting_since
|
||||
waiting_present = conversation.waiting_since.present?
|
||||
clear_waiting_since_on_outgoing_response if conversation.waiting_since.present? && !private
|
||||
set_waiting_since_on_incoming_message
|
||||
end
|
||||
|
||||
if waiting_present && !private
|
||||
if human_response?
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
|
||||
)
|
||||
conversation.update(waiting_since: nil)
|
||||
elsif bot_response?
|
||||
# Bot responses also clear waiting_since (simpler than checking on next customer message)
|
||||
conversation.update(waiting_since: nil)
|
||||
end
|
||||
def clear_waiting_since_on_outgoing_response
|
||||
if human_response?
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
|
||||
)
|
||||
conversation.update(waiting_since: nil)
|
||||
return
|
||||
end
|
||||
|
||||
# Bot responses also clear waiting_since (simpler than checking on next customer message)
|
||||
conversation.update(waiting_since: nil) if bot_response? && !preserve_waiting_since
|
||||
end
|
||||
|
||||
def set_waiting_since_on_incoming_message
|
||||
# Set waiting_since when customer sends a message (if currently blank)
|
||||
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
|
||||
end
|
||||
@@ -429,7 +435,7 @@ class Message < ApplicationRecord
|
||||
|
||||
def set_conversation_activity
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.update_columns(last_activity_at: created_at)
|
||||
conversation.update_columns(last_activity_at: created_at, updated_at: Time.current)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: reporting_events_rollups
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# count :bigint default(0), not null
|
||||
# date :date not null
|
||||
# dimension_id :bigint not null
|
||||
# dimension_type :string not null
|
||||
# metric :string not null
|
||||
# sum_value :float default(0.0), not null
|
||||
# sum_value_business_hours :float default(0.0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_rollup_summary (account_id,dimension_type,date)
|
||||
# index_rollup_timeseries (account_id,metric,date)
|
||||
# index_rollup_unique_key (account_id,date,dimension_type,dimension_id,metric) UNIQUE
|
||||
#
|
||||
|
||||
class ReportingEventsRollup < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
# Store string values directly in the database for better readability and debugging
|
||||
enum :dimension_type, %w[account agent inbox team].index_by(&:itself)
|
||||
enum :metric, %w[
|
||||
resolutions_count
|
||||
first_response
|
||||
resolution_time
|
||||
reply_time
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].index_by(&:itself)
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :date, presence: true
|
||||
validates :dimension_type, presence: true
|
||||
validates :dimension_id, presence: true
|
||||
validates :metric, presence: true
|
||||
validates :count, numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
scope :for_date_range, ->(start_date, end_date) { where(date: start_date..end_date) }
|
||||
scope :for_dimension, ->(type, id) { where(dimension_type: type, dimension_id: id) }
|
||||
scope :for_metric, ->(metric) { where(metric: metric) }
|
||||
end
|
||||
@@ -47,7 +47,9 @@ class ActionService
|
||||
|
||||
@agent = @account.users.find_by(id: agent_ids)
|
||||
|
||||
@conversation.update!(assignee_id: @agent.id) if @agent.present?
|
||||
return unless @agent.present? && @agent.confirmed?
|
||||
|
||||
@conversation.update!(assignee_id: @agent.id)
|
||||
end
|
||||
|
||||
def remove_label(labels)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ReportingEvents::BackfillService
|
||||
DIMENSIONS = [
|
||||
{ type: 'account', group_column: nil },
|
||||
{ type: 'agent', group_column: :user_id },
|
||||
{ type: 'inbox', group_column: :inbox_id }
|
||||
].freeze
|
||||
|
||||
# TODO: Move this to EventMetricRegistry when we expand distinct-counting support.
|
||||
# The live path already guards uniqueness in ReportingEventListener#conversation_bot_handoff,
|
||||
# but historical duplicates can exist since it's not enforced at the DB level.
|
||||
# These events are queried per-dimension (not group-then-sum) because COUNT(DISTINCT) is not additive.
|
||||
DISTINCT_COUNT_EVENTS = %w[conversation_bot_handoff].freeze
|
||||
|
||||
DISTINCT_COUNT_SQL = Arel.sql('COUNT(DISTINCT conversation_id)')
|
||||
|
||||
def self.backfill_date(account, date)
|
||||
new(account, date).perform
|
||||
end
|
||||
|
||||
def initialize(account, date)
|
||||
@account = account
|
||||
@date = date
|
||||
end
|
||||
|
||||
def perform
|
||||
start_utc, end_utc = date_boundaries_in_utc
|
||||
rollup_rows = build_rollup_rows(start_utc, end_utc)
|
||||
|
||||
ReportingEventsRollup.transaction do
|
||||
delete_existing_rollups
|
||||
bulk_insert_rollups(rollup_rows) if rollup_rows.any?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delete_existing_rollups
|
||||
ReportingEventsRollup.where(account_id: @account.id, date: @date).delete_all
|
||||
end
|
||||
|
||||
def date_boundaries_in_utc
|
||||
tz = ActiveSupport::TimeZone[@account.reporting_timezone]
|
||||
start_in_tz = tz.parse(@date.to_s)
|
||||
end_in_tz = start_in_tz + 1.day
|
||||
[start_in_tz.utc, end_in_tz.utc]
|
||||
end
|
||||
|
||||
def build_rollup_rows(start_utc, end_utc)
|
||||
aggregates = build_aggregates(start_utc, end_utc)
|
||||
|
||||
aggregates.map do |(dimension_type, dimension_id, metric), data|
|
||||
{
|
||||
account_id: @account.id,
|
||||
date: @date,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
metric: metric,
|
||||
count: data[:count],
|
||||
sum_value: data[:sum_value],
|
||||
sum_value_business_hours: data[:sum_value_business_hours],
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build_aggregates(start_utc, end_utc)
|
||||
aggregates = Hash.new { |h, k| h[k] = { count: 0, sum_value: 0.0, sum_value_business_hours: 0.0 } }
|
||||
standard_names = ReportingEvents::EventMetricRegistry.event_names - DISTINCT_COUNT_EVENTS
|
||||
base = @account.reporting_events.where(created_at: start_utc...end_utc)
|
||||
|
||||
DIMENSIONS.each do |dimension|
|
||||
aggregate_standard_events(aggregates, base.where(name: standard_names), dimension)
|
||||
aggregate_distinct_events(aggregates, base.where(name: DISTINCT_COUNT_EVENTS), dimension)
|
||||
end
|
||||
|
||||
aggregates
|
||||
end
|
||||
|
||||
def aggregate_standard_events(aggregates, scope, dimension)
|
||||
group_cols, selects = dimension_groups_and_selects(dimension)
|
||||
|
||||
scope.group(*group_cols).pluck(*selects).each do |row|
|
||||
event_name, dimension_id, count, sum_value, sum_value_business_hours = unpack_row(row, dimension)
|
||||
next if dimension_id.nil?
|
||||
|
||||
accumulate_metrics(aggregates, dimension[:type], dimension_id, event_name,
|
||||
{ count: count, sum_value: sum_value, sum_value_business_hours: sum_value_business_hours })
|
||||
end
|
||||
end
|
||||
|
||||
def accumulate_metrics(aggregates, dimension_type, dimension_id, event_name, values)
|
||||
ReportingEvents::EventMetricRegistry.metrics_for_aggregate(event_name, **values).each do |metric, metric_data|
|
||||
key = [dimension_type, dimension_id, metric]
|
||||
aggregates[key][:count] += metric_data[:count]
|
||||
aggregates[key][:sum_value] += metric_data[:sum_value].to_f
|
||||
aggregates[key][:sum_value_business_hours] += metric_data[:sum_value_business_hours].to_f
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_distinct_events(aggregates, scope, dimension)
|
||||
return if DISTINCT_COUNT_EVENTS.empty?
|
||||
|
||||
group_cols = dimension[:group_column] ? [:name, dimension[:group_column]] : [:name]
|
||||
|
||||
scope.group(*group_cols).pluck(*group_cols, DISTINCT_COUNT_SQL).each do |row|
|
||||
event_name, dimension_id, count = dimension[:group_column] ? row : [row[0], @account.id, row[1]]
|
||||
next if dimension_id.nil?
|
||||
|
||||
accumulate_metrics(aggregates, dimension[:type], dimension_id, event_name,
|
||||
{ count: count, sum_value: 0, sum_value_business_hours: 0 })
|
||||
end
|
||||
end
|
||||
|
||||
def dimension_groups_and_selects(dimension)
|
||||
agg_selects = [Arel.sql('COUNT(*)'), Arel.sql('COALESCE(SUM(value), 0)'), Arel.sql('COALESCE(SUM(value_in_business_hours), 0)')]
|
||||
|
||||
if dimension[:group_column]
|
||||
[[:name, dimension[:group_column]], [:name, dimension[:group_column], *agg_selects]]
|
||||
else
|
||||
[[:name], [:name, *agg_selects]]
|
||||
end
|
||||
end
|
||||
|
||||
def unpack_row(row, dimension)
|
||||
if dimension[:group_column]
|
||||
# [name, dimension_id, count, sum_value, sum_value_business_hours]
|
||||
row
|
||||
else
|
||||
# [name, count, sum_value, sum_value_business_hours] → inject account id
|
||||
[row[0], @account.id, row[1], row[2], row[3]]
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_insert_rollups(rollup_rows)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
ReportingEventsRollup.insert_all(rollup_rows)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
module ReportingEvents::EventMetricRegistry
|
||||
# Describes one rollup metric emitted by a raw reporting event.
|
||||
# rollup_metric: metric name stored in reporting_events_rollups.
|
||||
# payload_kind: whether the emitted row carries only a count or a duration payload.
|
||||
Metric = Data.define(:rollup_metric, :payload_kind)
|
||||
|
||||
EVENTS = {
|
||||
conversation_resolved: [
|
||||
Metric.new(rollup_metric: :resolutions_count, payload_kind: :count),
|
||||
Metric.new(rollup_metric: :resolution_time, payload_kind: :duration)
|
||||
].freeze,
|
||||
first_response: [
|
||||
Metric.new(rollup_metric: :first_response, payload_kind: :duration)
|
||||
].freeze,
|
||||
reply_time: [
|
||||
Metric.new(rollup_metric: :reply_time, payload_kind: :duration)
|
||||
].freeze,
|
||||
conversation_bot_resolved: [
|
||||
Metric.new(rollup_metric: :bot_resolutions_count, payload_kind: :count)
|
||||
].freeze,
|
||||
conversation_bot_handoff: [
|
||||
Metric.new(rollup_metric: :bot_handoffs_count, payload_kind: :count)
|
||||
].freeze
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def event_names
|
||||
EVENTS.keys.map(&:to_s)
|
||||
end
|
||||
|
||||
def metrics_for(event)
|
||||
return {} if event.blank?
|
||||
|
||||
metrics_for_aggregate(
|
||||
event.name,
|
||||
count: 1,
|
||||
sum_value: event.try(:value),
|
||||
sum_value_business_hours: event.try(:value_in_business_hours)
|
||||
)
|
||||
end
|
||||
|
||||
def metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:)
|
||||
return {} if event_name.blank?
|
||||
|
||||
values = {
|
||||
count: count.to_i,
|
||||
sum_value: sum_value.to_f,
|
||||
sum_value_business_hours: sum_value_business_hours.to_f
|
||||
}
|
||||
|
||||
EVENTS.fetch(event_name.to_sym, []).to_h do |metric|
|
||||
[metric.rollup_metric, metric_values(metric.payload_kind, values)]
|
||||
end
|
||||
end
|
||||
|
||||
private_class_method def metric_values(payload_kind, values)
|
||||
case payload_kind
|
||||
when :count
|
||||
count_values(values[:count])
|
||||
when :duration
|
||||
duration_values(values)
|
||||
else
|
||||
raise ArgumentError, "Unknown metric payload kind: #{payload_kind.inspect}"
|
||||
end
|
||||
end
|
||||
|
||||
private_class_method def count_values(count)
|
||||
{ count: count, sum_value: 0, sum_value_business_hours: 0 }
|
||||
end
|
||||
|
||||
private_class_method def duration_values(values)
|
||||
{
|
||||
count: values[:count],
|
||||
sum_value: values[:sum_value],
|
||||
sum_value_business_hours: values[:sum_value_business_hours]
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
# Raw reporting events and rollup rows do not share a single metric namespace; this registry keeps write and read paths aligned.
|
||||
# TODO: Split this into separate registries for raw event mappings and report metric definitions.
|
||||
module ReportingEvents::MetricRegistry
|
||||
# Maps report summary response keys to the metric definitions they read from.
|
||||
SUMMARY_METRICS = {
|
||||
resolutions_count: :resolved_conversations_count,
|
||||
avg_resolution_time: :avg_resolution_time,
|
||||
avg_first_response_time: :avg_first_response_time,
|
||||
reply_time: :avg_reply_time
|
||||
}.freeze
|
||||
|
||||
# Expands each raw reporting event into the rollup metric payloads persisted for aggregation.
|
||||
EVENT_METRICS = {
|
||||
'conversation_resolved' => lambda do |values|
|
||||
{
|
||||
resolutions_count: count_metric(values[:count]),
|
||||
resolution_time: duration_metric(values)
|
||||
}
|
||||
end,
|
||||
'first_response' => ->(values) { { first_response: duration_metric(values) } },
|
||||
'reply_time' => ->(values) { { reply_time: duration_metric(values) } },
|
||||
'conversation_bot_resolved' => ->(values) { { bot_resolutions_count: count_metric(values[:count]) } },
|
||||
'conversation_bot_handoff' => ->(values) { { bot_handoffs_count: count_metric(values[:count]) } }
|
||||
}.freeze
|
||||
|
||||
# Describes which report metrics are supported and how each one is sourced and aggregated.
|
||||
REPORT_METRICS = {
|
||||
conversations_count: { aggregate: :count }.freeze,
|
||||
incoming_messages_count: { aggregate: :count }.freeze,
|
||||
outgoing_messages_count: { aggregate: :count }.freeze,
|
||||
avg_first_response_time: { raw_event_name: :first_response, rollup_metric: :first_response, aggregate: :average }.freeze,
|
||||
avg_resolution_time: { raw_event_name: :conversation_resolved, rollup_metric: :resolution_time, aggregate: :average }.freeze,
|
||||
reply_time: { raw_event_name: :reply_time, rollup_metric: :reply_time, aggregate: :average }.freeze,
|
||||
resolutions_count: { raw_event_name: :conversation_resolved, rollup_metric: :resolutions_count, aggregate: :count }.freeze,
|
||||
bot_resolutions_count: { raw_event_name: :conversation_bot_resolved, rollup_metric: :bot_resolutions_count, aggregate: :count }.freeze,
|
||||
bot_handoffs_count: { raw_event_name: :conversation_bot_handoff, rollup_metric: :bot_handoffs_count, aggregate: :count,
|
||||
raw_count_strategy: :distinct_conversation }.freeze
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def event_metrics_for(event)
|
||||
return {} if event.blank?
|
||||
return {} unless EVENT_METRICS.key?(event.name.to_s)
|
||||
|
||||
event_metrics_for_aggregate(
|
||||
event.name,
|
||||
count: 1,
|
||||
sum_value: event.try(:value),
|
||||
sum_value_business_hours: event.try(:value_in_business_hours)
|
||||
)
|
||||
end
|
||||
|
||||
def event_metrics_for_aggregate(event_name, count:, sum_value:, sum_value_business_hours:)
|
||||
values = {
|
||||
count: count.to_i,
|
||||
sum_value: sum_value.to_f,
|
||||
sum_value_business_hours: sum_value_business_hours.to_f
|
||||
}
|
||||
|
||||
EVENT_METRICS[event_name.to_s]&.call(values) || {}
|
||||
end
|
||||
|
||||
def report_metric(metric)
|
||||
return if metric.blank?
|
||||
|
||||
REPORT_METRICS[metric.to_sym]
|
||||
end
|
||||
|
||||
def supported_metric?(metric)
|
||||
report_metric(metric).present?
|
||||
end
|
||||
|
||||
def aggregate_for(metric)
|
||||
report_metric(metric)&.dig(:aggregate)
|
||||
end
|
||||
|
||||
def rollup_supported_metric?(metric)
|
||||
rollup_metric_for(metric).present?
|
||||
end
|
||||
|
||||
def rollup_metric_for(metric)
|
||||
report_metric(metric)&.dig(:rollup_metric)
|
||||
end
|
||||
|
||||
def raw_event_name_for(metric)
|
||||
report_metric(metric)&.dig(:raw_event_name)
|
||||
end
|
||||
|
||||
def summary_metrics
|
||||
SUMMARY_METRICS.map do |metric_name, summary_key|
|
||||
report_metric(metric_name).merge(metric_name: metric_name, summary_key: summary_key)
|
||||
end
|
||||
end
|
||||
|
||||
private_class_method def count_metric(count)
|
||||
{ count: count, sum_value: 0, sum_value_business_hours: 0 }
|
||||
end
|
||||
|
||||
private_class_method def duration_metric(values)
|
||||
{
|
||||
count: values[:count],
|
||||
sum_value: values[:sum_value],
|
||||
sum_value_business_hours: values[:sum_value_business_hours]
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
class ReportingEvents::RollupService
|
||||
def self.perform(reporting_event)
|
||||
new(reporting_event).perform
|
||||
end
|
||||
|
||||
def initialize(reporting_event)
|
||||
@reporting_event = reporting_event
|
||||
@account = reporting_event.account
|
||||
end
|
||||
|
||||
def perform
|
||||
return unless rollup_enabled?
|
||||
|
||||
rows = build_rollup_rows
|
||||
return if rows.empty?
|
||||
|
||||
upsert_rollups(rows)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# NOTE: This is intentionally not gated by the reporting_events_rollup feature flag.
|
||||
# Rollup data is collected for all accounts with a valid reporting timezone (soft toggle).
|
||||
# The feature flag only controls the read path — whether reports query rollups or raw events.
|
||||
def rollup_enabled?
|
||||
@account.reporting_timezone.present? && ActiveSupport::TimeZone[@account.reporting_timezone].present?
|
||||
end
|
||||
|
||||
def event_date
|
||||
@event_date ||= @reporting_event.created_at.in_time_zone(@account.reporting_timezone).to_date
|
||||
end
|
||||
|
||||
def dimensions
|
||||
{
|
||||
account: @account.id,
|
||||
agent: @reporting_event.user_id,
|
||||
inbox: @reporting_event.inbox_id
|
||||
}
|
||||
end
|
||||
|
||||
def build_rollup_rows
|
||||
event_metrics = ReportingEvents::EventMetricRegistry.metrics_for(@reporting_event)
|
||||
|
||||
dimensions.each_with_object([]) do |(dimension_type, dimension_id), rows|
|
||||
next if dimension_id.nil?
|
||||
|
||||
event_metrics.each do |metric, metric_data|
|
||||
rows << rollup_attributes(dimension_type, dimension_id, metric, metric_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_rollups(rows)
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
ReportingEventsRollup.upsert_all(
|
||||
rows,
|
||||
unique_by: [:account_id, :date, :dimension_type, :dimension_id, :metric],
|
||||
on_duplicate: upsert_on_duplicate_sql
|
||||
)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def rollup_attributes(dimension_type, dimension_id, metric, metric_data)
|
||||
{
|
||||
account_id: @account.id, date: event_date,
|
||||
dimension_type: dimension_type, dimension_id: dimension_id, metric: metric,
|
||||
count: metric_data[:count], sum_value: metric_data[:sum_value].to_f,
|
||||
sum_value_business_hours: metric_data[:sum_value_business_hours].to_f,
|
||||
created_at: Time.current, updated_at: Time.current
|
||||
}
|
||||
end
|
||||
|
||||
def upsert_on_duplicate_sql
|
||||
Arel.sql(
|
||||
'count = reporting_events_rollups.count + EXCLUDED.count, ' \
|
||||
'sum_value = reporting_events_rollups.sum_value + EXCLUDED.sum_value, ' \
|
||||
'sum_value_business_hours = reporting_events_rollups.sum_value_business_hours + EXCLUDED.sum_value_business_hours, ' \
|
||||
'updated_at = EXCLUDED.updated_at'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
class Reports::DataSource
|
||||
SUPPORTED_ROLLUP_DIMENSIONS = %w[account agent inbox].freeze
|
||||
|
||||
attr_reader :account, :metric, :dimension_type, :dimension_id,
|
||||
:scope, :range, :group_by, :timezone,
|
||||
:timezone_offset, :business_hours
|
||||
|
||||
class << self
|
||||
def for(**context)
|
||||
adapter_class_for(**context).new(**context)
|
||||
end
|
||||
|
||||
def rollup_eligible?(**context)
|
||||
account = context[:account]
|
||||
|
||||
rollup_enabled_for_account?(account) &&
|
||||
!hourly_grouping?(context[:group_by]) &&
|
||||
supported_dimension?(context[:dimension_type]) &&
|
||||
timezone_matches_account?(account, context[:timezone], context[:timezone_offset]) &&
|
||||
supported_metric?(context[:metric])
|
||||
end
|
||||
|
||||
def timezone_matches_account?(account, timezone, timezone_offset)
|
||||
return normalized_timezone_identifier(timezone) == normalized_timezone_identifier(account.reporting_timezone) if timezone.present?
|
||||
|
||||
return false if timezone_offset.blank?
|
||||
|
||||
offset_in_seconds = timezone_offset.to_f * 3600
|
||||
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
|
||||
account_zone&.now&.utc_offset == offset_in_seconds
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def adapter_class_for(**context)
|
||||
rollup_eligible?(**context) ? Reports::RollupDataSource : Reports::RawDataSource
|
||||
end
|
||||
|
||||
def rollup_enabled_for_account?(account)
|
||||
account.reporting_timezone.present? && account.feature_enabled?(:report_rollup)
|
||||
end
|
||||
|
||||
def hourly_grouping?(group_by)
|
||||
group_by.to_s == 'hour'
|
||||
end
|
||||
|
||||
def supported_dimension?(dimension_type)
|
||||
SUPPORTED_ROLLUP_DIMENSIONS.include?((dimension_type.presence || 'account').to_s)
|
||||
end
|
||||
|
||||
def supported_metric?(metric)
|
||||
metric.blank? || Reports::ReportMetricRegistry.rollup_supported?(metric)
|
||||
end
|
||||
|
||||
def normalized_timezone_identifier(timezone)
|
||||
ActiveSupport::TimeZone[timezone]&.tzinfo&.name
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(**context)
|
||||
@account = context[:account]
|
||||
@metric = context[:metric]
|
||||
@dimension_type = (context[:dimension_type].presence || 'account').to_s
|
||||
@dimension_id = context[:dimension_id]
|
||||
@scope = context[:scope]
|
||||
@range = context[:range]
|
||||
@group_by = context[:group_by].to_s.presence || 'day'
|
||||
@timezone = context[:timezone]
|
||||
@timezone_offset = context[:timezone_offset]
|
||||
@business_hours = context[:business_hours]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def report_metric
|
||||
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
|
||||
end
|
||||
|
||||
def average_metric?
|
||||
report_metric&.average?
|
||||
end
|
||||
|
||||
def count_metric?
|
||||
!average_metric?
|
||||
end
|
||||
|
||||
def rollup_metric
|
||||
report_metric&.rollup_metric
|
||||
end
|
||||
|
||||
def raw_event_name
|
||||
report_metric&.raw_event_name
|
||||
end
|
||||
|
||||
def raw_count_strategy
|
||||
report_metric&.raw_count_strategy
|
||||
end
|
||||
|
||||
def summary_metrics
|
||||
@summary_metrics ||= Reports::ReportMetricRegistry.summary_metrics
|
||||
end
|
||||
|
||||
def use_business_hours?
|
||||
ActiveModel::Type::Boolean.new.cast(business_hours)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
class Reports::RawDataSource < Reports::DataSource
|
||||
def timeseries
|
||||
average_metric? ? average_timeseries : count_timeseries
|
||||
end
|
||||
|
||||
def aggregate
|
||||
average_metric? ? average_scope.average(average_value_key) : count_scope.count
|
||||
end
|
||||
|
||||
def summary
|
||||
metric_results = summary_scope
|
||||
.select(*summary_select_fields)
|
||||
.group(summary_group_by_key)
|
||||
.index_by { |record| record.public_send(summary_index_key) }
|
||||
|
||||
merge_summary_results(metric_results, summary_conversation_counts)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count_timeseries
|
||||
grouped_count.map do |event_date, event_count|
|
||||
{ value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def average_timeseries
|
||||
grouped_average_time = grouped_average_scope.average(average_value_key)
|
||||
grouped_event_count = grouped_average_scope.count
|
||||
|
||||
grouped_average_time.each_with_object([]) do |(event_date, average_time), results|
|
||||
results << {
|
||||
value: average_time,
|
||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
||||
count: grouped_event_count[event_date]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def grouped_average_scope
|
||||
average_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
)
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
count_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
).count
|
||||
end
|
||||
|
||||
def average_scope
|
||||
scope.reporting_events.where(name: raw_event_name, created_at: range, account_id: account.id)
|
||||
end
|
||||
|
||||
def count_scope
|
||||
case metric.to_s
|
||||
when 'conversations_count'
|
||||
scope.conversations.where(account_id: account.id, created_at: range)
|
||||
when 'incoming_messages_count'
|
||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
||||
when 'outgoing_messages_count'
|
||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
||||
else
|
||||
reporting_event_count_scope
|
||||
end
|
||||
end
|
||||
|
||||
def reporting_event_count_scope
|
||||
events = scope.reporting_events.where(
|
||||
name: raw_event_name,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
|
||||
return events unless raw_count_strategy == :distinct_conversation
|
||||
|
||||
events.joins(:conversation).select(:conversation_id).distinct
|
||||
end
|
||||
|
||||
def summary_scope
|
||||
scope = account.reporting_events.where(created_at: range)
|
||||
return scope.joins(:conversation) if dimension_type == 'team'
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def summary_conversation_counts
|
||||
account.conversations
|
||||
.where(created_at: range)
|
||||
.group(summary_conversation_group_by_key)
|
||||
.count
|
||||
end
|
||||
|
||||
def merge_summary_results(metric_results, conversation_counts)
|
||||
(metric_results.keys | conversation_counts.keys).each_with_object({}) do |dimension_id, results|
|
||||
record = metric_results[dimension_id]
|
||||
results[dimension_id] = summary_attributes_for(record, conversation_counts[dimension_id])
|
||||
end
|
||||
end
|
||||
|
||||
def summary_select_fields
|
||||
["#{summary_group_by_key} as #{summary_index_key}"] + summary_metrics.map { |definition| summary_select_field(definition) }
|
||||
end
|
||||
|
||||
def summary_select_field(definition)
|
||||
if definition.count?
|
||||
"COUNT(CASE WHEN name = '#{definition.raw_event_name}' THEN 1 END) as #{definition.summary_key}"
|
||||
else
|
||||
"AVG(CASE WHEN name = '#{definition.raw_event_name}' THEN #{average_value_key} END) as #{definition.summary_key}"
|
||||
end
|
||||
end
|
||||
|
||||
def summary_attributes_for(record, conversations_count = 0)
|
||||
summary_metrics.each_with_object({ conversations_count: conversations_count.to_i }) do |definition, attributes|
|
||||
value = record&.public_send(definition.summary_key)
|
||||
attributes[definition.summary_key] = definition.count? ? value.to_i : value
|
||||
end
|
||||
end
|
||||
|
||||
def summary_group_by_key
|
||||
{
|
||||
'account' => :account_id,
|
||||
'agent' => :user_id,
|
||||
'inbox' => :inbox_id,
|
||||
'team' => 'conversations.team_id'
|
||||
}[dimension_type]
|
||||
end
|
||||
|
||||
def summary_conversation_group_by_key
|
||||
{
|
||||
'account' => :account_id,
|
||||
'agent' => :assignee_id,
|
||||
'inbox' => :inbox_id,
|
||||
'team' => :team_id
|
||||
}[dimension_type]
|
||||
end
|
||||
|
||||
def summary_index_key
|
||||
summary_group_by_key.to_s.split('.').last
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
use_business_hours? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,120 @@
|
||||
module Reports::ReportMetricRegistry
|
||||
# Describes one public report metric.
|
||||
# name: API-facing metric name requested by reports.
|
||||
# aggregate: whether the metric is a count or average.
|
||||
# raw_event_name: source reporting_events name for raw queries.
|
||||
# rollup_metric: source reporting_events_rollups metric for rollup queries.
|
||||
# summary_key: key used when this metric appears in grouped summary responses.
|
||||
# raw_count_strategy: optional raw-query counting rule, such as distinct conversations.
|
||||
Metric = Data.define(
|
||||
:name,
|
||||
:aggregate,
|
||||
:raw_event_name,
|
||||
:rollup_metric,
|
||||
:summary_key,
|
||||
:raw_count_strategy
|
||||
) do
|
||||
def initialize(name:, aggregate:, raw_event_name: nil, rollup_metric: nil, summary_key: nil, raw_count_strategy: nil)
|
||||
super
|
||||
end
|
||||
|
||||
def average?
|
||||
aggregate == :average
|
||||
end
|
||||
|
||||
def count?
|
||||
aggregate == :count
|
||||
end
|
||||
|
||||
def rollup_supported?
|
||||
rollup_metric.present?
|
||||
end
|
||||
|
||||
def summary?
|
||||
summary_key.present?
|
||||
end
|
||||
end
|
||||
|
||||
METRICS = {
|
||||
conversations_count: Metric.new(
|
||||
name: :conversations_count,
|
||||
aggregate: :count
|
||||
),
|
||||
incoming_messages_count: Metric.new(
|
||||
name: :incoming_messages_count,
|
||||
aggregate: :count
|
||||
),
|
||||
outgoing_messages_count: Metric.new(
|
||||
name: :outgoing_messages_count,
|
||||
aggregate: :count
|
||||
),
|
||||
avg_first_response_time: Metric.new(
|
||||
name: :avg_first_response_time,
|
||||
aggregate: :average,
|
||||
raw_event_name: :first_response,
|
||||
rollup_metric: :first_response,
|
||||
summary_key: :avg_first_response_time
|
||||
),
|
||||
avg_resolution_time: Metric.new(
|
||||
name: :avg_resolution_time,
|
||||
aggregate: :average,
|
||||
raw_event_name: :conversation_resolved,
|
||||
rollup_metric: :resolution_time,
|
||||
summary_key: :avg_resolution_time
|
||||
),
|
||||
reply_time: Metric.new(
|
||||
name: :reply_time,
|
||||
aggregate: :average,
|
||||
raw_event_name: :reply_time,
|
||||
rollup_metric: :reply_time,
|
||||
summary_key: :avg_reply_time
|
||||
),
|
||||
resolutions_count: Metric.new(
|
||||
name: :resolutions_count,
|
||||
aggregate: :count,
|
||||
raw_event_name: :conversation_resolved,
|
||||
rollup_metric: :resolutions_count,
|
||||
summary_key: :resolved_conversations_count
|
||||
),
|
||||
bot_resolutions_count: Metric.new(
|
||||
name: :bot_resolutions_count,
|
||||
aggregate: :count,
|
||||
raw_event_name: :conversation_bot_resolved,
|
||||
rollup_metric: :bot_resolutions_count
|
||||
),
|
||||
bot_handoffs_count: Metric.new(
|
||||
name: :bot_handoffs_count,
|
||||
aggregate: :count,
|
||||
raw_event_name: :conversation_bot_handoff,
|
||||
rollup_metric: :bot_handoffs_count,
|
||||
raw_count_strategy: :distinct_conversation
|
||||
)
|
||||
}.freeze
|
||||
|
||||
SUMMARY_METRIC_NAMES = %i[
|
||||
resolutions_count
|
||||
avg_resolution_time
|
||||
avg_first_response_time
|
||||
reply_time
|
||||
].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def fetch(name)
|
||||
return if name.blank?
|
||||
|
||||
METRICS[name.to_sym]
|
||||
end
|
||||
|
||||
def supported?(name)
|
||||
fetch(name).present?
|
||||
end
|
||||
|
||||
def rollup_supported?(name)
|
||||
fetch(name)&.rollup_supported? || false
|
||||
end
|
||||
|
||||
def summary_metrics
|
||||
SUMMARY_METRIC_NAMES.map { |metric_name| METRICS.fetch(metric_name) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,199 @@
|
||||
class Reports::RollupDataSource < Reports::DataSource
|
||||
def timeseries
|
||||
count_metric? ? count_timeseries : average_timeseries
|
||||
end
|
||||
|
||||
def aggregate
|
||||
count_metric? ? count_aggregate : average_aggregate
|
||||
end
|
||||
|
||||
def summary
|
||||
metric_results = summary_rows.index_by(&:dimension_id)
|
||||
|
||||
merge_summary_results(metric_results, summary_conversation_counts)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count_timeseries
|
||||
grouped_data = all_periods_in_range.index_with { 0 }
|
||||
|
||||
rollup_scope.each do |row|
|
||||
date_key = normalized_period_key(row.date)
|
||||
grouped_data[date_key] ||= 0
|
||||
grouped_data[date_key] += row.count
|
||||
end
|
||||
|
||||
results = grouped_data.map do |date_key, count|
|
||||
{ value: count, timestamp: date_key.in_time_zone(timezone).to_i }
|
||||
end
|
||||
|
||||
results.sort_by { |result| result[:timestamp] }
|
||||
end
|
||||
|
||||
def average_timeseries
|
||||
grouped_data = all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } }
|
||||
|
||||
rollup_scope.each { |row| accumulate_average_row(grouped_data, row) }
|
||||
|
||||
results = grouped_data.map do |date_key, data|
|
||||
{
|
||||
value: data[:count].zero? ? 0 : data[:sum_value] / data[:count],
|
||||
timestamp: date_key.in_time_zone(timezone).to_i,
|
||||
count: data[:count]
|
||||
}
|
||||
end
|
||||
|
||||
results.sort_by { |result| result[:timestamp] }
|
||||
end
|
||||
|
||||
def count_aggregate
|
||||
rollup_scope.sum(:count).to_i
|
||||
end
|
||||
|
||||
def average_aggregate
|
||||
result = rollup_scope.pick(Arel.sql("SUM(count), SUM(#{rollup_value_column})"))
|
||||
return nil if result.blank? || result[0].to_i.zero?
|
||||
|
||||
result[1].to_f / result[0].to_i
|
||||
end
|
||||
|
||||
def rollup_scope
|
||||
ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
metric: rollup_metric,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id_for_rollup,
|
||||
date: rollup_date_range
|
||||
)
|
||||
end
|
||||
|
||||
def summary_rows
|
||||
ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
dimension_type: dimension_type,
|
||||
date: rollup_date_range
|
||||
).group(:dimension_id).select(*summary_select_fields)
|
||||
end
|
||||
|
||||
def summary_conversation_counts
|
||||
account.conversations
|
||||
.where(created_at: range)
|
||||
.group(summary_conversation_group_by_key)
|
||||
.count
|
||||
end
|
||||
|
||||
def merge_summary_results(metric_results, conversation_counts)
|
||||
(metric_results.keys | conversation_counts.keys).index_with do |dimension_id|
|
||||
summary_attributes_for(metric_results[dimension_id], conversation_counts[dimension_id])
|
||||
end
|
||||
end
|
||||
|
||||
def summary_select_fields
|
||||
['dimension_id'] + summary_metrics.flat_map { |definition| summary_select_fields_for_metric(definition) }
|
||||
end
|
||||
|
||||
def summary_select_fields_for_metric(definition)
|
||||
return [sum_count_select(definition.rollup_metric, definition.summary_key)] if definition.count?
|
||||
|
||||
[
|
||||
sum_count_select(definition.rollup_metric, summary_count_alias(definition)),
|
||||
sum_value_select(definition.rollup_metric, summary_sum_alias(definition))
|
||||
]
|
||||
end
|
||||
|
||||
def sum_count_select(rollup_metric_name, alias_name)
|
||||
"SUM(CASE WHEN metric = '#{rollup_metric_name}' THEN count ELSE 0 END) as #{alias_name}"
|
||||
end
|
||||
|
||||
def sum_value_select(rollup_metric_name, alias_name)
|
||||
"SUM(CASE WHEN metric = '#{rollup_metric_name}' THEN #{rollup_value_column} ELSE 0 END) as #{alias_name}"
|
||||
end
|
||||
|
||||
def summary_attributes_for(row, conversations_count = 0)
|
||||
summary_metrics.each_with_object({ conversations_count: conversations_count.to_i }) do |definition, attributes|
|
||||
attributes[definition.summary_key] = summary_value_for(row, definition)
|
||||
end
|
||||
end
|
||||
|
||||
def summary_value_for(row, definition)
|
||||
return row&.public_send(definition.summary_key).to_i if definition.count?
|
||||
|
||||
average_from(row&.public_send(summary_sum_alias(definition)), row&.public_send(summary_count_alias(definition)))
|
||||
end
|
||||
|
||||
def summary_count_alias(definition)
|
||||
"#{definition.summary_key}_count"
|
||||
end
|
||||
|
||||
def summary_sum_alias(definition)
|
||||
"#{definition.summary_key}_sum_value"
|
||||
end
|
||||
|
||||
def dimension_id_for_rollup
|
||||
dimension_type == 'account' ? account.id : scope.id
|
||||
end
|
||||
|
||||
def summary_conversation_group_by_key
|
||||
{
|
||||
'account' => :account_id,
|
||||
'agent' => :assignee_id,
|
||||
'inbox' => :inbox_id,
|
||||
'team' => :team_id
|
||||
}[dimension_type]
|
||||
end
|
||||
|
||||
def rollup_value_column
|
||||
use_business_hours? ? :sum_value_business_hours : :sum_value
|
||||
end
|
||||
|
||||
def rollup_date_range
|
||||
tz = ActiveSupport::TimeZone[account.reporting_timezone]
|
||||
start_date = range.first.in_time_zone(tz).to_date
|
||||
end_date = (range.last - 1.second).in_time_zone(tz).to_date
|
||||
start_date..end_date
|
||||
end
|
||||
|
||||
def all_periods_in_range
|
||||
current = normalized_period_key(rollup_date_range.first)
|
||||
periods = []
|
||||
|
||||
while current <= rollup_date_range.last
|
||||
periods << current
|
||||
current = advance_period(current)
|
||||
end
|
||||
|
||||
periods
|
||||
end
|
||||
|
||||
def accumulate_average_row(grouped_data, row)
|
||||
date_key = normalized_period_key(row.date)
|
||||
grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
|
||||
grouped_data[date_key][:count] += row.count
|
||||
grouped_data[date_key][:sum_value] += row.public_send(rollup_value_column)
|
||||
end
|
||||
|
||||
def normalized_period_key(date)
|
||||
case group_by
|
||||
when 'week' then date.beginning_of_week(:sunday)
|
||||
when 'month' then date.beginning_of_month
|
||||
when 'year' then date.beginning_of_year
|
||||
else date
|
||||
end
|
||||
end
|
||||
|
||||
def advance_period(date)
|
||||
case group_by
|
||||
when 'week' then date + 1.week
|
||||
when 'month' then date + 1.month
|
||||
when 'year' then date + 1.year
|
||||
else date + 1.day
|
||||
end
|
||||
end
|
||||
|
||||
def average_from(sum_value, count)
|
||||
return nil if count.to_i.zero?
|
||||
|
||||
sum_value.to_f / count.to_i
|
||||
end
|
||||
end
|
||||
@@ -39,11 +39,11 @@ class Whatsapp::HealthService
|
||||
|
||||
def health_fields
|
||||
%w[
|
||||
id
|
||||
quality_rating
|
||||
messaging_limit_tier
|
||||
code_verification_status
|
||||
account_mode
|
||||
id
|
||||
display_phone_number
|
||||
name_status
|
||||
verified_name
|
||||
@@ -68,6 +68,7 @@ class Whatsapp::HealthService
|
||||
|
||||
def format_health_response(response)
|
||||
{
|
||||
id: response['id'],
|
||||
display_phone_number: response['display_phone_number'],
|
||||
verified_name: response['verified_name'],
|
||||
name_status: response['name_status'],
|
||||
@@ -75,10 +76,20 @@ class Whatsapp::HealthService
|
||||
messaging_limit_tier: response['messaging_limit_tier'],
|
||||
account_mode: response['account_mode'],
|
||||
code_verification_status: response['code_verification_status'],
|
||||
webhook_configuration: response['webhook_configuration'],
|
||||
expected_webhook_url: build_expected_webhook_url,
|
||||
throughput: response['throughput'],
|
||||
last_onboarded_time: response['last_onboarded_time'],
|
||||
platform_type: response['platform_type'],
|
||||
certificate: response['certificate'],
|
||||
business_id: @channel.provider_config['business_account_id']
|
||||
}
|
||||
end
|
||||
|
||||
def build_expected_webhook_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
return nil if frontend_url.blank?
|
||||
|
||||
"#{frontend_url}/webhooks/whatsapp/#{@channel.phone_number}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Whatsapp::WebhookSetupService
|
||||
def initialize(channel, waba_id, access_token)
|
||||
def initialize(channel, waba_id = nil, access_token = nil)
|
||||
@channel = channel
|
||||
@waba_id = waba_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
@waba_id = waba_id || channel.provider_config['business_account_id']
|
||||
@access_token = access_token || channel.provider_config['api_key']
|
||||
@api_client = Whatsapp::FacebookApiClient.new(@access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
@@ -17,6 +17,11 @@ class Whatsapp::WebhookSetupService
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
def register_callback
|
||||
validate_parameters!
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
@@ -33,8 +38,6 @@ class Whatsapp::WebhookSetupService
|
||||
store_pin(pin)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
|
||||
# Continue with webhook setup even if registration fails
|
||||
# This is just a warning, not a blocking error
|
||||
end
|
||||
|
||||
def fetch_or_create_pin
|
||||
|
||||
@@ -191,7 +191,6 @@
|
||||
- name: assignment_v2
|
||||
display_name: Assignment V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: twilio_content_templates
|
||||
display_name: Twilio Content Templates
|
||||
enabled: false
|
||||
|
||||
@@ -48,6 +48,9 @@ en:
|
||||
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
|
||||
|
||||
errors:
|
||||
account:
|
||||
reporting_timezone:
|
||||
invalid: is not a valid timezone
|
||||
validations:
|
||||
presence: must not be blank
|
||||
webhook:
|
||||
@@ -235,8 +238,10 @@ en:
|
||||
activity:
|
||||
captain:
|
||||
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
|
||||
resolved_with_reason: 'Conversation was marked resolved by %{user_name} (%{reason})'
|
||||
resolved_by_tool: 'Conversation was marked resolved by %{user_name}: %{reason}'
|
||||
open: 'Conversation was marked open by %{user_name}'
|
||||
open_with_reason: 'Conversation was marked open by %{user_name} (%{reason})'
|
||||
auto_opened_after_agent_reply: 'Conversation was marked open automatically after an agent reply'
|
||||
agent_bot:
|
||||
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
|
||||
|
||||
@@ -104,11 +104,11 @@ wistia:
|
||||
</div>
|
||||
|
||||
bunny:
|
||||
regex: 'https?://iframe\.mediadelivery\.net/play/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
|
||||
regex: 'https?://(?:iframe|player)\.mediadelivery\.net/(?:play|embed)/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
|
||||
template: |
|
||||
<div style="position: relative; padding-top: 56.25%;">
|
||||
<iframe
|
||||
src="https://iframe.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
|
||||
src="https://player.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
|
||||
title="Bunny video player"
|
||||
loading="lazy"
|
||||
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
|
||||
|
||||
@@ -218,6 +218,7 @@ Rails.application.routes.draw do
|
||||
delete :avatar, on: :member
|
||||
post :sync_templates, on: :member
|
||||
get :health, on: :member
|
||||
post :register_webhook, on: :member
|
||||
if ChatwootApp.enterprise?
|
||||
resource :conference, only: %i[create destroy], controller: 'conference' do
|
||||
get :token, on: :member
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
class CreateReportingEventsRollup < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
create_rollups_table
|
||||
add_rollups_indexes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_rollups_table
|
||||
create_table :reporting_events_rollups do |t|
|
||||
t.integer :account_id, null: false
|
||||
t.date :date, null: false
|
||||
t.string :dimension_type, null: false
|
||||
t.bigint :dimension_id, null: false
|
||||
t.string :metric, null: false
|
||||
t.bigint :count, default: 0, null: false
|
||||
t.float :sum_value, default: 0.0, null: false
|
||||
t.float :sum_value_business_hours, default: 0.0, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
|
||||
def add_rollups_indexes
|
||||
add_index :reporting_events_rollups,
|
||||
[:account_id, :date, :dimension_type, :dimension_id, :metric],
|
||||
unique: true, name: 'index_rollup_unique_key', algorithm: :concurrently
|
||||
|
||||
add_index :reporting_events_rollups,
|
||||
[:account_id, :metric, :date],
|
||||
name: 'index_rollup_timeseries', algorithm: :concurrently
|
||||
|
||||
add_index :reporting_events_rollups,
|
||||
[:account_id, :dimension_type, :date],
|
||||
name: 'index_rollup_summary', algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
@@ -1124,6 +1124,22 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
|
||||
t.index ["user_id"], name: "index_reporting_events_on_user_id"
|
||||
end
|
||||
|
||||
create_table "reporting_events_rollups", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.date "date", null: false
|
||||
t.string "dimension_type", null: false
|
||||
t.bigint "dimension_id", null: false
|
||||
t.string "metric", null: false
|
||||
t.bigint "count", default: 0, null: false
|
||||
t.float "sum_value", default: 0.0, null: false
|
||||
t.float "sum_value_business_hours", default: 0.0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "date", "dimension_type", "dimension_id", "metric"], name: "index_rollup_unique_key", unique: true
|
||||
t.index ["account_id", "dimension_type", "date"], name: "index_rollup_summary"
|
||||
t.index ["account_id", "metric", "date"], name: "index_rollup_timeseries"
|
||||
end
|
||||
|
||||
create_table "sla_events", force: :cascade do |t|
|
||||
t.bigint "applied_sla_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class SamlUserBuilder
|
||||
class AuthenticationFailed < StandardError; end
|
||||
|
||||
def initialize(auth_hash, account_id)
|
||||
@auth_hash = auth_hash
|
||||
@account_id = account_id
|
||||
@@ -16,13 +18,20 @@ class SamlUserBuilder
|
||||
def find_or_create_user
|
||||
user = User.from_email(auth_attribute('email'))
|
||||
|
||||
if user
|
||||
confirm_user_if_required(user)
|
||||
convert_existing_user_to_saml(user)
|
||||
return user
|
||||
end
|
||||
return create_user unless user
|
||||
return existing_user_for_account(user) if user_belongs_to_account?(user)
|
||||
|
||||
create_user
|
||||
raise AuthenticationFailed, I18n.t('auth.saml.authentication_failed')
|
||||
end
|
||||
|
||||
def existing_user_for_account(user)
|
||||
confirm_user_if_required(user)
|
||||
convert_existing_user_to_saml(user)
|
||||
user
|
||||
end
|
||||
|
||||
def user_belongs_to_account?(user)
|
||||
user.account_users.exists?(account_id: @account_id)
|
||||
end
|
||||
|
||||
def confirm_user_if_required(user)
|
||||
|
||||
@@ -24,10 +24,16 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def playground
|
||||
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
additional_message: params[:message_content],
|
||||
message_history: message_history
|
||||
)
|
||||
response = if captain_v2_enabled?
|
||||
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, source: 'playground').generate_response(
|
||||
message_history: playground_message_history
|
||||
)
|
||||
else
|
||||
Captain::Llm::AssistantChatService.new(assistant: @assistant, source: 'playground').generate_response(
|
||||
additional_message: playground_params[:message_content],
|
||||
message_history: message_history
|
||||
)
|
||||
end
|
||||
|
||||
render json: response
|
||||
end
|
||||
@@ -64,10 +70,31 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def playground_params
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
|
||||
end
|
||||
|
||||
def message_history
|
||||
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
|
||||
(playground_params[:message_history] || []).map do |message|
|
||||
{
|
||||
role: message[:role],
|
||||
content: message[:content],
|
||||
agent_name: message[:agent_name]
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
|
||||
def playground_message_history
|
||||
history = message_history
|
||||
current_message = playground_params[:message_content]
|
||||
return history if current_message.blank?
|
||||
|
||||
current_user_message = { role: 'user', content: current_message }
|
||||
return history if history.last == current_user_message
|
||||
|
||||
history + [current_user_message]
|
||||
end
|
||||
|
||||
def captain_v2_enabled?
|
||||
@assistant.account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
end
|
||||
|
||||
+18
-14
@@ -39,7 +39,7 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
error = params[:message] || 'authentication-failed'
|
||||
|
||||
if for_mobile?(relay_state)
|
||||
redirect_to_mobile_error(error, relay_state)
|
||||
redirect_to_mobile_error(error)
|
||||
else
|
||||
redirect_to login_page_url(error: "saml-#{error}")
|
||||
end
|
||||
@@ -51,23 +51,15 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
account_id = extract_saml_account_id
|
||||
relay_state = saml_relay_state
|
||||
|
||||
unless saml_enabled_for_account?(account_id)
|
||||
return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
|
||||
|
||||
return redirect_to login_page_url(error: 'saml-not-enabled')
|
||||
end
|
||||
return handle_saml_auth_error(relay_state, 'saml-not-enabled') unless saml_enabled_for_account?(account_id)
|
||||
|
||||
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
|
||||
|
||||
if @resource.persisted?
|
||||
return sign_in_user_on_mobile if for_mobile?(relay_state)
|
||||
return sign_in_saml_user(relay_state) if @resource.persisted?
|
||||
|
||||
sign_in_user
|
||||
else
|
||||
return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
|
||||
|
||||
redirect_to login_page_url(error: 'saml-authentication-failed')
|
||||
end
|
||||
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
|
||||
end
|
||||
|
||||
def extract_saml_account_id
|
||||
@@ -82,6 +74,18 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
relay_state.to_s.casecmp('mobile').zero?
|
||||
end
|
||||
|
||||
def sign_in_saml_user(relay_state)
|
||||
return sign_in_user_on_mobile if for_mobile?(relay_state)
|
||||
|
||||
sign_in_user
|
||||
end
|
||||
|
||||
def handle_saml_auth_error(relay_state, error)
|
||||
return redirect_to_mobile_error(error) if for_mobile?(relay_state)
|
||||
|
||||
redirect_to login_page_url(error: error)
|
||||
end
|
||||
|
||||
def redirect_to_mobile_error(error)
|
||||
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
|
||||
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
|
||||
|
||||
@@ -5,7 +5,6 @@ module Captain::ChatHelper
|
||||
|
||||
def request_chat_completion
|
||||
log_chat_completion_request
|
||||
|
||||
chat = build_chat
|
||||
|
||||
add_messages_to_chat(chat)
|
||||
@@ -86,7 +85,8 @@ module Captain::ChatHelper
|
||||
temperature: temperature,
|
||||
metadata: {
|
||||
assistant_id: @assistant&.id,
|
||||
channel_type: resolved_channel_type
|
||||
channel_type: resolved_channel_type,
|
||||
source: @source
|
||||
}.compact
|
||||
}
|
||||
end
|
||||
@@ -130,7 +130,6 @@ module Captain::ChatHelper
|
||||
end
|
||||
|
||||
def log_chat_completion_request
|
||||
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion " \
|
||||
"for messages #{@messages} with #{@tools&.length || 0} tools")
|
||||
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, requesting completion for #{@messages} with #{@tools&.length || 0} tools")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON = 'no outstanding questions'.freeze
|
||||
CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON = 'pending clarification from customer'.freeze
|
||||
|
||||
queue_as :low
|
||||
|
||||
def perform(inbox)
|
||||
return if inbox.account.captain_disable_auto_resolve
|
||||
return if inbox.account.captain_auto_resolve_disabled?
|
||||
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
|
||||
resolvable_conversations.each do |conversation|
|
||||
create_outgoing_message(conversation, inbox)
|
||||
conversation.resolved!
|
||||
if evaluate_conversation_completion?(inbox.account)
|
||||
perform_with_evaluation(inbox)
|
||||
else
|
||||
perform_time_based(inbox)
|
||||
end
|
||||
ensure
|
||||
Current.reset
|
||||
@@ -17,18 +18,122 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def create_outgoing_message(conversation, inbox)
|
||||
def evaluate_conversation_completion?(account)
|
||||
account.feature_enabled?('captain_tasks') && account.captain_auto_resolve_evaluated?
|
||||
end
|
||||
|
||||
def perform_time_based(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_pending_conversations(inbox).each do |conversation|
|
||||
create_resolution_message(conversation, inbox)
|
||||
conversation.resolved!
|
||||
end
|
||||
end
|
||||
|
||||
def perform_with_evaluation(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_pending_conversations(inbox).each do |conversation|
|
||||
evaluation = evaluate_conversation(conversation, inbox)
|
||||
next unless still_resolvable_after_evaluation?(conversation)
|
||||
|
||||
if evaluation[:complete]
|
||||
resolve_conversation(conversation, inbox, evaluation[:reason])
|
||||
else
|
||||
handoff_conversation(conversation, inbox, evaluation[:reason])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def evaluate_conversation(conversation, inbox)
|
||||
Captain::ConversationCompletionService.new(
|
||||
account: inbox.account,
|
||||
conversation_display_id: conversation.display_id
|
||||
).perform
|
||||
end
|
||||
|
||||
def resolvable_pending_conversations(inbox)
|
||||
inbox.conversations.pending
|
||||
.where('last_activity_at < ?', auto_resolve_cutoff_time)
|
||||
.limit(Limits::BULK_ACTIONS_LIMIT)
|
||||
end
|
||||
|
||||
def still_resolvable_after_evaluation?(conversation)
|
||||
conversation.reload
|
||||
conversation.pending? && conversation.last_activity_at < auto_resolve_cutoff_time
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
false
|
||||
end
|
||||
|
||||
def auto_resolve_cutoff_time
|
||||
Time.now.utc - 1.hour
|
||||
end
|
||||
|
||||
def resolve_conversation(conversation, inbox, reason)
|
||||
create_private_note(conversation, inbox, "Auto-resolved: #{reason}")
|
||||
create_resolution_message(conversation, inbox)
|
||||
conversation.with_captain_activity_context(
|
||||
reason: CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON,
|
||||
reason_type: :inference
|
||||
) { conversation.resolved! }
|
||||
conversation.dispatch_captain_inference_resolved_event
|
||||
end
|
||||
|
||||
def handoff_conversation(conversation, inbox, reason)
|
||||
create_private_note(conversation, inbox, "Auto-handoff: #{reason}")
|
||||
create_handoff_message(conversation, inbox)
|
||||
conversation.with_captain_activity_context(
|
||||
reason: CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON,
|
||||
reason_type: :inference
|
||||
) { conversation.bot_handoff! }
|
||||
conversation.dispatch_captain_inference_handoff_event
|
||||
send_out_of_office_message_if_applicable(conversation.reload)
|
||||
end
|
||||
|
||||
def send_out_of_office_message_if_applicable(conversation)
|
||||
# Campaign conversations should never receive OOO templates — the campaign itself
|
||||
# serves as the initial outreach, and OOO would be confusing in that context.
|
||||
return if conversation.campaign.present?
|
||||
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
|
||||
def create_private_note(conversation, inbox, content)
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: content
|
||||
)
|
||||
end
|
||||
|
||||
def create_resolution_message(conversation, inbox)
|
||||
I18n.with_locale(inbox.account.locale) do
|
||||
resolution_message = inbox.captain_assistant.config['resolution_message']
|
||||
conversation.messages.create!(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
|
||||
sender: inbox.captain_assistant
|
||||
}
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
|
||||
sender: inbox.captain_assistant
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_handoff_message(conversation, inbox)
|
||||
handoff_message = inbox.captain_assistant.config['handoff_message']
|
||||
return if handoff_message.blank?
|
||||
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: handoff_message,
|
||||
preserve_waiting_since: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob
|
||||
inbox = captain_inbox.inbox
|
||||
|
||||
next if inbox.email?
|
||||
next if inbox.account.captain_disable_auto_resolve
|
||||
next if inbox.account.captain_auto_resolve_disabled?
|
||||
|
||||
Captain::InboxPendingConversationsResolutionJob.perform_later(
|
||||
inbox
|
||||
|
||||
@@ -106,7 +106,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
scenarios: scenarios.enabled.map do |scenario|
|
||||
{
|
||||
title: scenario.title,
|
||||
key: scenario.title.parameterize.underscore,
|
||||
key: scenario.handoff_key,
|
||||
description: scenario.description
|
||||
}
|
||||
end,
|
||||
|
||||
@@ -24,6 +24,19 @@ class Captain::Scenario < ApplicationRecord
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
# OpenAI enforces a 64-char limit on function names. The ai-agents gem
|
||||
# prepends "handoff_to_" (11 chars), so we keep a safety margin and cap
|
||||
# the full tool name to MAX_HANDOFF_TOOL_NAME_LENGTH (60 chars).
|
||||
# Format: "scenario_{id}_{slug}_agent" for persisted records (stable + readable),
|
||||
# and "scenario_draft_{slug}_agent" for unsaved records, with slug truncated
|
||||
# based on the available length budget.
|
||||
HANDOFF_TOOL_PREFIX = 'handoff_to_'.freeze
|
||||
HANDOFF_KEY_PREFIX = 'scenario'.freeze
|
||||
HANDOFF_KEY_SUFFIX = 'agent'.freeze
|
||||
MAX_HANDOFF_TOOL_NAME_LENGTH = 60
|
||||
MAX_AGENT_NAME_LENGTH = MAX_HANDOFF_TOOL_NAME_LENGTH - HANDOFF_TOOL_PREFIX.length
|
||||
MAX_HANDOFF_SLUG_LENGTH = 24
|
||||
|
||||
self.table_name = 'captain_scenarios'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
@@ -42,6 +55,10 @@ class Captain::Scenario < ApplicationRecord
|
||||
|
||||
before_save :resolve_tool_references
|
||||
|
||||
def handoff_key
|
||||
[handoff_id_key, compact_handoff_slug, HANDOFF_KEY_SUFFIX].compact.join('_')
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
{
|
||||
title: title,
|
||||
@@ -56,7 +73,28 @@ class Captain::Scenario < ApplicationRecord
|
||||
private
|
||||
|
||||
def agent_name
|
||||
"#{title} Agent".parameterize(separator: '_')
|
||||
handoff_key
|
||||
end
|
||||
|
||||
def handoff_id_key
|
||||
return "#{HANDOFF_KEY_PREFIX}_#{id}" if id.present?
|
||||
|
||||
"#{HANDOFF_KEY_PREFIX}_draft"
|
||||
end
|
||||
|
||||
def compact_handoff_slug
|
||||
slug = title.to_s.parameterize(separator: '_').presence
|
||||
return nil if slug.blank?
|
||||
|
||||
max_slug_length = [MAX_HANDOFF_SLUG_LENGTH, dynamic_slug_max_length].min
|
||||
return nil if max_slug_length <= 0
|
||||
|
||||
slug.first(max_slug_length).sub(/_+\z/, '').presence
|
||||
end
|
||||
|
||||
def dynamic_slug_max_length
|
||||
# handoff_to_#{scenario_<id>_<slug>_agent}
|
||||
MAX_AGENT_NAME_LENGTH - handoff_id_key.length - HANDOFF_KEY_SUFFIX.length - 2
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
|
||||
@@ -6,18 +6,30 @@ module Enterprise::ActivityMessageHandler
|
||||
key = captain_activity_key
|
||||
return unless key
|
||||
|
||||
I18n.t(key, user_name: Current.executed_by.name, reason: Current.captain_resolve_reason, locale: locale)
|
||||
I18n.t(key, user_name: Current.executed_by.name, reason: captain_status_reason, locale: locale)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_status_reason
|
||||
captain_activity_reason.presence
|
||||
end
|
||||
|
||||
def captain_activity_key
|
||||
if resolved? && Current.captain_resolve_reason.present?
|
||||
'conversations.activity.captain.resolved_by_tool'
|
||||
elsif resolved?
|
||||
'conversations.activity.captain.resolved'
|
||||
elsif open?
|
||||
'conversations.activity.captain.open'
|
||||
end
|
||||
return captain_resolved_activity_key if resolved?
|
||||
return captain_open_activity_key if open?
|
||||
end
|
||||
|
||||
def captain_resolved_activity_key
|
||||
return 'conversations.activity.captain.resolved_by_tool' if captain_activity_reason_type == :tool && captain_status_reason.present?
|
||||
return 'conversations.activity.captain.resolved_with_reason' if captain_status_reason.present?
|
||||
|
||||
'conversations.activity.captain.resolved'
|
||||
end
|
||||
|
||||
def captain_open_activity_key
|
||||
return 'conversations.activity.captain.open_with_reason' if captain_status_reason.present?
|
||||
|
||||
'conversations.activity.captain.open'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
module Enterprise::Conversation
|
||||
attr_accessor :captain_activity_reason, :captain_activity_reason_type
|
||||
|
||||
def dispatch_captain_inference_resolved_event
|
||||
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_RESOLVED)
|
||||
end
|
||||
|
||||
def dispatch_captain_inference_handoff_event
|
||||
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_HANDOFF)
|
||||
end
|
||||
|
||||
def list_of_keys
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
def with_captain_activity_context(reason:, reason_type:)
|
||||
previous_reason = captain_activity_reason
|
||||
previous_reason_type = captain_activity_reason_type
|
||||
|
||||
self.captain_activity_reason = reason
|
||||
self.captain_activity_reason_type = reason_type
|
||||
yield
|
||||
ensure
|
||||
self.captain_activity_reason = previous_reason
|
||||
self.captain_activity_reason_type = previous_reason_type
|
||||
end
|
||||
|
||||
# Include select additional_attributes keys (call related) for update events
|
||||
def allowed_keys?
|
||||
return true if super
|
||||
@@ -13,4 +35,10 @@ module Enterprise::Conversation
|
||||
changed_attr_keys = attrs_change[1].keys
|
||||
changed_attr_keys.intersect?(%w[call_status])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
dispatcher_dispatch(event_name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,11 +19,11 @@ class Captain::Assistant::AgentRunnerService
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {})
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@callbacks = callbacks
|
||||
@source = source
|
||||
end
|
||||
|
||||
def generate_response(message_history: [])
|
||||
@@ -32,8 +32,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
process_agent_result(result)
|
||||
rescue StandardError => e
|
||||
# when running the agent runner service in a rake task, the conversation might not have an account associated
|
||||
# for regular production usage, it will run just fine
|
||||
# In rake/local runs, conversation may not be present, so account is optional here.
|
||||
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
|
||||
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
@@ -128,6 +127,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
@@ -140,8 +140,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
return unless @conversation.contact_inbox
|
||||
|
||||
state[:contact_inbox] =
|
||||
@conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
|
||||
state[:contact_inbox] = @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
@@ -180,6 +179,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
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],
|
||||
ATTR_LANGFUSE_TRACE_INPUT => trace_input,
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
|
||||
}.compact.transform_values(&:to_s)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
def initialize(assistant: nil, conversation_id: nil)
|
||||
def initialize(assistant: nil, conversation_id: nil, source: nil)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@conversation_id = conversation_id
|
||||
@source = source
|
||||
|
||||
@messages = [system_message]
|
||||
@response = ''
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
class Captain::ConversationCompletionSchema < RubyLLM::Schema
|
||||
boolean :complete, description: 'Whether the conversation is complete and can be closed'
|
||||
string :reason, description: 'Brief explanation of why the conversation is complete or incomplete'
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
# Evaluates whether a conversation is complete and can be auto-resolved.
|
||||
# Used by InboxPendingConversationsResolutionJob to determine if inactive
|
||||
# conversations should be resolved or handed off to human agents.
|
||||
#
|
||||
# NOTE: This service intentionally does NOT count toward Captain usage limits.
|
||||
# The response excludes the :message key that Enterprise::Captain::BaseTaskService
|
||||
# checks for usage tracking. This is an internal operational evaluation,
|
||||
# not a customer-facing value-add, so we don't charge for it.
|
||||
class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::ConversationCompletionSchema
|
||||
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
content = format_messages_as_string
|
||||
return default_incomplete_response('No messages found') if content.blank?
|
||||
|
||||
response = make_api_call(
|
||||
model: InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('conversation_completion') },
|
||||
{ role: 'user', content: content }
|
||||
],
|
||||
schema: RESPONSE_SCHEMA
|
||||
)
|
||||
|
||||
return default_incomplete_response(response[:error]) if response[:error].present?
|
||||
|
||||
parse_response(response[:message])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prompt_from_file(file_name)
|
||||
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
|
||||
end
|
||||
|
||||
def format_messages_as_string
|
||||
messages = conversation_messages(start_from: 0)
|
||||
messages.map do |msg|
|
||||
sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant'
|
||||
"#{sender_type}: #{msg[:content]}"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def parse_response(message)
|
||||
return default_incomplete_response('Invalid response format') unless message.is_a?(Hash)
|
||||
|
||||
{
|
||||
complete: message['complete'] == true,
|
||||
reason: message['reason'] || 'No reason provided'
|
||||
}
|
||||
end
|
||||
|
||||
def default_incomplete_response(reason)
|
||||
{ complete: false, reason: reason }
|
||||
end
|
||||
|
||||
# Prefer the system API key over the account's OpenAI hook key.
|
||||
# This is an internal operational evaluation, not a customer-triggered feature,
|
||||
# so it should not consume the customer's OpenAI credits on hosted platforms.
|
||||
# Falls back to the account hook for self-hosted deployments without a system key.
|
||||
def api_key
|
||||
@api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key')
|
||||
end
|
||||
|
||||
def event_name
|
||||
'captain.conversation_completion'
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
Captain::ConversationCompletionService.prepend_mod_with('Captain::ConversationCompletionService')
|
||||
@@ -0,0 +1,21 @@
|
||||
You are evaluating whether a customer support conversation is complete and can be safely closed. When in doubt, keep the conversation OPEN. It is far better to hand off to a human agent unnecessarily than to close a conversation where the customer still needs help.
|
||||
|
||||
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
|
||||
|
||||
A conversation is INCOMPLETE (keep open) if ANY of these apply:
|
||||
- The assistant suggested the customer try something or take an action — they may still be attempting it
|
||||
- The assistant directed the customer to an external resource, link, or contact — they may still be following up
|
||||
- The assistant asked a question or requested information that the customer hasn't provided
|
||||
- The customer asked a question that wasn't fully answered
|
||||
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
|
||||
- The customer raised multiple questions or issues and not all were addressed
|
||||
|
||||
A conversation is COMPLETE only if ALL of these are true:
|
||||
- The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer
|
||||
- There are no unanswered questions, unmet requests, or outstanding follow-ups from either side
|
||||
- Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation.
|
||||
|
||||
Analyze the conversation and respond with ONLY a JSON object (no other text):
|
||||
{"complete": true, "reason": "brief explanation"}
|
||||
or
|
||||
{"complete": false, "reason": "brief explanation"}
|
||||
@@ -6,16 +6,11 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved?
|
||||
return 'Auto-resolve is disabled for this account' if conversation.account.captain_disable_auto_resolve
|
||||
return 'Auto-resolve is disabled for this account' if conversation.account.captain_auto_resolve_disabled?
|
||||
|
||||
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
|
||||
|
||||
Current.captain_resolve_reason = reason
|
||||
begin
|
||||
conversation.resolved!
|
||||
ensure
|
||||
Current.captain_resolve_reason = nil
|
||||
end
|
||||
conversation.with_captain_activity_context(reason: reason, reason_type: :tool) { conversation.resolved! }
|
||||
|
||||
"Conversation ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Overrides the quota check from Enterprise::Captain::BaseTaskService
|
||||
# so that conversation completion evaluation always runs regardless of
|
||||
# the customer's Captain usage quota. This is an internal operational
|
||||
# check, not a customer-facing feature — it should never be blocked
|
||||
# by quota exhaustion.
|
||||
module Enterprise::Captain::ConversationCompletionService
|
||||
private
|
||||
|
||||
def responses_available?
|
||||
true
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user