From cdd3b73fc9e9cd15785446025d78be839aa3534c Mon Sep 17 00:00:00 2001 From: Karim <58488518+KarimNajul@users.noreply.github.com> Date: Mon, 13 Oct 2025 02:37:07 -0300 Subject: [PATCH 1/5] fix: Duplicate contacts creating for Argentina numbers (#11173) --- .../incoming_message_service_helpers.rb | 9 ++++ .../argentina_phone_normalizer.rb | 18 +++++++ .../phone_number_normalization_service.rb | 5 +- .../whatsapp/incoming_message_service_spec.rb | 52 +++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb index e40dc408f..705babbba 100644 --- a/app/services/whatsapp/incoming_message_service_helpers.rb +++ b/app/services/whatsapp/incoming_message_service_helpers.rb @@ -47,6 +47,15 @@ module Whatsapp::IncomingMessageServiceHelpers %w[reaction ephemeral unsupported request_welcome].include?(message_type) end + def argentina_phone_number?(phone_number) + phone_number.match(/^54/) + end + + def normalised_argentina_mobil_number(phone_number) + # Remove 9 before country code + phone_number.sub(/^549/, '54') + end + def processed_waid(waid) Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid) end diff --git a/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb b/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb new file mode 100644 index 000000000..109a0683f --- /dev/null +++ b/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb @@ -0,0 +1,18 @@ +# Handles Argentina phone number normalization +# +# Argentina phone numbers can appear with or without "9" after country code +# This normalizer removes the "9" when present to create consistent format: 54 + area + number +class Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer + def normalize(waid) + return waid unless handles_country?(waid) + + # Remove "9" after country code if present (549 → 54) + waid.sub(/^549/, '54') + end + + private + + def country_code_pattern + /^54/ + end +end diff --git a/app/services/whatsapp/phone_number_normalization_service.rb b/app/services/whatsapp/phone_number_normalization_service.rb index b8e416794..cd10db0d0 100644 --- a/app/services/whatsapp/phone_number_normalization_service.rb +++ b/app/services/whatsapp/phone_number_normalization_service.rb @@ -1,5 +1,5 @@ # Service to handle phone number normalization for WhatsApp messages -# Currently supports Brazil phone number format variations +# Currently supports Brazil and Argentina phone number format variations # Designed to be extensible for additional countries in future PRs # # Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid) @@ -34,6 +34,7 @@ class Whatsapp::PhoneNumberNormalizationService end NORMALIZERS = [ - Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer + Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer, + Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer ].freeze end diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb index ede1ba824..6c23e9b71 100644 --- a/spec/services/whatsapp/incoming_message_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_service_spec.rb @@ -341,6 +341,58 @@ describe Whatsapp::IncomingMessageService do end end + describe 'When the incoming waid is an Argentine number with 9 after country code' do + let(:wa_id) { '5491123456789' } + + it 'creates appropriate conversations, message and contacts if contact does not exist' do + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(whatsapp_channel.inbox.messages.first.content).to eq('Test') + expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id) + end + + it 'appends to existing contact if contact inbox exists with normalized format' do + # Normalized format removes the 9 after country code + normalized_wa_id = '541123456789' + contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + # no new conversation should be created + expect(whatsapp_channel.inbox.conversations.count).to eq(1) + # message appended to the last conversation + expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body]) + # should use the normalized wa_id from existing contact + expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id) + end + end + + describe 'When incoming waid is an Argentine number without 9 after country code' do + let(:wa_id) { '541123456789' } + + context 'when a contact inbox exists with the same format' do + it 'appends to existing contact' do + contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + # no new conversation should be created + expect(whatsapp_channel.inbox.conversations.count).to eq(1) + # message appended to the last conversation + expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body]) + end + end + + context 'when a contact inbox does not exist' do + it 'creates contact inbox with the incoming waid' do + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(whatsapp_channel.inbox.messages.first.content).to eq('Test') + expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id) + end + end + end + describe 'when message processing is in progress' do it 'ignores the current message creation request' do params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }], From ec9a82a0176f53aacba23a65a7522065f25ef9b8 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 13 Oct 2025 15:59:59 +0530 Subject: [PATCH 2/5] feat: Open conversation when agent bot webhook fails (#12379) # Changelog When an agent bot webhook fails, we now flip any pending conversation back to an open state so a human agent can pick it up immediately. There will be an clear activity message giving the team clear visibility into what went wrong. This keeps customers from getting stuck in limbo when their connected bot goes offline. # Testing instructions 1. Initial setup: Create an agent bot with a working webhook URL and connect it to a test inbox. Send a message from a contact (e.g., via the widget) so a conversation is created; it should enter the Pending state while the bot handles the reply. 2. Introduce failure: Edit that agent bot and swap the webhook URL for a dummy endpoint that will fail. Have the same contact send another message in the existing conversation. Because the webhook call now fails, the conversation should flip from Pending back to Open, making it visible to agents. Also verify the activity message 3. New conversation check: With the dummy URL still in place, start a brand-new conversation from a contact. When the bot tries (and fails) to respond, confirm that the conversation appears immediately as Open rather than remaining Pending. Also the activity message is visible 4. Subsequent messages in open conversations will show no change --------- Co-authored-by: Muhsin Keloth --- app/jobs/agent_bots/webhook_job.rb | 4 ++ config/locales/en.yml | 2 + lib/webhooks/trigger.rb | 27 +++++++++++-- spec/lib/webhooks/trigger_spec.rb | 65 +++++++++++++++++++++++++++++- 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/app/jobs/agent_bots/webhook_job.rb b/app/jobs/agent_bots/webhook_job.rb index d0c4e7959..b3a3d6cc1 100644 --- a/app/jobs/agent_bots/webhook_job.rb +++ b/app/jobs/agent_bots/webhook_job.rb @@ -1,3 +1,7 @@ class AgentBots::WebhookJob < WebhookJob queue_as :high + + def perform(url, payload, webhook_type = :agent_bot_webhook) + super(url, payload, webhook_type) + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 6afab9253..ad54b8dfa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -202,6 +202,8 @@ en: captain: resolved: 'Conversation was marked resolved by %{user_name} due to inactivity' open: 'Conversation was marked open by %{user_name}' + agent_bot: + error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.' status: resolved: 'Conversation was marked resolved by %{user_name}' contact_resolved: 'Conversation was resolved by %{contact_name}' diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index 41b3a415d..95c399d54 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -31,14 +31,33 @@ class Webhooks::Trigger end def handle_error(error) - return unless should_handle_error? + return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event]) return unless message - update_message_status(error) + case @webhook_type + when :agent_bot_webhook + conversation = message.conversation + return unless conversation&.pending? + + conversation.open! + create_agent_bot_error_activity(conversation) + when :api_inbox_webhook + update_message_status(error) + end end - def should_handle_error? - @webhook_type == :api_inbox_webhook && SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event]) + def create_agent_bot_error_activity(conversation) + content = I18n.t('conversations.activity.agent_bot.error_moved_to_open') + Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content)) + end + + def activity_message_params(conversation, content) + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: content + } end def update_message_status(error) diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb index 8ff2a21a5..224a35e07 100644 --- a/spec/lib/webhooks/trigger_spec.rb +++ b/spec/lib/webhooks/trigger_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe Webhooks::Trigger do + include ActiveJob::TestHelper + subject(:trigger) { described_class } let!(:account) { create(:account) } @@ -8,8 +10,18 @@ describe Webhooks::Trigger do let!(:conversation) { create(:conversation, inbox: inbox) } let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) } - let!(:webhook_type) { :api_inbox_webhook } + let(:webhook_type) { :api_inbox_webhook } let!(:url) { 'https://test.com' } + let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') } + + before do + ActiveJob::Base.queue_adapter = :test + end + + after do + clear_enqueued_jobs + clear_performed_jobs + end describe '#execute' do it 'triggers webhook' do @@ -54,6 +66,57 @@ describe Webhooks::Trigger do ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed') end + + context 'when webhook type is agent bot' do + let(:webhook_type) { :agent_bot_webhook } + + it 'reopens conversation and enqueues activity message if pending' do + conversation.update(status: :pending) + payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: 5 + ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once + + expect do + perform_enqueued_jobs do + trigger.execute(url, payload, webhook_type) + end + end.not_to(change { message.reload.status }) + + expect(conversation.reload.status).to eq('open') + + activity_message = conversation.reload.messages.order(:created_at).last + expect(activity_message.message_type).to eq('activity') + expect(activity_message.content).to eq(agent_bot_error_content) + end + + it 'does not change message status or enqueue activity when conversation is not pending' do + payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: 5 + ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once + + expect do + trigger.execute(url, payload, webhook_type) + end.not_to(change { message.reload.status }) + + expect(Conversations::ActivityMessageJob).not_to have_been_enqueued + + expect(conversation.reload.status).to eq('open') + end + end end it 'does not update message status if webhook fails for other events' do From e7b01d80b3ddb08da96f4e6b346f8782267ee811 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Mon, 13 Oct 2025 16:21:45 +0530 Subject: [PATCH 3/5] chore: add script to throttle bulkreindex job creation and increase meta timeouts(#12626) - scripts to throttle reindex job creation and monitor progress ``` RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb ``` --------- Co-authored-by: Pranav --- .../store/modules/conversationStats.js | 6 +- config/sidekiq.yml | 1 + script/bulk_reindex_messages.rb | 58 +++++++++++++++++++ script/monitor_reindex.rb | 19 ++++++ script/reindex_single_account.rb | 58 +++++++++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 script/bulk_reindex_messages.rb create mode 100644 script/monitor_reindex.rb create mode 100644 script/reindex_single_account.rb diff --git a/app/javascript/dashboard/store/modules/conversationStats.js b/app/javascript/dashboard/store/modules/conversationStats.js index 353c1e59a..ba3e5c455 100644 --- a/app/javascript/dashboard/store/modules/conversationStats.js +++ b/app/javascript/dashboard/store/modules/conversationStats.js @@ -26,12 +26,12 @@ const fetchMetaData = async (commit, params) => { }; const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1500); -const longDebouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 8000); +const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000); const superLongDebouncedFetchMetaData = debounce( fetchMetaData, - 1500, + 10000, false, - 10000 + 20000 ); export const actions = { diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 50a47a20b..138cf78b3 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -27,6 +27,7 @@ - purgable - housekeeping - async_database_migration + - bulk_reindex_low - active_storage_analysis - active_storage_purge - action_mailbox_incineration diff --git a/script/bulk_reindex_messages.rb b/script/bulk_reindex_messages.rb new file mode 100644 index 000000000..1e19f70a7 --- /dev/null +++ b/script/bulk_reindex_messages.rb @@ -0,0 +1,58 @@ +# Bulk reindex all messages with throttling to prevent DB overload +# This creates jobs slowly to avoid overwhelming the database connection pool +# Usage: RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb + +JOBS_PER_MINUTE = 50 # Adjust based on your DB capacity +BATCH_SIZE = 1000 # Messages per job + +batch_count = 0 +total_batches = (Message.count / BATCH_SIZE.to_f).ceil +start_time = Time.zone.now + +index_name = Message.searchkick_index.name + +puts '=' * 80 +puts "Bulk Reindex Started at #{start_time}" +puts '=' * 80 +puts "Total messages: #{Message.count}" +puts "Batch size: #{BATCH_SIZE}" +puts "Total batches: #{total_batches}" +puts "Index name: #{index_name}" +puts "Rate: #{JOBS_PER_MINUTE} jobs/minute (#{JOBS_PER_MINUTE * BATCH_SIZE} messages/minute)" +puts "Estimated time: #{(total_batches / JOBS_PER_MINUTE.to_f / 60).round(2)} hours" +puts '=' * 80 +puts '' + +sleep(15) + +Message.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, index| + batch_count += 1 + + # Enqueue to low priority queue with proper format + Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later( + class_name: 'Message', + index_name: index_name, + batch_id: index, + record_ids: batch.map(&:id) # Keep as integers like Message.reindex does + ) + + # Throttle: wait after every N jobs + if (batch_count % JOBS_PER_MINUTE).zero? + elapsed = Time.zone.now - start_time + progress = (batch_count.to_f / total_batches * 100).round(2) + queue_size = Sidekiq::Queue.new('bulk_reindex_low').size + + puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}] Progress: #{batch_count}/#{total_batches} (#{progress}%)" + puts " Queue size: #{queue_size}" + puts " Elapsed: #{(elapsed / 3600).round(2)} hours" + puts " ETA: #{((elapsed / batch_count * (total_batches - batch_count)) / 3600).round(2)} hours remaining" + puts '' + + sleep(60) + end +end + +puts '=' * 80 +puts "Done! Created #{batch_count} jobs" +puts "Total time: #{((Time.zone.now - start_time) / 3600).round(2)} hours" +puts '=' * 80 diff --git a/script/monitor_reindex.rb b/script/monitor_reindex.rb new file mode 100644 index 000000000..6a2c1ee6c --- /dev/null +++ b/script/monitor_reindex.rb @@ -0,0 +1,19 @@ +# Monitor bulk reindex progress +# RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb + +puts 'Monitoring bulk reindex progress (Ctrl+C to stop)...' +puts '' + +loop do + bulk_queue = Sidekiq::Queue.new('bulk_reindex_low') + prod_queue = Sidekiq::Queue.new('async_database_migration') + retry_set = Sidekiq::RetrySet.new + + puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}]" + puts " Bulk Reindex Queue: #{bulk_queue.size} jobs" + puts " Production Queue: #{prod_queue.size} jobs" + puts " Retry Queue: #{retry_set.size} jobs" + puts " #{('-' * 60)}" + + sleep(30) +end diff --git a/script/reindex_single_account.rb b/script/reindex_single_account.rb new file mode 100644 index 000000000..cb7dd8c87 --- /dev/null +++ b/script/reindex_single_account.rb @@ -0,0 +1,58 @@ +# Reindex messages for a single account +# Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK] + +#account_id = ARGV[0]&.to_i +days_back = (ARGV[1] || 30).to_i + +# if account_id.nil? || account_id.zero? +# puts "Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]" +# puts "Example: bundle exec rails runner script/reindex_single_account.rb 93293 30" +# exit 1 +# end + +# account = Account.find(account_id) +# puts "=" * 80 +# puts "Reindexing messages for: #{account.name} (ID: #{account.id})" +# puts "=" * 80 + +# Enable feature if not already enabled +# unless account.feature_enabled?('advanced_search_indexing') +# puts "Enabling advanced_search_indexing feature..." +# account.enable_features(:advanced_search_indexing) +# account.save! +# end + +# Get messages to index +# messages = Message.where(account_id: account.id) +# .where(message_type: [0, 1]) # incoming/outgoing only +# .where('created_at >= ?', days_back.days.ago) + +messages = Message.where('created_at >= ?', days_back.days.ago) + +puts "Found #{messages.count} messages to index (last #{days_back} days)" +puts '' + +sleep(15) + +# Create bulk reindex jobs +index_name = Message.searchkick_index.name +batch_count = 0 + +messages.find_in_batches(batch_size: 1000).with_index do |batch, index| + Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later( + class_name: 'Message', + index_name: index_name, + batch_id: index, + record_ids: batch.map(&:id) + ) + + batch_count += 1 + print '.' + sleep(0.5) # Small delay +end + +puts '' +puts '=' * 80 +puts "Done! Created #{batch_count} bulk reindex jobs" +puts 'Messages will be indexed shortly via the bulk_reindex_low queue' +puts '=' * 80 From 38f16ba677bd5328ab9baaab562f8f536c94e760 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 13 Oct 2025 18:05:12 +0530 Subject: [PATCH 4/5] feat: Secure external credentials with database encryption (#12648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changelog - Added conditional Active Record encryption to every external credential we store (SMTP/IMAP passwords, Twilio tokens, Slack/OpenAI hook tokens, Facebook/Instagram tokens, LINE/Telegram keys, Twitter secrets) so new writes are encrypted whenever Chatwoot.encryption_configured? is true; legacy installs still receive plaintext until their secrets are updated. - Tuned encryption settings in config/application.rb to allow legacy reads (support_unencrypted_data) and to extend deterministic queries so lookups continue to match plaintext rows during the rollout; added TODOs to retire the fallback once encryption becomes mandatory. - Introduced an MFA-pipeline test suite (spec/models/external_credentials_encryption_spec.rb) plus shared examples to verify each attribute encrypts at rest and that plaintext records re-encrypt on update, with a dedicated Telegram case. The existing MFA GitHub workflow now runs these tests using the preconfigured encryption keys. fixes: https://linear.app/chatwoot/issue/CW-5453/encrypt-sensitive-credentials-stored-in-plain-text-in-database ## Testing Instructions 1. Instance without encryption keys - Unset ACTIVE_RECORD_ENCRYPTION_* vars (or run in an environment where they’re absent). - Create at least one credentialed channel (e.g., Email SMTP). - Confirm workflows still function (send/receive mail or a similar sanity check). - In the DB you should still see plaintext values—this confirms the guard prevents encryption when keys are missing. 2. Instance with encryption keys - Configure the three encryption env vars and restart. - Pick a couple of representative integrations (e.g., Email SMTP + Twilio SMS). - Legacy channel check: - Use existing records created before enabling keys. Trigger their workflow (send an email / SMS, or hit the webhook) to ensure they still authenticate. - Inspect the raw column—value remains plaintext until changed. - Update legacy channel: - Edit one legacy channel’s credential (e.g., change SMTP password). - Verify the operation still works and the stored value is now encrypted (raw column differs, accessor returns original). - New channel creation: - Create a new channel of the same type; confirm functionality and that the stored credential is encrypted from the start. --------- Co-authored-by: Muhsin Keloth --- .github/workflows/run_mfa_spec.yml | 1 + app/models/channel/email.rb | 6 + app/models/channel/facebook_page.rb | 6 + app/models/channel/instagram.rb | 3 + app/models/channel/line.rb | 6 + app/models/channel/telegram.rb | 3 + app/models/channel/twilio_sms.rb | 3 + app/models/channel/twitter_profile.rb | 6 + app/models/integrations/hook.rb | 3 + config/application.rb | 6 + ...rd_external_credentials_encryption_spec.rb | 113 ++++++++++++++++++ .../encrypted_external_credential_examples.rb | 21 ++++ 12 files changed, 177 insertions(+) create mode 100644 spec/models/application_record_external_credentials_encryption_spec.rb create mode 100644 spec/support/examples/encrypted_external_credential_examples.rb diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml index 61b406f8a..69d019cc9 100644 --- a/.github/workflows/run_mfa_spec.yml +++ b/.github/workflows/run_mfa_spec.yml @@ -70,6 +70,7 @@ jobs: spec/services/mfa/authentication_service_spec.rb \ spec/requests/api/v1/profile/mfa_controller_spec.rb \ spec/controllers/devise_overrides/sessions_controller_spec.rb \ + spec/models/application_record_external_credentials_encryption_spec.rb \ --profile=10 \ --format documentation env: diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb index a8fadb61e..b1124dd75 100644 --- a/app/models/channel/email.rb +++ b/app/models/channel/email.rb @@ -40,6 +40,12 @@ class Channel::Email < ApplicationRecord AUTHORIZATION_ERROR_THRESHOLD = 10 + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + if Chatwoot.encryption_configured? + encrypts :imap_password + encrypts :smtp_password + end + self.table_name = 'channel_email' EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl, :smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto, diff --git a/app/models/channel/facebook_page.rb b/app/models/channel/facebook_page.rb index 1b6e151cb..1866d245b 100644 --- a/app/models/channel/facebook_page.rb +++ b/app/models/channel/facebook_page.rb @@ -21,6 +21,12 @@ class Channel::FacebookPage < ApplicationRecord include Channelable include Reauthorizable + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + if Chatwoot.encryption_configured? + encrypts :page_access_token + encrypts :user_access_token + end + self.table_name = 'channel_facebook_pages' validates :page_id, uniqueness: { scope: :account_id } diff --git a/app/models/channel/instagram.rb b/app/models/channel/instagram.rb index 964a4c1a2..7e6444b30 100644 --- a/app/models/channel/instagram.rb +++ b/app/models/channel/instagram.rb @@ -19,6 +19,9 @@ class Channel::Instagram < ApplicationRecord include Reauthorizable self.table_name = 'channel_instagram' + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + encrypts :access_token if Chatwoot.encryption_configured? + AUTHORIZATION_ERROR_THRESHOLD = 1 validates :access_token, presence: true diff --git a/app/models/channel/line.rb b/app/models/channel/line.rb index a417dbf64..63b0924da 100644 --- a/app/models/channel/line.rb +++ b/app/models/channel/line.rb @@ -18,6 +18,12 @@ class Channel::Line < ApplicationRecord include Channelable + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + if Chatwoot.encryption_configured? + encrypts :line_channel_secret + encrypts :line_channel_token + end + self.table_name = 'channel_line' EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze diff --git a/app/models/channel/telegram.rb b/app/models/channel/telegram.rb index b00897614..b18c6dbc9 100644 --- a/app/models/channel/telegram.rb +++ b/app/models/channel/telegram.rb @@ -17,6 +17,9 @@ class Channel::Telegram < ApplicationRecord include Channelable + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + encrypts :bot_token, deterministic: true if Chatwoot.encryption_configured? + self.table_name = 'channel_telegram' EDITABLE_ATTRS = [:bot_token].freeze diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb index 73e5c873e..2f9130cbb 100644 --- a/app/models/channel/twilio_sms.rb +++ b/app/models/channel/twilio_sms.rb @@ -28,6 +28,9 @@ class Channel::TwilioSms < ApplicationRecord self.table_name = 'channel_twilio_sms' + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + encrypts :auth_token if Chatwoot.encryption_configured? + validates :account_sid, presence: true # The same parameter is used to store api_key_secret if api_key authentication is opted validates :auth_token, presence: true diff --git a/app/models/channel/twitter_profile.rb b/app/models/channel/twitter_profile.rb index d0f765e9f..4ec167ce5 100644 --- a/app/models/channel/twitter_profile.rb +++ b/app/models/channel/twitter_profile.rb @@ -19,6 +19,12 @@ class Channel::TwitterProfile < ApplicationRecord include Channelable + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + if Chatwoot.encryption_configured? + encrypts :twitter_access_token + encrypts :twitter_access_token_secret + end + self.table_name = 'channel_twitter_profiles' validates :profile_id, uniqueness: { scope: :account_id } diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index ca77fa13d..97d3f91ae 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -21,6 +21,9 @@ class Integrations::Hook < ApplicationRecord before_validation :ensure_hook_type after_create :trigger_setup_if_crm + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + encrypts :access_token, deterministic: true if Chatwoot.encryption_configured? + validates :account_id, presence: true validates :app_id, presence: true validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' } diff --git a/config/application.rb b/config/application.rb index d644dd28f..aa150794a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -75,7 +75,11 @@ module Chatwoot config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'] config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil) config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil) + # TODO: Remove once encryption is mandatory and legacy plaintext is migrated. config.active_record.encryption.support_unencrypted_data = true + # Extend deterministic queries so they match both encrypted and plaintext rows + config.active_record.encryption.extend_queries = true + # Store a per-row key reference to support future key rotation config.active_record.encryption.store_key_references = true end end @@ -94,6 +98,8 @@ module Chatwoot end def self.encryption_configured? + # TODO: Once Active Record encryption keys are mandatory (target 3-4 releases out), + # remove this guard and assume encryption is always enabled. # Check if proper encryption keys are configured # MFA/2FA features should only be enabled when proper keys are set ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? && diff --git a/spec/models/application_record_external_credentials_encryption_spec.rb b/spec/models/application_record_external_credentials_encryption_spec.rb new file mode 100644 index 000000000..65c347434 --- /dev/null +++ b/spec/models/application_record_external_credentials_encryption_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ApplicationRecord do + it_behaves_like 'encrypted external credential', + factory: :channel_email, + attribute: :smtp_password, + value: 'smtp-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_email, + attribute: :imap_password, + value: 'imap-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_twilio_sms, + attribute: :auth_token, + value: 'twilio-secret' + + it_behaves_like 'encrypted external credential', + factory: :integrations_hook, + attribute: :access_token, + value: 'hook-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_facebook_page, + attribute: :page_access_token, + value: 'fb-page-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_facebook_page, + attribute: :user_access_token, + value: 'fb-user-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_instagram, + attribute: :access_token, + value: 'ig-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_line, + attribute: :line_channel_secret, + value: 'line-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_line, + attribute: :line_channel_token, + value: 'line-token-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_telegram, + attribute: :bot_token, + value: 'telegram-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_twitter_profile, + attribute: :twitter_access_token, + value: 'twitter-access-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_twitter_profile, + attribute: :twitter_access_token_secret, + value: 'twitter-secret-secret' + + context 'when backfilling legacy plaintext' do + before do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + end + + it 'reads existing plaintext and encrypts on update' do + account = create(:account) + channel = create(:channel_email, account: account, smtp_password: nil) + + # Simulate legacy plaintext by updating the DB directly + sql = ActiveRecord::Base.send( + :sanitize_sql_array, + ['UPDATE channel_email SET smtp_password = ? WHERE id = ?', 'legacy-plain', channel.id] + ) + ActiveRecord::Base.connection.execute(sql) + + legacy_record = Channel::Email.find(channel.id) + expect(legacy_record.smtp_password).to eq('legacy-plain') + + legacy_record.update!(smtp_password: 'encrypted-now') + + stored_value = legacy_record.reload.read_attribute_before_type_cast(:smtp_password) + expect(stored_value).to be_present + expect(stored_value).not_to include('encrypted-now') + expect(legacy_record.smtp_password).to eq('encrypted-now') + end + end + + context 'when looking up telegram legacy records' do + before do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + end + + it 'finds plaintext records via fallback lookup' do + channel = create(:channel_telegram, bot_token: 'legacy-token') + + # Simulate legacy plaintext by updating the DB directly + sql = ActiveRecord::Base.send( + :sanitize_sql_array, + ['UPDATE channel_telegram SET bot_token = ? WHERE id = ?', 'legacy-token', channel.id] + ) + ActiveRecord::Base.connection.execute(sql) + + found = Channel::Telegram.find_by(bot_token: 'legacy-token') + expect(found).to eq(channel) + end + end +end diff --git a/spec/support/examples/encrypted_external_credential_examples.rb b/spec/support/examples/encrypted_external_credential_examples.rb new file mode 100644 index 000000000..c67d814a9 --- /dev/null +++ b/spec/support/examples/encrypted_external_credential_examples.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'encrypted external credential' do |factory:, attribute:, value: 'secret-token'| + before do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + if defined?(Facebook::Messenger::Subscriptions) + allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true) + allow(Facebook::Messenger::Subscriptions).to receive(:unsubscribe).and_return(true) + end + end + + it "encrypts #{attribute} at rest" do + record = create(factory, attribute => value) + + raw_stored_value = record.reload.read_attribute_before_type_cast(attribute).to_s + expect(raw_stored_value).to be_present + expect(raw_stored_value).not_to include(value) + expect(record.public_send(attribute)).to eq(value) + expect(record.encrypted_attribute?(attribute)).to be(true) + end +end From f1f1ce644c5262e37266f5d506b06659946bb13f Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 13 Oct 2025 19:15:57 +0530 Subject: [PATCH 5/5] feat: Overview heatmap improvements (#12359) This PR adds inbox filtering to the conversation traffic heatmap, allowing users to analyze patterns for specific inboxes. Additionally, it also adds a new resolution count heatmap that shows when support teams are most active in resolving conversations, using a green color to distinguish it from the blue conversation heatmap. The PR also reorganizes heatmap components into a cleaner structure with a shared `BaseHeatmapContainer` that handles common functionality like date range selection, inbox filtering, and data fetching. This makes it easy to add new heatmap metrics in the future - just create a wrapper component specifying the metric type and color scheme. CleanShot 2025-10-13 at 14 01
35@2x CleanShot 2025-10-13 at 14 03
00@2x Unrelated change, the data seeder conversation resolution would not work correctly, we've fixed it. --------- Co-authored-by: Muhsin Keloth --- .../dashboard/i18n/locale/en/report.json | 10 + .../settings/reports/LiveReports.vue | 6 +- .../settings/reports/components/Heatmap.vue | 175 ------------ .../reports/components/HeatmapContainer.vue | 119 -------- .../components/heatmaps/BaseHeatmap.vue | 214 ++++++++++++++ .../heatmaps/BaseHeatmapContainer.vue | 265 ++++++++++++++++++ .../heatmaps/ConversationHeatmapContainer.vue | 18 ++ .../components/heatmaps/HeatmapTooltip.vue | 57 ++++ .../heatmaps/ResolutionHeatmapContainer.vue | 18 ++ .../heatmaps/composables/useHeatmapTooltip.js | 34 +++ .../dashboard/store/modules/reports.js | 21 ++ .../dashboard/store/mutation-types.js | 2 + lib/seeders/reports/conversation_creator.rb | 36 ++- 13 files changed, 668 insertions(+), 307 deletions(-) delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/Heatmap.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/HeatmapContainer.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmap.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmapContainer.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ConversationHeatmapContainer.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapTooltip.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ResolutionHeatmapContainer.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/composables/useHeatmapTooltip.js diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 7c42fdfba..c622170b0 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -51,6 +51,7 @@ }, "DATE_RANGE_OPTIONS": { "LAST_7_DAYS": "Last 7 days", + "LAST_14_DAYS": "Last 14 days", "LAST_30_DAYS": "Last 30 days", "LAST_3_MONTHS": "Last 3 months", "LAST_6_MONTHS": "Last 6 months", @@ -266,6 +267,8 @@ "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "DOWNLOAD_INBOX_REPORTS": "Download inbox reports", "FILTER_DROPDOWN_LABEL": "Select Inbox", + "ALL_INBOXES": "All Inboxes", + "SEARCH_INBOX": "Search Inbox", "METRICS": { "CONVERSATIONS": { "NAME": "Conversations", @@ -467,6 +470,13 @@ "CONVERSATIONS": "{count} conversations", "DOWNLOAD_REPORT": "Download report" }, + "RESOLUTION_HEATMAP": { + "HEADER": "Resolutions", + "NO_CONVERSATIONS": "No conversations", + "CONVERSATION": "{count} conversation", + "CONVERSATIONS": "{count} conversations", + "DOWNLOAD_REPORT": "Download report" + }, "AGENT_CONVERSATIONS": { "HEADER": "Conversations by agents", "LOADING_MESSAGE": "Loading agent metrics...", diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue index a0eeb3ab9..1eb9640ac 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LiveReports.vue @@ -1,6 +1,7 @@ - - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/HeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/HeatmapContainer.vue deleted file mode 100644 index 280e1d6ee..000000000 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/HeatmapContainer.vue +++ /dev/null @@ -1,119 +0,0 @@ - - - diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmap.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmap.vue new file mode 100644 index 000000000..3f9dd9db4 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmap.vue @@ -0,0 +1,214 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmapContainer.vue new file mode 100644 index 000000000..2a692b12a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/BaseHeatmapContainer.vue @@ -0,0 +1,265 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ConversationHeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ConversationHeatmapContainer.vue new file mode 100644 index 000000000..394b7cdc2 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ConversationHeatmapContainer.vue @@ -0,0 +1,18 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapTooltip.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapTooltip.vue new file mode 100644 index 000000000..79377a6a3 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapTooltip.vue @@ -0,0 +1,57 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ResolutionHeatmapContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ResolutionHeatmapContainer.vue new file mode 100644 index 000000000..24530d0c7 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/ResolutionHeatmapContainer.vue @@ -0,0 +1,18 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/composables/useHeatmapTooltip.js b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/composables/useHeatmapTooltip.js new file mode 100644 index 000000000..28b050542 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/composables/useHeatmapTooltip.js @@ -0,0 +1,34 @@ +import { ref } from 'vue'; + +export function useHeatmapTooltip() { + const visible = ref(false); + const x = ref(0); + const y = ref(0); + const value = ref(null); + + let timeoutId = null; + + const show = (event, cellValue) => { + clearTimeout(timeoutId); + + // Update position immediately for smooth movement + const rect = event.target.getBoundingClientRect(); + x.value = rect.left + rect.width / 2; + y.value = rect.top; + + // Only delay content update and visibility + timeoutId = setTimeout(() => { + value.value = cellValue; + visible.value = true; + }, 100); + }; + + const hide = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + visible.value = false; + }, 50); + }; + + return { visible, x, y, value, show, hide }; +} diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js index bb5364bb5..99b2acf18 100644 --- a/app/javascript/dashboard/store/modules/reports.js +++ b/app/javascript/dashboard/store/modules/reports.js @@ -57,11 +57,13 @@ const state = { uiFlags: { isFetchingAccountConversationMetric: false, isFetchingAccountConversationsHeatmap: false, + isFetchingAccountResolutionsHeatmap: false, isFetchingAgentConversationMetric: false, isFetchingTeamConversationMetric: false, }, accountConversationMetric: {}, accountConversationHeatmap: [], + accountResolutionHeatmap: [], agentConversationMetric: [], teamConversationMetric: [], }, @@ -89,6 +91,9 @@ const getters = { getAccountConversationHeatmapData(_state) { return _state.overview.accountConversationHeatmap; }, + getAccountResolutionHeatmapData(_state) { + return _state.overview.accountResolutionHeatmap; + }, getAgentConversationMetric(_state) { return _state.overview.agentConversationMetric; }, @@ -130,6 +135,16 @@ export const actions = { commit(types.default.TOGGLE_HEATMAP_LOADING, false); }); }, + fetchAccountResolutionHeatmap({ commit }, reportObj) { + commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, true); + Report.getReports({ ...reportObj, groupBy: 'hour' }).then(heatmapData => { + let { data } = heatmapData; + data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to); + + commit(types.default.SET_RESOLUTION_HEATMAP_DATA, data); + commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, false); + }); + }, fetchAccountSummary({ commit }, reportObj) { commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING); Report.getSummary( @@ -287,6 +302,9 @@ const mutations = { [types.default.SET_HEATMAP_DATA](_state, heatmapData) { _state.overview.accountConversationHeatmap = heatmapData; }, + [types.default.SET_RESOLUTION_HEATMAP_DATA](_state, heatmapData) { + _state.overview.accountResolutionHeatmap = heatmapData; + }, [types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) { _state.accountReport.isFetching[metric] = value; }, @@ -299,6 +317,9 @@ const mutations = { [types.default.TOGGLE_HEATMAP_LOADING](_state, flag) { _state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag; }, + [types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING](_state, flag) { + _state.overview.uiFlags.isFetchingAccountResolutionsHeatmap = flag; + }, [types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) { _state.accountSummary = summaryData; }, diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 4f361e140..68ff79e66 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -187,6 +187,8 @@ export default { SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS', SET_HEATMAP_DATA: 'SET_HEATMAP_DATA', TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING', + SET_RESOLUTION_HEATMAP_DATA: 'SET_RESOLUTION_HEATMAP_DATA', + TOGGLE_RESOLUTION_HEATMAP_LOADING: 'TOGGLE_RESOLUTION_HEATMAP_LOADING', SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY', SET_BOT_SUMMARY: 'SET_BOT_SUMMARY', TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING', diff --git a/lib/seeders/reports/conversation_creator.rb b/lib/seeders/reports/conversation_creator.rb index b6259de7d..1cd11ef33 100644 --- a/lib/seeders/reports/conversation_creator.rb +++ b/lib/seeders/reports/conversation_creator.rb @@ -16,8 +16,11 @@ class Seeders::Reports::ConversationCreator @priorities = [nil, 'urgent', 'high', 'medium', 'low'] end + # rubocop:disable Metrics/MethodLength def create_conversation(created_at:) conversation = nil + should_resolve = false + resolution_time = nil ActiveRecord::Base.transaction do travel_to(created_at) do @@ -26,14 +29,35 @@ class Seeders::Reports::ConversationCreator add_labels_to_conversation(conversation) create_messages_for_conversation(conversation) - resolve_conversation_if_needed(conversation) + + # Determine if should resolve but don't update yet + should_resolve = rand > 0.3 + if should_resolve + resolution_delay = rand((30.minutes)..(24.hours)) + resolution_time = created_at + resolution_delay + end end travel_back end + # Now resolve outside of time travel if needed + if should_resolve && resolution_time + # rubocop:disable Rails/SkipsModelValidations + conversation.update_column(:status, :resolved) + conversation.update_column(:updated_at, resolution_time) + # rubocop:enable Rails/SkipsModelValidations + + # Trigger the event with proper timestamp + travel_to(resolution_time) do + trigger_conversation_resolved_event(conversation) + end + travel_back + end + conversation end + # rubocop:enable Metrics/MethodLength private @@ -85,16 +109,6 @@ class Seeders::Reports::ConversationCreator message_creator.create_messages end - def resolve_conversation_if_needed(conversation) - return unless rand < 0.7 - - resolution_delay = rand((30.minutes)..(24.hours)) - travel(resolution_delay) - conversation.update!(status: :resolved) - - trigger_conversation_resolved_event(conversation) - end - def trigger_conversation_resolved_event(conversation) event_data = { conversation: conversation }