diff --git a/Gemfile b/Gemfile index 397659d8571a3..bc92fae31eca9 100644 --- a/Gemfile +++ b/Gemfile @@ -74,7 +74,7 @@ gem 'devise_token_auth' gem 'jwt' gem 'pundit' # super admin -gem 'administrate' +gem 'administrate', '>= 0.19.0' gem 'administrate-field-active_storage' ##--- gems for pubsub service ---## diff --git a/Gemfile.lock b/Gemfile.lock index 08d5e5e26b851..462eff7746eb7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,7 +105,7 @@ GEM activerecord (>= 6.0, < 7.1) addressable (2.8.4) public_suffix (>= 2.0.2, < 6.0) - administrate (0.18.0) + administrate (0.19.0) actionpack (>= 5.0) actionview (>= 5.0) activerecord (>= 5.0) @@ -376,7 +376,7 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) jmespath (1.6.2) - jquery-rails (4.5.1) + jquery-rails (4.6.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) @@ -451,7 +451,7 @@ GEM mime-types-data (3.2023.0218.1) mini_magick (4.12.0) mini_mime (1.1.2) - mini_portile2 (2.8.2) + mini_portile2 (2.8.4) minitest (5.18.1) mock_redis (0.36.0) ruby2_keywords @@ -829,7 +829,7 @@ DEPENDENCIES active_record_query_trace activerecord-import acts-as-taggable-on - administrate + administrate (>= 0.19.0) administrate-field-active_storage annotate attr_extras diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index b0f6ba25edddc..c96950ee91e3d 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -20,7 +20,7 @@ def timeseries # For backward compatible with old report def build - if %w[avg_first_response_time avg_resolution_time].include?(params[:metric]) + if %w[avg_first_response_time avg_resolution_time reply_time].include?(params[:metric]) timeseries.each_with_object([]) do |p, arr| arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] } end @@ -38,7 +38,8 @@ def summary outgoing_messages_count: outgoing_messages.count, avg_first_response_time: avg_first_response_time_summary, avg_resolution_time: avg_resolution_time_summary, - resolutions_count: resolutions.count + resolutions_count: resolutions.count, + reply_time: reply_time_summary } end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 796986e5bce1f..2b0f43b3fc0ec 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -56,6 +56,13 @@ def avg_first_response_time grouped_reporting_events.average(:value) end + def reply_time + grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'reply_time', account_id: account.id)) + return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] + + grouped_reporting_events.average(:value) + end + def avg_resolution_time grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved', account_id: account.id)) return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] @@ -77,6 +84,16 @@ def avg_resolution_time_summary avg_rt end + def reply_time_summary + reporting_events = scope.reporting_events + .where(name: 'reply_time', account_id: account.id, created_at: range) + reply_time = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value) + + return 0 if reply_time.blank? + + reply_time + end + def avg_first_response_time_summary reporting_events = scope.reporting_events .where(name: 'first_response', account_id: account.id, created_at: range) diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index f384adad79bb1..0b234ec165a9b 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -34,6 +34,10 @@ "RESOLUTION_COUNT": { "NAME": "Resolution Count", "DESC": "( Total )" + }, + "REPLY_TIME": { + "NAME": "Customer waiting time", + "TOOLTIP_TEXT": "Waiting time is %{metricValue} (based on %{conversationCount} conversations)" } }, "DATE_RANGE_OPTIONS": { diff --git a/app/javascript/dashboard/mixins/reportMixin.js b/app/javascript/dashboard/mixins/reportMixin.js index 3a53f1196ed09..2b8a5f87d68fb 100644 --- a/app/javascript/dashboard/mixins/reportMixin.js +++ b/app/javascript/dashboard/mixins/reportMixin.js @@ -7,19 +7,13 @@ export default { accountSummary: 'getAccountSummary', accountReport: 'getAccountReports', }), - calculateTrend() { - return metric_key => { - if (!this.accountSummary.previous[metric_key]) return 0; - const diff = - this.accountSummary[metric_key] - - this.accountSummary.previous[metric_key]; - return Math.round( - (diff / this.accountSummary.previous[metric_key]) * 100 - ); - }; - }, }, methods: { + calculateTrend(key) { + if (!this.accountSummary.previous[key]) return 0; + const diff = this.accountSummary[key] - this.accountSummary.previous[key]; + return Math.round((diff / this.accountSummary.previous[key]) * 100); + }, displayMetric(key) { if (this.isAverageMetricType(key)) { return formatTime(this.accountSummary[key]); @@ -39,7 +33,11 @@ export default { return ''; }, isAverageMetricType(key) { - return ['avg_first_response_time', 'avg_resolution_time'].includes(key); + return [ + 'avg_first_response_time', + 'avg_resolution_time', + 'reply_time', + ].includes(key); }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue index b04216bff0cb1..4ab4a82169788 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue @@ -35,6 +35,7 @@ const REPORTS_KEYS = { FIRST_RESPONSE_TIME: 'avg_first_response_time', RESOLUTION_TIME: 'avg_resolution_time', RESOLUTION_COUNT: 'resolutions_count', + REPLY_TIME: 'reply_time', }; export default { @@ -78,6 +79,7 @@ export default { 'FIRST_RESPONSE_TIME', 'RESOLUTION_TIME', 'RESOLUTION_COUNT', + 'REPLY_TIME', ].forEach(async key => { try { await this.$store.dispatch('fetchAccountReport', { diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue index 0fa504a36757b..3dbd4eb57d945 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue @@ -44,6 +44,7 @@ const REPORTS_KEYS = { FIRST_RESPONSE_TIME: 'avg_first_response_time', RESOLUTION_TIME: 'avg_resolution_time', RESOLUTION_COUNT: 'resolutions_count', + REPLY_TIME: 'reply_time', }; export default { @@ -60,6 +61,7 @@ export default { const reportKeys = [ 'CONVERSATIONS', 'FIRST_RESPONSE_TIME', + 'REPLY_TIME', 'RESOLUTION_TIME', 'RESOLUTION_COUNT', 'INCOMING_MESSAGES', diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue index fdac063bad990..6ed2545b6dfaf 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue @@ -38,6 +38,7 @@ const REPORTS_KEYS = { FIRST_RESPONSE_TIME: 'avg_first_response_time', RESOLUTION_TIME: 'avg_resolution_time', RESOLUTION_COUNT: 'resolutions_count', + REPLY_TIME: 'reply_time', }; export default { @@ -106,6 +107,7 @@ export default { 'FIRST_RESPONSE_TIME', 'RESOLUTION_TIME', 'RESOLUTION_COUNT', + 'REPLY_TIME', ].forEach(async key => { try { const { from, to, groupBy, businessHours } = this; diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js b/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js index cc23529b88b8d..f4eb5a6dc76d3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/constants.js @@ -151,78 +151,48 @@ export const DEFAULT_CHART = { }, }; -export const METRIC_CHART = { - conversations_count: DEFAULT_CHART, - incoming_messages_count: DEFAULT_CHART, - outgoing_messages_count: DEFAULT_CHART, - avg_first_response_time: { - datasets: [DEFAULT_BAR_CHART], - scales: { - xAxes: [ - { - ticks: { - fontFamily: CHART_FONT_FAMILY, - }, - gridLines: { - drawOnChartArea: false, - }, +const TIME_CHART_CONFIG = { + datasets: [DEFAULT_BAR_CHART], + scales: { + xAxes: [ + { + ticks: { + fontFamily: CHART_FONT_FAMILY, }, - ], - yAxes: [ - { - id: 'y-left', - type: 'linear', - position: 'left', - ticks: { - fontFamily: CHART_FONT_FAMILY, - callback: (value, index, values) => { - if (!index || index === values.length - 1) { - return formatTime(value); - } - return ''; - }, - }, - gridLines: { - drawOnChartArea: false, - }, + gridLines: { + drawOnChartArea: false, }, - ], - }, - }, - avg_resolution_time: { - datasets: [DEFAULT_BAR_CHART], - scales: { - xAxes: [ - { - ticks: { - fontFamily: CHART_FONT_FAMILY, - }, - gridLines: { - drawOnChartArea: false, + }, + ], + yAxes: [ + { + id: 'y-left', + type: 'linear', + position: 'left', + ticks: { + fontFamily: CHART_FONT_FAMILY, + callback: (value, index, values) => { + if (!index || index === values.length - 1) { + return formatTime(value); + } + return ''; }, }, - ], - yAxes: [ - { - id: 'y-left', - type: 'linear', - position: 'left', - ticks: { - fontFamily: CHART_FONT_FAMILY, - callback: (value, index, values) => { - if (!index || index === values.length - 1) { - return formatTime(value); - } - return ''; - }, - }, - gridLines: { - drawOnChartArea: false, - }, + gridLines: { + drawOnChartArea: false, }, - ], - }, + }, + ], }, +}; + +export const METRIC_CHART = { + conversations_count: DEFAULT_CHART, + incoming_messages_count: DEFAULT_CHART, + outgoing_messages_count: DEFAULT_CHART, + avg_first_response_time: TIME_CHART_CONFIG, + reply_time: TIME_CHART_CONFIG, + avg_resolution_time: TIME_CHART_CONFIG, resolutions_count: DEFAULT_CHART, }; diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js index 7f51eaf42c07f..b529730febf8e 100644 --- a/app/javascript/dashboard/store/modules/reports.js +++ b/app/javascript/dashboard/store/modules/reports.js @@ -19,6 +19,7 @@ const state = { avg_first_response_time: false, avg_resolution_time: false, resolutions_count: false, + reply_time: false, }, data: { conversations_count: [], @@ -27,6 +28,7 @@ const state = { avg_first_response_time: [], avg_resolution_time: [], resolutions_count: [], + reply_time: [], }, }, accountSummary: { @@ -35,6 +37,7 @@ const state = { conversations_count: 0, incoming_messages_count: 0, outgoing_messages_count: 0, + reply_time: 0, resolutions_count: 0, previous: {}, }, diff --git a/app/models/concerns/channelable.rb b/app/models/concerns/channelable.rb index d88aae90a3cf8..0b23283e1ef31 100644 --- a/app/models/concerns/channelable.rb +++ b/app/models/concerns/channelable.rb @@ -4,9 +4,14 @@ module Channelable validates :account_id, presence: true belongs_to :account has_one :inbox, as: :channel, dependent: :destroy_async, touch: true + after_update :create_audit_log_entry end def messaging_window_enabled? false end + + def create_audit_log_entry; end end + +Channelable.prepend_mod_with('Channelable') diff --git a/config/app.yml b/config/app.yml index f72285e70af8e..c8d719230ed4b 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '2.18.0' + version: '3.0.0' development: <<: *shared diff --git a/enterprise/app/models/enterprise/channelable.rb b/enterprise/app/models/enterprise/channelable.rb new file mode 100644 index 0000000000000..e46fdb2beb70c --- /dev/null +++ b/enterprise/app/models/enterprise/channelable.rb @@ -0,0 +1,34 @@ +module Enterprise::Channelable + extend ActiveSupport::Concern + + # Active support concern has `included` which changes the order of the method lookup chain + # https://stackoverflow.com/q/40061982/3824876 + # manually prepend the instance methods to combat this + included do + prepend InstanceMethods + end + + module InstanceMethods + def create_audit_log_entry + account = self.account + associated_type = 'Account' + + return if inbox.nil? + + auditable_id = inbox.id + auditable_type = 'Inbox' + audited_changes = saved_changes.except('updated_at') + + return if audited_changes.blank? + + Enterprise::AuditLog.create( + auditable_id: auditable_id, + auditable_type: auditable_type, + action: 'update', + associated_id: account.id, + associated_type: associated_type, + audited_changes: audited_changes + ) + end + end +end diff --git a/enterprise/lib/enterprise/integrations/openai_processor_service.rb b/enterprise/lib/enterprise/integrations/openai_processor_service.rb index 85cc3ad7522e6..4ef6bec6d2b68 100644 --- a/enterprise/lib/enterprise/integrations/openai_processor_service.rb +++ b/enterprise/lib/enterprise/integrations/openai_processor_service.rb @@ -27,6 +27,10 @@ def labels_with_messages character_count = labels.length conversation = find_conversation + + # return nil if conversation has less than 3 incoming messages + return nil if conversation.messages.incoming.count < 3 + messages = init_messages_body(false) add_messages_until_token_limit(conversation, messages, false, character_count) diff --git a/enterprise/spec/integrations/openai/processor_service_spec.rb b/enterprise/spec/integrations/openai/processor_service_spec.rb index 6fa41f4bc644e..2f4033ef73e3e 100644 --- a/enterprise/spec/integrations/openai/processor_service_spec.rb +++ b/enterprise/spec/integrations/openai/processor_service_spec.rb @@ -17,43 +17,36 @@ ] }.to_json end - let!(:conversation) { create(:conversation, account: account) } - let!(:customer_message) { create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent') } - let!(:agent_message) { create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer') } + + let(:conversation) { create(:conversation, account: account) } describe '#perform' do - context 'when event name is label_suggestion with labels' do + context 'when event name is label_suggestion with labels with < 3 messages' do let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } - let(:label1) { create(:label, account: account) } - let(:label2) { create(:label, account: account) } - let(:label_suggestion_payload) do - labels = "#{label1.title}, #{label2.title}" - messages = - "Customer #{customer_message.sender.name} : #{customer_message.content}\nAgent #{agent_message.sender.name} : #{agent_message.content}" - "Messages:\n#{messages}\n\nLabels:\n#{labels}" + it 'returns nil' do + create(:label, account: account) + create(:label, account: account) + + expect(subject.perform).to be_nil end + end + + context 'when event name is label_suggestion with labels with >3 messages' do + let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } it 'returns the label suggestions' do - request_body = { - 'model' => 'gpt-3.5-turbo', - 'messages' => [ - { - role: 'system', - content: 'Your role is as an assistant to a customer support agent. You will be provided with ' \ - 'a transcript of a conversation between a customer and the support agent, along with a list of potential labels. ' \ - 'Your task is to analyze the conversation and select the two labels from the given list that most accurately ' \ - 'represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided ' \ - 'in the list. Do not create new labels; only choose from those provided. Once you have made your selections, ' \ - 'please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain ' \ - 'the labels you\'ve selected, in their original casing, and nothing else. ' - }, - { role: 'user', content: label_suggestion_payload } - ] - }.to_json + create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent') + create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer') + create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 2') + create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 3') + create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 4') + + create(:label, account: account) + create(:label, account: account) stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: request_body, headers: expected_headers) + .with(body: anything, headers: expected_headers) .to_return(status: 200, body: openai_response, headers: {}) result = subject.perform diff --git a/lib/chatwoot_exception_tracker.rb b/lib/chatwoot_exception_tracker.rb index e525f3e8980c8..d2879a08c8c7b 100644 --- a/lib/chatwoot_exception_tracker.rb +++ b/lib/chatwoot_exception_tracker.rb @@ -12,11 +12,8 @@ def initialize(exception, user: nil, account: nil) end def capture_exception - if ENV['SENTRY_DSN'].present? - capture_exception_with_sentry - else - Rails.logger.error @exception - end + capture_exception_with_sentry if ENV['SENTRY_DSN'].present? + Rails.logger.error @exception end private diff --git a/package.json b/package.json index 9b96b9018476f..76c9a36bfc1dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "2.18.0", + "version": "3.0.0", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb index 978a7ba5d79e0..2cc7eeea9927e 100644 --- a/spec/enterprise/models/inbox_spec.rb +++ b/spec/enterprise/models/inbox_spec.rb @@ -40,14 +40,60 @@ describe 'audit log' do context 'when inbox is created' do it 'has associated audit log created' do - expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'create').count).to eq 1 + expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'create').count).to eq(1) end end context 'when inbox is updated' do it 'has associated audit log created' do - inbox.update(auto_assignment_config: { max_assignment_limit: 2 }) - expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq 1 + inbox.update(name: 'Updated Inbox') + expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1) + end + end + + context 'when channel is updated' do + it 'has associated audit log created' do + previous_color = inbox.channel.widget_color + new_color = '#ff0000' + inbox.channel.update(widget_color: new_color) + + # check if channel update creates an audit log against inbox + expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1) + # Check for the specific widget_color update in the audit log + expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update', + audited_changes: { 'widget_color' => [previous_color, new_color] }).count).to eq(1) + end + end + end + + describe 'audit log with api channel' do + let!(:channel) { create(:channel_api) } + let!(:inbox) { channel.inbox } + + context 'when inbox is created' do + it 'has associated audit log created' do + expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'create').count).to eq(1) + end + end + + context 'when inbox is updated' do + it 'has associated audit log created' do + inbox.update(name: 'Updated Inbox') + expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1) + end + end + + context 'when channel is updated' do + it 'has associated audit log created' do + previous_webhook = inbox.channel.webhook_url + new_webhook = 'https://example2.com' + inbox.channel.update(webhook_url: new_webhook) + + # check if channel update creates an audit log against inbox + expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1) + # Check for the specific webhook_update update in the audit log + expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update', + audited_changes: { 'webhook_url' => [previous_webhook, new_webhook] }).count).to eq(1) end end end diff --git a/yarn.lock b/yarn.lock index c1dff2b955d9f..cb98cc388a14f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20806,9 +20806,9 @@ with@^7.0.0: babel-walk "3.0.0-canary-5" word-wrap@~1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + version "1.2.4" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" + integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== wordwrap@^1.0.0: version "1.0.0"