From 93d9992cda959c3623a29b9bb214f844a1c37e8f Mon Sep 17 00:00:00 2001 From: "Cayo P. R. Oliveira" Date: Sun, 27 Jul 2025 13:39:54 -0300 Subject: [PATCH] feat: rake task to copy conversations from one inbox to another (#84) * feat: implement inbox message cloning task * feat: update contact inbox cloning to use builder pattern for unique token generation * fix: skip validations during message cloning to improve performance and error handling * feat: enhance inbox message cloning with detailed contact attributes and improved tagging * feat: improve inbox message cloning with enhanced error handling and attribute duplication * feat: skip message flooding check * chore: improved transaction handling * test: add specs for skip message flooding --------- Co-authored-by: gabrieljablonski --- app/models/message.rb | 4 + lib/tasks/clone_inbox.rake | 163 ++++++++++++++++++++++++++++++++++++ spec/models/message_spec.rb | 52 ++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 lib/tasks/clone_inbox.rake diff --git a/app/models/message.rb b/app/models/message.rb index 74a4c933c..dd9b27b96 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -80,6 +80,9 @@ class Message < ApplicationRecord # when you have a temperory id in your frontend and want it echoed back via action cable attr_accessor :echo_id + # NOTE: Allow skipping message flooding validation for bulk operations like imports/cloning + attr_accessor :skip_message_flooding_validation + enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 } enum content_type: { text: 0, @@ -232,6 +235,7 @@ class Message < ApplicationRecord # Added this to cover the validation specs in messages # We can revisit and see if we can remove this later return if conversation.blank? + return if skip_message_flooding_validation # there are cases where automations can result in message loops, we need to prevent such cases. if conversation.messages.where('created_at >= ?', 1.minute.ago).count >= Limits.conversation_message_per_minute_limit diff --git a/lib/tasks/clone_inbox.rake b/lib/tasks/clone_inbox.rake new file mode 100644 index 000000000..4036e41b5 --- /dev/null +++ b/lib/tasks/clone_inbox.rake @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +namespace :inbox do # rubocop:disable Metrics/BlockLength + desc 'Clone all messages from a source inbox to a destination inbox' + task :clone_messages, %i[source_inbox_id destination_inbox_id] => :environment do |_task, args| # rubocop:disable Metrics/BlockLength + source_inbox_id = args[:source_inbox_id] + destination_inbox_id = args[:destination_inbox_id] + + if source_inbox_id.blank? || destination_inbox_id.blank? + puts 'Usage: rails inbox:clone_messages[,]' + next + end + + if source_inbox_id == destination_inbox_id + puts "ERROR: Source and destination inbox IDs are the same (ID: #{source_inbox_id}). Please provide different IDs." + next + end + + source_inbox = Inbox.find(source_inbox_id) + destination_inbox = Inbox.find(destination_inbox_id) + + if source_inbox.account_id != destination_inbox.account_id + puts "ERROR: Source and destination inboxes are in different accounts (Source Account ID: #{source_inbox.account_id}, " \ + "Destination Account ID: #{destination_inbox.account_id})" + next + end + + puts "Cloning messages from '#{source_inbox.name}' (ID: #{source_inbox.id}) to '#{destination_inbox.name}' (ID: #{destination_inbox.id})..." + + old_to_new_contact_inbox = {} + old_to_new_conversation = {} + cloned_messages_count = 0 + failed_messages_count = 0 + + puts 'Cloning contacts and contact inboxes...' + ActiveRecord::Base.transaction do + source_inbox.contact_inboxes.includes(:contact).find_each do |contact_inbox| + existing_contact_inbox = destination_inbox.contact_inboxes.find_by(source_id: contact_inbox.source_id) + if existing_contact_inbox + old_to_new_contact_inbox[contact_inbox.id] = existing_contact_inbox.id + next + end + + contact_attrs = contact_inbox.contact.attributes.except('id', 'created_at', 'updated_at', 'account_id') + + new_contact_inbox = ContactInboxWithContactBuilder.new( + source_id: contact_inbox.source_id, + inbox: destination_inbox, + contact_attributes: contact_attrs + ).perform + new_contact = new_contact_inbox.contact + + if contact_inbox.contact.respond_to?(:label_list) && contact_inbox.contact.label_list.present? + new_contact.label_list = contact_inbox.contact.label_list + end + + new_contact.custom_attributes = contact_inbox.contact.custom_attributes.dup if contact_inbox.contact.custom_attributes.present? + + new_contact.save! + old_to_new_contact_inbox[contact_inbox.id] = new_contact_inbox.id + end + end + puts "Cloned #{old_to_new_contact_inbox.count} contact inboxes." + + puts 'Cloning conversations...' + ActiveRecord::Base.transaction do + source_inbox.conversations.includes(:conversation_participants, :csat_survey_response).find_each do |conversation| + target_contact_inbox_id = old_to_new_contact_inbox[conversation.contact_inbox_id] + next unless target_contact_inbox_id + + existing_conversation = destination_inbox.conversations.find_by( + contact_inbox_id: target_contact_inbox_id, + created_at: conversation.created_at + ) + + if existing_conversation + old_to_new_conversation[conversation.id] = existing_conversation.id + next + end + + new_conversation_attrs = conversation.attributes.except('id', 'display_id', 'updated_at', 'uuid') + new_conversation_attrs['inbox_id'] = destination_inbox.id + new_conversation_attrs['contact_inbox_id'] = target_contact_inbox_id + + new_conversation = destination_inbox.conversations.create!(new_conversation_attrs) + old_to_new_conversation[conversation.id] = new_conversation.id + + if conversation.label_list.present? + new_conversation.label_list = conversation.label_list + new_conversation.save! + end + + new_conversation.update!(custom_attributes: conversation.custom_attributes.dup) if conversation.custom_attributes.present? + + conversation.conversation_participants.each do |conversation_participant| + participant_attrs = conversation_participant.attributes.except('id', 'created_at', 'updated_at') + participant_attrs['conversation_id'] = new_conversation.id + new_conversation.conversation_participants.create!(participant_attrs) + end + + if (csat_response = conversation.csat_survey_response) + new_conversation.create_csat_survey_response!(csat_response.attributes.except('id', 'conversation_id', 'updated_at')) + end + end + end + puts "Cloned #{old_to_new_conversation.count} conversations." + + puts 'Cloning messages...' + source_inbox.messages.includes(:attachments).find_in_batches(batch_size: 1000) do |message_batch| # rubocop:disable Metrics/BlockLength + ActiveRecord::Base.transaction do # rubocop:disable Metrics/BlockLength + message_batch.each do |original_message| # rubocop:disable Metrics/BlockLength + new_conv_id = old_to_new_conversation[original_message.conversation_id] + next unless new_conv_id + + existing_message = if original_message.source_id.present? + Message.find_by( + conversation_id: new_conv_id, + source_id: original_message.source_id + ) + else + Message.find_by( + conversation_id: new_conv_id, + created_at: original_message.created_at, + content: original_message.content + ) + end + + if existing_message + cloned_messages_count += 1 + next + end + + message_attrs = original_message.attributes.except('id', 'updated_at') + message_attrs['conversation_id'] = new_conv_id + message_attrs['inbox_id'] = destination_inbox.id + + new_message = destination_inbox.messages.new(message_attrs) + + begin + new_message.skip_message_flooding_validation = true + new_message.save! + cloned_messages_count += 1 + original_message.attachments.each do |attachment| + new_message.attachments.create!( + file_type: attachment.file_type, + account_id: destination_inbox.account_id, + file: attachment.file.blob + ) + end + rescue StandardError => e + failed_messages_count += 1 + puts "Failed to clone message ID: #{original_message.id}. Errors: #{e.message}" + end + end + end + print "Progress: Cloned: #{cloned_messages_count} | Failed: #{failed_messages_count}\r" + end + + puts "\nCloning complete." + puts "Successfully cloned #{cloned_messages_count} messages." + puts "Failed to clone #{failed_messages_count} messages." + end +end diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index dad9614f5..be9c2ea35 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -38,6 +38,58 @@ RSpec.describe Message do expect(conv_new_message.errors[:base]).to eq(['Too many messages']) end end + + context 'when skip_message_flooding_validation is set' do + it 'skips message flooding validation when set to true' do + with_modified_env 'CONVERSATION_MESSAGE_PER_MINUTE_LIMIT': '2' do + conversation = message.conversation + create(:message, conversation: conversation) + conv_new_message = build(:message, conversation: message.conversation) + + expect(conv_new_message.valid?).to be false + expect(conv_new_message.errors[:base]).to eq(['Too many messages']) + + conv_new_message.skip_message_flooding_validation = true + conv_new_message.valid? + expect(conv_new_message.errors[:base]).to be_empty + expect(conv_new_message.valid?).to be true + end + end + + it 'still validates other attributes when message flooding is skipped' do + message_without_required_fields = build(:message) + message_without_required_fields.account_id = nil + message_without_required_fields.inbox_id = nil + message_without_required_fields.skip_message_flooding_validation = true + + expect(message_without_required_fields.valid?).to be false + expect(message_without_required_fields.errors[:account_id]).to include("can't be blank") + expect(message_without_required_fields.errors[:inbox_id]).to include("can't be blank") + end + + it 'allows bulk message creation when skip_message_flooding_validation is true' do + with_modified_env 'CONVERSATION_MESSAGE_PER_MINUTE_LIMIT': '2' do + conversation = message.conversation + + messages_to_create = 5 + created_messages = [] + + messages_to_create.times do |i| + new_message = build(:message, + conversation: conversation, + content: "Bulk message #{i + 1}") + new_message.skip_message_flooding_validation = true + + expect(new_message.valid?).to be true + new_message.save! + created_messages << new_message + end + + expect(created_messages.count).to eq(messages_to_create) + expect(conversation.messages.count).to eq(messages_to_create + 1) + end + end + end end end