+
+
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 4ec63acc2..eb97a22e8 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -228,14 +228,17 @@ class Conversation < ApplicationRecord
def determine_conversation_status
self.status = :resolved and return if contact.blocked?
- # Message template hooks aren't executed for conversations from campaigns
- # So making these conversations open for agent visibility
- return if campaign.present?
+ return handle_campaign_status if campaign.present?
# TODO: make this an inbox config instead of assuming bot conversations should start as pending
self.status = :pending if inbox.active_bot?
end
+ def handle_campaign_status
+ # If campaign has no sender (bot-initiated) and inbox has active bot, let bot handle it
+ self.status = :pending if campaign.sender_id.nil? && inbox.active_bot?
+ end
+
def notify_conversation_creation
dispatcher_dispatch(CONVERSATION_CREATED)
end
diff --git a/app/models/message.rb b/app/models/message.rb
index 5f98493d0..2964d9286 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -153,15 +153,6 @@ class Message < ApplicationRecord
merge_sender_attributes(data)
end
- def search_data
- data = attributes.symbolize_keys
- data[:conversation] = conversation.present? ? conversation_push_event_data : nil
- data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
- data[:sender] = sender.push_event_data if sender
- data[:inbox] = inbox
- data
- end
-
def conversation_push_event_data
{
assignee_id: conversation.assignee_id,
@@ -259,6 +250,10 @@ class Message < ApplicationRecord
true
end
+ def search_data
+ Messages::SearchDataPresenter.new(self).search_data
+ end
+
private
def prevent_message_flooding
diff --git a/app/presenters/messages/search_data_presenter.rb b/app/presenters/messages/search_data_presenter.rb
new file mode 100644
index 000000000..7d0638add
--- /dev/null
+++ b/app/presenters/messages/search_data_presenter.rb
@@ -0,0 +1,58 @@
+class Messages::SearchDataPresenter < SimpleDelegator
+ def search_data
+ {
+ **searchable_content,
+ **message_attributes,
+ additional_attributes: additional_attributes_data,
+ conversation: conversation_data
+ }
+ end
+
+ private
+
+ def searchable_content
+ {
+ content: content,
+ attachments: attachment_data,
+ content_attributes: content_attributes_data
+ }
+ end
+
+ def message_attributes
+ {
+ account_id: account_id,
+ inbox_id: inbox_id,
+ conversation_id: conversation_id,
+ message_type: message_type,
+ private: private,
+ created_at: created_at,
+ source_id: source_id,
+ sender_id: sender_id,
+ sender_type: sender_type
+ }
+ end
+
+ def attachment_data
+ attachments.filter_map do |a|
+ { transcribed_text: a.meta&.dig('transcribed_text') }
+ end.presence
+ end
+
+ def content_attributes_data
+ email_subject = content_attributes.dig(:email, :subject)
+ return {} if email_subject.blank?
+
+ { email: { subject: email_subject } }
+ end
+
+ def conversation_data
+ { id: conversation.display_id }
+ end
+
+ def additional_attributes_data
+ {
+ campaign_id: additional_attributes&.dig('campaign_id'),
+ automation_rule_id: content_attributes&.dig('automation_rule_id')
+ }
+ end
+end
diff --git a/app/services/contacts/bulk_action_service.rb b/app/services/contacts/bulk_action_service.rb
index c759e95a5..a0e11ad9a 100644
--- a/app/services/contacts/bulk_action_service.rb
+++ b/app/services/contacts/bulk_action_service.rb
@@ -6,6 +6,7 @@ class Contacts::BulkActionService
end
def perform
+ return delete_contacts if delete_requested?
return assign_labels if labels_to_add.any?
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
@@ -22,6 +23,13 @@ class Contacts::BulkActionService
).perform
end
+ def delete_contacts
+ Contacts::BulkDeleteService.new(
+ account: @account,
+ contact_ids: ids
+ ).perform
+ end
+
def ids
Array(@params[:ids]).compact
end
@@ -29,4 +37,8 @@ class Contacts::BulkActionService
def labels_to_add
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
end
+
+ def delete_requested?
+ @params[:action_name] == 'delete'
+ end
end
diff --git a/app/services/contacts/bulk_delete_service.rb b/app/services/contacts/bulk_delete_service.rb
new file mode 100644
index 000000000..d197f17f6
--- /dev/null
+++ b/app/services/contacts/bulk_delete_service.rb
@@ -0,0 +1,18 @@
+class Contacts::BulkDeleteService
+ def initialize(account:, contact_ids: [])
+ @account = account
+ @contact_ids = Array(contact_ids).compact
+ end
+
+ def perform
+ return if @contact_ids.blank?
+
+ contacts.find_each(&:destroy!)
+ end
+
+ private
+
+ def contacts
+ @account.contacts.where(id: @contact_ids)
+ end
+end
diff --git a/app/services/llm_formatter/conversation_llm_formatter.rb b/app/services/llm_formatter/conversation_llm_formatter.rb
index 8654e0adf..4e0bd7013 100644
--- a/app/services/llm_formatter/conversation_llm_formatter.rb
+++ b/app/services/llm_formatter/conversation_llm_formatter.rb
@@ -39,7 +39,14 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
end
def format_message(message)
- sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
+ sender = case message.sender_type
+ when 'User'
+ 'Support Agent'
+ when 'Contact'
+ 'User'
+ else
+ 'Bot'
+ end
sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content}\n"
end
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 04d605c2e..dd5c71a4d 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -18,8 +18,10 @@ end
Sidekiq.configure_server do |config|
config.redis = Redis::Config.app
- config.server_middleware do |chain|
- chain.add ChatwootDequeuedLogger
+ if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_SIDEKIQ_DEQUEUE_LOGGER', false))
+ config.server_middleware do |chain|
+ chain.add ChatwootDequeuedLogger
+ end
end
# skip the default start stop logging
diff --git a/db/migrate/20251021082242_add_unique_index_to_companies_domain.rb b/db/migrate/20251021082242_add_unique_index_to_companies_domain.rb
new file mode 100644
index 000000000..2fb387242
--- /dev/null
+++ b/db/migrate/20251021082242_add_unique_index_to_companies_domain.rb
@@ -0,0 +1,16 @@
+class AddUniqueIndexToCompaniesDomain < ActiveRecord::Migration[7.1]
+ def up
+ remove_index :companies, name: 'index_companies_on_domain_and_account_id', if_exists: true
+
+ add_index :companies, [:account_id, :domain],
+ unique: true,
+ name: 'index_companies_on_account_and_domain',
+ where: 'domain IS NOT NULL'
+ end
+
+ def down
+ remove_index :companies, name: 'index_companies_on_account_and_domain', if_exists: true
+ add_index :companies, [:domain, :account_id],
+ name: 'index_companies_on_domain_and_account_id'
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 022a0101e..45165d5c5 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -577,8 +577,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_22_152158) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
t.index ["account_id"], name: "index_companies_on_account_id"
- t.index ["domain", "account_id"], name: "index_companies_on_domain_and_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
end
diff --git a/enterprise/app/jobs/migration/company_account_batch_job.rb b/enterprise/app/jobs/migration/company_account_batch_job.rb
new file mode 100644
index 000000000..2e6c20f19
--- /dev/null
+++ b/enterprise/app/jobs/migration/company_account_batch_job.rb
@@ -0,0 +1,54 @@
+class Migration::CompanyAccountBatchJob < ApplicationJob
+ queue_as :low
+
+ def perform(account)
+ account.contacts
+ .where.not(email: nil)
+ .find_in_batches(batch_size: 1000) do |contact_batch|
+ process_contact_batch(contact_batch, account)
+ end
+ end
+
+ private
+
+ def process_contact_batch(contacts, account)
+ contacts.each do |contact|
+ next unless should_process?(contact)
+
+ company = find_or_create_company(contact, account)
+ # rubocop:disable Rails/SkipsModelValidations
+ contact.update_column(:company_id, company.id) if company
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+ end
+
+ def should_process?(contact)
+ return false if contact.company_id.present?
+ return false if contact.email.blank?
+
+ Companies::BusinessEmailDetectorService.new(contact.email).perform
+ end
+
+ def find_or_create_company(contact, account)
+ domain = extract_domain(contact.email)
+ company_name = derive_company_name(contact, domain)
+
+ Company.find_or_create_by!(account: account, domain: domain) do |company|
+ company.name = company_name
+ end
+ rescue ActiveRecord::RecordNotUnique
+ # Race condition: Another job created it between our check and create
+ # just find the one that was created
+
+ Company.find_by(account: account, domain: domain)
+ end
+
+ def extract_domain(email)
+ email.split('@').last&.downcase
+ end
+
+ def derive_company_name(contact, domain)
+ contact.additional_attributes&.dig('company_name').presence ||
+ domain.split('.').first.tr('-_', ' ').titleize
+ end
+end
diff --git a/enterprise/app/jobs/migration/company_backfill_job.rb b/enterprise/app/jobs/migration/company_backfill_job.rb
new file mode 100644
index 000000000..db9a3370f
--- /dev/null
+++ b/enterprise/app/jobs/migration/company_backfill_job.rb
@@ -0,0 +1,17 @@
+class Migration::CompanyBackfillJob < ApplicationJob
+ queue_as :low
+
+ def perform
+ Rails.logger.info 'Starting company backfill migration...'
+ account_count = 0
+ Account.find_in_batches(batch_size: 100) do |accounts|
+ accounts.each do |account|
+ Rails.logger.info "Enqueuing company backfill for account #{account.id}"
+ Migration::CompanyAccountBatchJob.perform_later(account)
+ account_count += 1
+ end
+ end
+
+ Rails.logger.info "Company backfill migration complete. Enqueued jobs for #{account_count} accounts."
+ end
+end
diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb
index 764cb2a9c..fde6cb122 100644
--- a/enterprise/app/models/company.rb
+++ b/enterprise/app/models/company.rb
@@ -12,9 +12,9 @@
#
# Indexes
#
-# index_companies_on_account_id (account_id)
-# index_companies_on_domain_and_account_id (domain,account_id)
-# index_companies_on_name_and_account_id (name,account_id)
+# index_companies_on_account_and_domain (account_id,domain) UNIQUE WHERE (domain IS NOT NULL)
+# index_companies_on_account_id (account_id)
+# index_companies_on_name_and_account_id (name,account_id)
#
class Company < ApplicationRecord
include Avatarable
@@ -24,6 +24,7 @@ class Company < ApplicationRecord
with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/,
message: I18n.t('errors.companies.domain.invalid')
}
+ validates :domain, uniqueness: { scope: :account_id }, if: -> { domain.present? }
validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
belongs_to :account
diff --git a/enterprise/app/models/enterprise/concerns/contact.rb b/enterprise/app/models/enterprise/concerns/contact.rb
index 9139fc67e..09885a947 100644
--- a/enterprise/app/models/enterprise/concerns/contact.rb
+++ b/enterprise/app/models/enterprise/concerns/contact.rb
@@ -2,5 +2,30 @@ module Enterprise::Concerns::Contact
extend ActiveSupport::Concern
included do
belongs_to :company, optional: true
+
+ after_commit :associate_company_from_email,
+ on: [:create, :update],
+ if: :should_associate_company?
+ end
+
+ private
+
+ def should_associate_company?
+ # Only trigger if:
+ # 1. Contact has an email
+ # 2. Contact doesn't have a compan yet
+ # 3. Email was just set/changed
+ # 4. Email was previously nil (first time getting email)
+ email.present? &&
+ company_id.nil? &&
+ saved_change_to_email? &&
+ saved_change_to_email.first.nil?
+ end
+
+ def associate_company_from_email
+ Contacts::CompanyAssociationService.new.associate_company_from_email(self)
+ rescue StandardError => e
+ Rails.logger.error("Failed to associate company for contact #{id}: #{e.message}")
+ # Don't fail the contact save if the company association fails
end
end
diff --git a/enterprise/app/services/companies/business_email_detector_service.rb b/enterprise/app/services/companies/business_email_detector_service.rb
new file mode 100644
index 000000000..422ef41c8
--- /dev/null
+++ b/enterprise/app/services/companies/business_email_detector_service.rb
@@ -0,0 +1,19 @@
+class Companies::BusinessEmailDetectorService
+ attr_reader :email
+
+ def initialize(email)
+ @email = email
+ end
+
+ def perform
+ return false if email.blank?
+
+ address = ValidEmail2::Address.new(email)
+ return false unless address.valid?
+ return false if address.disposable_domain?
+
+ provider = EmailProviderInfo.call(email)
+
+ provider.nil?
+ end
+end
diff --git a/enterprise/app/services/contacts/company_association_service.rb b/enterprise/app/services/contacts/company_association_service.rb
new file mode 100644
index 000000000..f2e2ffdd2
--- /dev/null
+++ b/enterprise/app/services/contacts/company_association_service.rb
@@ -0,0 +1,46 @@
+class Contacts::CompanyAssociationService
+ def associate_company_from_email(contact)
+ return nil if skip_association?(contact)
+
+ company = find_or_create_company(contact)
+ # rubocop:disable Rails/SkipsModelValidations
+ # Intentionally using update_column here to:
+ # 1. Avoid triggering callbacks
+ # 2. Improve performance (We're only setting company_id, no need for validation)
+ contact.update_column(:company_id, company.id) if company
+ # rubocop:enable Rails/SkipsModelValidations
+ company
+ end
+
+ private
+
+ def skip_association?(contact)
+ return true if contact.company_id.present?
+ return true if contact.email.blank?
+
+ detector = Companies::BusinessEmailDetectorService.new(contact.email)
+ return true unless detector.perform
+
+ false
+ end
+
+ def find_or_create_company(contact)
+ domain = extract_domain(contact.email)
+ company_name = derive_company_name(contact, domain)
+
+ Company.find_or_create_by!(account: contact.account, domain: domain) do |company|
+ company.name = company_name
+ end
+ rescue ActiveRecord::RecordNotUnique
+ # If another process created it first, just find that
+ Company.find_by(account: contact.account, domain: domain)
+ end
+
+ def extract_domain(email)
+ email.split('@').last&.downcase
+ end
+
+ def derive_company_name(contact, domain)
+ contact.additional_attributes&.dig('company_name') || domain.split('.').first.tr('-_', ' ').titleize
+ end
+end
diff --git a/lib/tasks/companies.rake b/lib/tasks/companies.rake
new file mode 100644
index 000000000..11fb5dc10
--- /dev/null
+++ b/lib/tasks/companies.rake
@@ -0,0 +1,12 @@
+namespace :companies do
+ desc 'Backfill companies from existing contact email domains'
+ task backfill: :environment do
+ puts 'Starting company backfill migration...'
+ puts 'This will process all accounts and create companies from contact email domains.'
+ puts 'The job will run in the background via Sidekiq'
+ puts ''
+ Migration::CompanyBackfillJob.perform_later
+ puts 'Company backfill job has been enqueued.'
+ puts 'Monitor progress in logs or Sidekiq dashboard.'
+ end
+end
diff --git a/package.json b/package.json
index 6fb7156cb..4660b44fa 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,7 @@
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.2.1",
+ "@chatwoot/prosemirror-schema": "1.2.3",
"@chatwoot/utils": "^0.0.51",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 22c662d89..e5629af3b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,8 +20,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.2.1
- version: 1.2.1
+ specifier: 1.2.3
+ version: 1.2.3
'@chatwoot/utils':
specifier: ^0.0.51
version: 0.0.51
@@ -406,8 +406,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.2.1':
- resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==}
+ '@chatwoot/prosemirror-schema@1.2.3':
+ resolution: {integrity: sha512-q/EfirVK9jt8FJAx3Gf6y3LoVadmYVLknbYvPrkUe81WO0f2mkZ/kY2UQgpUISVvOGEkCH4bkfYMp5UQ+Buz3g==}
'@chatwoot/utils@0.0.51':
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
@@ -4768,7 +4768,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.2.1':
+ '@chatwoot/prosemirror-schema@1.2.3':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
diff --git a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb
index 9c53099fd..1ab490a96 100644
--- a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb
@@ -195,37 +195,6 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer')
expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer')
end
-
- it 'enqueues contact bulk action job with permitted params' do
- contact_one = create(:contact, account: account)
- contact_two = create(:contact, account: account)
-
- previous_adapter = ActiveJob::Base.queue_adapter
- ActiveJob::Base.queue_adapter = :test
-
- expect do
- post "/api/v1/accounts/#{account.id}/bulk_actions",
- headers: agent.create_new_auth_token,
- params: {
- type: 'Contact',
- ids: [contact_one.id, contact_two.id],
- labels: { add: %w[vip support] },
- extra: 'ignored'
- }
- end.to have_enqueued_job(Contacts::BulkActionJob).with(
- account.id,
- agent.id,
- hash_including(
- 'ids' => [contact_one.id, contact_two.id],
- 'labels' => hash_including('add' => %w[vip support])
- )
- )
-
- expect(response).to have_http_status(:success)
- ensure
- ActiveJob::Base.queue_adapter = previous_adapter
- clear_enqueued_jobs
- end
end
end
@@ -256,4 +225,49 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
end
end
end
+
+ describe 'POST /api/v1/accounts/{account.id}/bulk_actions (contacts)' do
+ context 'when it is an authenticated user' do
+ let!(:agent) { create(:user, account: account, role: :agent) }
+
+ it 'enqueues Contacts::BulkActionJob with permitted params' do
+ contact_one = create(:contact, account: account)
+ contact_two = create(:contact, account: account)
+
+ expect do
+ post "/api/v1/accounts/#{account.id}/bulk_actions",
+ headers: agent.create_new_auth_token,
+ params: {
+ type: 'Contact',
+ ids: [contact_one.id, contact_two.id],
+ labels: { add: %w[vip support] },
+ extra: 'ignored'
+ }
+ end.to have_enqueued_job(Contacts::BulkActionJob).with(
+ account.id,
+ agent.id,
+ hash_including(
+ 'ids' => [contact_one.id.to_s, contact_two.id.to_s],
+ 'labels' => hash_including('add' => %w[vip support])
+ )
+ )
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'returns unauthorized for delete action when user is not admin' do
+ contact = create(:contact, account: account)
+
+ post "/api/v1/accounts/#{account.id}/bulk_actions",
+ headers: agent.create_new_auth_token,
+ params: {
+ type: 'Contact',
+ ids: [contact.id],
+ action_name: 'delete'
+ }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
end
diff --git a/spec/enterprise/jobs/migration/company_account_batch_job_spec.rb b/spec/enterprise/jobs/migration/company_account_batch_job_spec.rb
new file mode 100644
index 000000000..499b96287
--- /dev/null
+++ b/spec/enterprise/jobs/migration/company_account_batch_job_spec.rb
@@ -0,0 +1,133 @@
+require 'rails_helper'
+
+RSpec.describe Migration::CompanyAccountBatchJob, type: :job do
+ let(:account) { create(:account) }
+
+ describe '#perform' do
+ before do
+ # Stub EmailProvideInfo to control behavior in tests
+ allow(EmailProviderInfo).to receive(:call) do |email|
+ domain = email.split('@').last&.downcase
+ case domain
+ when 'gmail.com', 'yahoo.com', 'hotmail.com', 'uol.com.br'
+ 'free_provider' # generic free provider name
+ end
+ end
+ end
+
+ context 'when contact has business email' do
+ let!(:contact) { create(:contact, account: account, email: 'user@acme.com') }
+
+ it 'creates a company and associates the contact' do
+ # Clean up companies created by Part 2's callback
+ Company.delete_all
+ # rubocop:disable Rails/SkipsModelValidations
+ contact.update_column(:company_id, nil)
+ # rubocop:enable Rails/SkipsModelValidations
+
+ expect do
+ described_class.perform_now(account)
+ end.to change(Company, :count).by(1)
+ contact.reload
+ expect(contact.company).to be_present
+ expect(contact.company.domain).to eq('acme.com')
+ expect(contact.company.name).to eq('Acme')
+ end
+ end
+
+ context 'when contact has free email' do
+ let!(:contact) { create(:contact, account: account, email: 'user@gmail.com') }
+
+ it 'does not create a company' do
+ expect do
+ described_class.perform_now(account)
+ end.not_to change(Company, :count)
+ contact.reload
+ expect(contact.company_id).to be_nil
+ end
+ end
+
+ context 'when contact has company_name in additional_attributes' do
+ let!(:contact) do
+ create(:contact, account: account, email: 'user@acme.com', additional_attributes: { 'company_name' => 'Acme Corporation' })
+ end
+
+ it 'uses the saved company name' do
+ described_class.perform_now(account)
+ contact.reload
+ expect(contact.company.name).to eq('Acme Corporation')
+ end
+ end
+
+ context 'when contact already has a company' do
+ let!(:existing_company) { create(:company, account: account, domain: 'existing.com') }
+ let!(:contact) do
+ create(:contact, account: account, email: 'user@acme.com', company: existing_company)
+ end
+
+ it 'does not change the existing company' do
+ described_class.perform_now(account)
+ contact.reload
+ expect(contact.company_id).to eq(existing_company.id)
+ end
+ end
+
+ context 'when multiple contacts have the same domain' do
+ let!(:contact1) { create(:contact, account: account, email: 'user1@acme.com') }
+ let!(:contact2) { create(:contact, account: account, email: 'user2@acme.com') }
+
+ it 'creates only one company for the domain' do
+ # Clean up companies created by Part 2's callback
+ Company.delete_all
+ # rubocop:disable Rails/SkipsModelValidations
+ contact1.update_column(:company_id, nil)
+ contact2.update_column(:company_id, nil)
+ # rubocop:enable Rails/SkipsModelValidations
+
+ expect do
+ described_class.perform_now(account)
+ end.to change(Company, :count).by(1)
+ contact1.reload
+ contact2.reload
+ expect(contact1.company_id).to eq(contact2.company_id)
+ expect(contact1.company.domain).to eq('acme.com')
+ end
+ end
+
+ context 'when contact has no email' do
+ let!(:contact) { create(:contact, account: account, email: nil) }
+
+ it 'skips the contact' do
+ expect do
+ described_class.perform_now(account)
+ end.not_to change(Company, :count)
+ contact.reload
+ expect(contact.company_id).to be_nil
+ end
+ end
+
+ context 'when processing large batch' do
+ before do
+ contacts_data = Array.new(2000) do |i|
+ {
+ account_id: account.id,
+ email: "user#{i}@company#{i % 100}.com",
+ name: "User #{i}",
+ created_at: Time.current,
+ updated_at: Time.current
+ }
+ end
+ # rubocop:disable Rails/SkipsModelValidations
+ Contact.insert_all(contacts_data)
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
+ it 'processes all contacts in batches' do
+ expect do
+ described_class.perform_now(account)
+ end.to change(Company, :count).by(100)
+ expect(account.contacts.where.not(company_id: nil).count).to eq(2000)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/migration/company_backfill_job_spec.rb b/spec/enterprise/jobs/migration/company_backfill_job_spec.rb
new file mode 100644
index 000000000..e60b84487
--- /dev/null
+++ b/spec/enterprise/jobs/migration/company_backfill_job_spec.rb
@@ -0,0 +1,31 @@
+require 'rails_helper'
+
+RSpec.describe Migration::CompanyBackfillJob, type: :job do
+ describe '#perform' do
+ it 'enqueues the job' do
+ expect { described_class.perform_later }
+ .to have_enqueued_job(described_class)
+ .on_queue('low')
+ end
+
+ context 'when accounts exist' do
+ let!(:account1) { create(:account) }
+ let!(:account2) { create(:account) }
+
+ it 'enqueues CompanyAccountBatchJob for each account' do
+ expect do
+ described_class.perform_now
+ end.to have_enqueued_job(Migration::CompanyAccountBatchJob)
+ .with(account1)
+ .and have_enqueued_job(Migration::CompanyAccountBatchJob)
+ .with(account2)
+ end
+ end
+
+ context 'when no accounts exist' do
+ it 'completes without error' do
+ expect { described_class.perform_now }.not_to raise_error
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/contact_company_association_spec.rb b/spec/enterprise/models/contact_company_association_spec.rb
new file mode 100644
index 000000000..065d5bc97
--- /dev/null
+++ b/spec/enterprise/models/contact_company_association_spec.rb
@@ -0,0 +1,61 @@
+require 'rails_helper'
+
+RSpec.describe Contact, type: :model do
+ describe 'company auto-association' do
+ let(:account) { create(:account) }
+
+ context 'when creating a new contact with business email' do
+ it 'automatically creates and associates a company' do
+ expect do
+ create(:contact, email: 'john@acme.com', account: account)
+ end.to change(Company, :count).by(1)
+ contact = described_class.last
+ expect(contact.company).to be_present
+ expect(contact.company.domain).to eq('acme.com')
+ end
+
+ it 'does not create company for free email providers' do
+ expect do
+ create(:contact, email: 'john@gmail.com', account: account)
+ end.not_to change(Company, :count)
+ end
+ end
+
+ context 'when updating a contact to add email for first time' do
+ it 'creates and associates company' do
+ contact = create(:contact, email: nil, account: account)
+ expect do
+ contact.update(email: 'john@acme.com')
+ end.to change(Company, :count).by(1)
+ contact.reload
+ expect(contact.company.domain).to eq('acme.com')
+ end
+ end
+
+ context 'when updating a contact that already has a company' do
+ it 'does not change company when email changes' do
+ existing_company = create(:company, domain: 'oldcompany.com', account: account)
+ contact = create(:contact, email: 'john@oldcompany.com', company: existing_company, account: account)
+
+ expect do
+ contact.update(email: 'john@new_company.com')
+ end.not_to change(Company, :count)
+ contact.reload
+ expect(contact.company).to eq(existing_company)
+ end
+ end
+
+ context 'when multiple contacts share the same domain' do
+ it 'associates all contacts with the same company' do
+ contacts = ['john@acme.com', 'jane@acme.com', 'bob@acme.com']
+ contacts.each do |contact|
+ create(:contact, email: contact, account: account)
+ end
+
+ expect(Company.where(domain: 'acme.com', account: account).count).to eq(1)
+ company = Company.find_by(domain: 'acme.com', account: account)
+ expect(company.contacts.count).to eq(contacts.length)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/companies/business_email_detector_service_spec.rb b/spec/enterprise/services/companies/business_email_detector_service_spec.rb
new file mode 100644
index 000000000..ceabfa905
--- /dev/null
+++ b/spec/enterprise/services/companies/business_email_detector_service_spec.rb
@@ -0,0 +1,99 @@
+require 'rails_helper'
+
+RSpec.describe Companies::BusinessEmailDetectorService, type: :service do
+ let(:service) { described_class.new(email) }
+
+ describe '#perform' do
+ context 'when email is from a business domain' do
+ let(:email) { 'user@acme.com' }
+ let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
+
+ before do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with(email).and_return(nil)
+ end
+
+ it 'returns true' do
+ expect(service.perform).to be(true)
+ end
+ end
+
+ context 'when email is from gmail' do
+ let(:email) { 'user@gmail.com' }
+ let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
+
+ before do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with(email).and_return('gmail')
+ end
+
+ it 'returns false' do
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email is from Brazilian free provider' do
+ let(:email) { 'user@uol.com.br' }
+ let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
+
+ before do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with(email).and_return('uol')
+ end
+
+ it 'returns false' do
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email is disposable' do
+ let(:email) { 'user@mailinator.com' }
+ let(:disposable_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: true) }
+
+ it 'returns false' do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address)
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email is invalid format' do
+ let(:email) { 'invalid-email' }
+ let(:invalid_email_address) { instance_double(ValidEmail2::Address, valid?: false) }
+
+ it 'returns false' do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address)
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email is nil' do
+ let(:email) { nil }
+
+ it 'remains false' do
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email is empty string' do
+ let(:email) { '' }
+
+ it 'returns false' do
+ expect(service.perform).to be(false)
+ end
+ end
+
+ context 'when email domain is uppercase' do
+ let(:email) { 'user@GMAIL.COM' }
+ let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
+
+ before do
+ allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with(email).and_return('gmail')
+ end
+
+ it 'returns false (case insensitive)' do
+ expect(service.perform).to be(false)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/contacts/company_association_service_spec.rb b/spec/enterprise/services/contacts/company_association_service_spec.rb
new file mode 100644
index 000000000..ea9363bad
--- /dev/null
+++ b/spec/enterprise/services/contacts/company_association_service_spec.rb
@@ -0,0 +1,83 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::CompanyAssociationService, type: :service do
+ let(:account) { create(:account) }
+ let(:service) { described_class.new }
+
+ describe '#associate_company_from_email' do
+ context 'when contact has business email and no company' do
+ it 'creates a new company and associates it' do
+ contact = create(:contact, email: 'john@acme.com', account: account, company_id: nil)
+ Company.delete_all # Delete any companies created by the callback
+ # rubocop:disable Rails/SkipsModelValidations
+ contact.update_column(:company_id, nil) # Delete the company association created by the callback
+ # rubocop:enable Rails/SkipsModelValidations
+
+ valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false)
+ allow(ValidEmail2::Address).to receive(:new).with('john@acme.com').and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with('john@acme.com').and_return(nil)
+
+ expect do
+ service.associate_company_from_email(contact)
+ end.to change(Company, :count).by(1)
+
+ contact.reload
+ expect(contact.company).to be_present
+ expect(contact.company.domain).to eq('acme.com')
+ expect(contact.company.name).to eq('Acme')
+ end
+
+ it 'reuses existing company with same domain' do
+ existing_company = create(:company, domain: 'acme.com', account: account)
+ contact = create(:contact, email: 'john@acme.com', account: account, company_id: nil)
+ # rubocop:disable Rails/SkipsModelValidations
+ contact.update_column(:company_id, nil) # Delete the company association created by the callback
+ # rubocop:enable Rails/SkipsModelValidations
+
+ valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false)
+ allow(ValidEmail2::Address).to receive(:new).with('john@acme.com').and_return(valid_email_address)
+ allow(EmailProviderInfo).to receive(:call).with('john@acme.com').and_return(nil)
+
+ expect do
+ service.associate_company_from_email(contact)
+ end.not_to change(Company, :count)
+
+ contact.reload
+ expect(contact.company).to eq(existing_company)
+ end
+ end
+
+ context 'when contact already has a company' do
+ it 'skips association and returns nil' do
+ existing_company = create(:company, account: account)
+ contact = create(:contact, email: 'john@acme.com', account: account, company_id: existing_company.id)
+ result = service.associate_company_from_email(contact)
+
+ expect(result).to be_nil
+ contact.reload
+ expect(contact.company).to eq(existing_company)
+ end
+ end
+
+ context 'when contact has free email provider' do
+ it 'skips association for email' do
+ contact = create(:contact, email: 'john@gmail.com', account: account, company_id: nil)
+ expect do
+ service.associate_company_from_email(contact)
+ end.not_to change(Company, :count)
+ contact.reload
+ expect(contact.company).to be_nil
+ end
+ end
+
+ context 'when contact has no email' do
+ it 'skips association' do
+ contact = create(:contact, email: nil, account: account, company_id: nil)
+
+ result = service.associate_company_from_email(contact)
+ expect(result).to be_nil
+ expect(contact.reload.company).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/factories/messages.rb b/spec/factories/messages.rb
index b2ae41c5e..99a2c7cd8 100644
--- a/spec/factories/messages.rb
+++ b/spec/factories/messages.rb
@@ -27,6 +27,13 @@ FactoryBot.define do
end
end
+ trait :bot_message do
+ message_type { 'outgoing' }
+ after(:build) do |message|
+ message.sender = nil
+ end
+ end
+
after(:build) do |message|
message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account)
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index 72962ba54..51bb43384 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -576,9 +576,38 @@ RSpec.describe Conversation do
expect(conversation.status).to eq('pending')
end
- it 'returns conversation as open if campaign is present' do
- conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: create(:campaign))
- expect(conversation.status).to eq('open')
+ context 'with campaigns' do
+ let(:user) { create(:user, account: bot_inbox.inbox.account) }
+
+ it 'returns conversation as open if campaign has a sender' do
+ campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: user)
+ conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
+ expect(conversation.status).to eq('open')
+ end
+
+ it 'returns conversation as pending if campaign has no sender (bot-initiated) and bot is active' do
+ campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: nil)
+ conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
+ expect(conversation.status).to eq('pending')
+ end
+ end
+
+ context 'with campaigns in inbox without bot' do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:user) { create(:user, account: account) }
+
+ it 'returns conversation as open if campaign has no sender but no bot is active' do
+ campaign = create(:campaign, inbox: inbox, account: account, sender: nil)
+ conversation = create(:conversation, inbox: inbox, campaign: campaign)
+ expect(conversation.status).to eq('open')
+ end
+
+ it 'returns conversation as open if campaign has a sender' do
+ campaign = create(:campaign, inbox: inbox, account: account, sender: user)
+ conversation = create(:conversation, inbox: inbox, campaign: campaign)
+ expect(conversation.status).to eq('open')
+ end
end
end
diff --git a/spec/presenters/messages/search_data_presenter_spec.rb b/spec/presenters/messages/search_data_presenter_spec.rb
new file mode 100644
index 000000000..0bbe1a812
--- /dev/null
+++ b/spec/presenters/messages/search_data_presenter_spec.rb
@@ -0,0 +1,78 @@
+require 'rails_helper'
+
+RSpec.describe Messages::SearchDataPresenter do
+ let(:presenter) { described_class.new(message) }
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+ let(:message) { create(:message, account: account, inbox: inbox, conversation: conversation, sender: contact) }
+
+ describe '#search_data' do
+ let(:expected_data) do
+ {
+ content: message.content,
+ account_id: message.account_id,
+ inbox_id: message.inbox_id,
+ conversation_id: message.conversation_id,
+ message_type: message.message_type,
+ private: message.private,
+ created_at: message.created_at,
+ source_id: message.source_id,
+ sender_id: message.sender_id,
+ sender_type: message.sender_type,
+ conversation: {
+ id: conversation.display_id
+ }
+ }
+ end
+
+ it 'returns search index payload with core fields' do
+ expect(presenter.search_data).to include(expected_data)
+ end
+
+ context 'with attachments' do
+ before do
+ attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
+ attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
+ attachment.meta = { 'transcribed_text' => 'Hello world' }
+ end
+
+ it 'includes attachment transcriptions' do
+ attachments_data = presenter.search_data[:attachments]
+ expect(attachments_data).to be_an(Array)
+ expect(attachments_data.first).to include(transcribed_text: 'Hello world')
+ end
+ end
+
+ context 'with email content attributes' do
+ before do
+ message.update(
+ content_attributes: { email: { subject: 'Test Subject' } }
+ )
+ end
+
+ it 'includes email subject' do
+ content_attrs = presenter.search_data[:content_attributes]
+ expect(content_attrs[:email][:subject]).to eq('Test Subject')
+ end
+ end
+
+ context 'with campaign and automation data' do
+ before do
+ message.update(
+ additional_attributes: { 'campaign_id' => '123' },
+ content_attributes: { 'automation_rule_id' => '456' }
+ )
+ end
+
+ it 'includes campaign_id' do
+ expect(presenter.search_data[:additional_attributes][:campaign_id]).to eq('123')
+ end
+
+ it 'includes automation_rule_id' do
+ expect(presenter.search_data[:additional_attributes][:automation_rule_id]).to eq('456')
+ end
+ end
+ end
+end
diff --git a/spec/services/contacts/bulk_action_service_spec.rb b/spec/services/contacts/bulk_action_service_spec.rb
new file mode 100644
index 000000000..0411c8455
--- /dev/null
+++ b/spec/services/contacts/bulk_action_service_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::BulkActionService do
+ subject(:service) { described_class.new(account: account, user: user, params: params) }
+
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+
+ describe '#perform' do
+ context 'when delete action is requested via action_name' do
+ let(:params) { { ids: [1, 2], action_name: 'delete' } }
+
+ it 'delegates to the bulk delete service' do
+ bulk_delete_service = instance_double(Contacts::BulkDeleteService, perform: true)
+
+ expect(Contacts::BulkDeleteService).to receive(:new)
+ .with(account: account, contact_ids: [1, 2])
+ .and_return(bulk_delete_service)
+
+ service.perform
+ end
+ end
+
+ context 'when labels are provided' do
+ let(:params) { { ids: [10, 20], labels: { add: %w[vip support] }, extra: 'ignored' } }
+
+ it 'delegates to the bulk assign labels service with permitted params' do
+ bulk_assign_service = instance_double(Contacts::BulkAssignLabelsService, perform: true)
+
+ expect(Contacts::BulkAssignLabelsService).to receive(:new)
+ .with(account: account, contact_ids: [10, 20], labels: %w[vip support])
+ .and_return(bulk_assign_service)
+
+ service.perform
+ end
+ end
+ end
+end
diff --git a/spec/services/contacts/bulk_delete_service_spec.rb b/spec/services/contacts/bulk_delete_service_spec.rb
new file mode 100644
index 000000000..4eaecaa0f
--- /dev/null
+++ b/spec/services/contacts/bulk_delete_service_spec.rb
@@ -0,0 +1,24 @@
+require 'rails_helper'
+
+RSpec.describe Contacts::BulkDeleteService do
+ subject(:service) { described_class.new(account: account, contact_ids: contact_ids) }
+
+ let(:account) { create(:account) }
+ let!(:contact_one) { create(:contact, account: account) }
+ let!(:contact_two) { create(:contact, account: account) }
+ let(:contact_ids) { [contact_one.id, contact_two.id] }
+
+ describe '#perform' do
+ it 'deletes the provided contacts' do
+ expect { service.perform }
+ .to change { account.contacts.exists?(contact_one.id) }.from(true).to(false)
+ .and change { account.contacts.exists?(contact_two.id) }.from(true).to(false)
+ end
+
+ it 'returns when no contact ids are provided' do
+ empty_service = described_class.new(account: account, contact_ids: [])
+
+ expect { empty_service.perform }.not_to change(Contact, :count)
+ end
+ end
+end
diff --git a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
index 49fcc1a18..b79ee6d79 100644
--- a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
+++ b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb
@@ -28,6 +28,14 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
content: 'Hello, I need help'
)
+ create(
+ :message,
+ :bot_message,
+ conversation: conversation,
+ message_type: 'outgoing',
+ content: 'Thanks for reaching out, an agent will reach out to you soon'
+ )
+
create(
:message,
conversation: conversation,
@@ -40,7 +48,8 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
"Channel: #{conversation.inbox.channel.name}",
'Message History:',
'User: Hello, I need help',
- 'Support agent: How can I assist you today?',
+ 'Bot: Thanks for reaching out, an agent will reach out to you soon',
+ 'Support Agent: How can I assist you today?',
''
].join("\n")
diff --git a/spec/services/widget/token_service_expiry_spec.rb b/spec/services/widget/token_service_expiry_spec.rb
index 051a757a5..50e104c65 100644
--- a/spec/services/widget/token_service_expiry_spec.rb
+++ b/spec/services/widget/token_service_expiry_spec.rb
@@ -15,11 +15,11 @@ RSpec.describe Widget::TokenService, type: :service do
end
it 'uses the configured value for token expiry' do
- freeze_time do
+ travel_to '2025-01-01' do
token = service.generate_token
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
- expect(decoded['iat']).to eq(Time.now.to_i)
- expect(decoded['exp']).to eq(30.days.from_now.to_i)
+ expect(decoded['iat']).to eq(Time.zone.now.to_i)
+ expect(decoded['exp']).to eq(Time.zone.now.to_i + 30.days.to_i)
end
end
end
@@ -30,11 +30,11 @@ RSpec.describe Widget::TokenService, type: :service do
end
it 'uses the default expiry' do
- freeze_time do
+ travel_to '2025-01-01' do
token = service.generate_token
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
- expect(decoded['iat']).to eq(Time.now.to_i)
- expect(decoded['exp']).to eq(180.days.from_now.to_i)
+ expect(decoded['iat']).to eq(Time.zone.now.to_i)
+ expect(decoded['exp']).to eq(Time.zone.now.to_i + 180.days.to_i)
end
end
end