fix(companies): sync contact company names (#14759)

Fixes company-contact name drift when a company is renamed or deleted.

Closes: N/A

## Why
Contacts keep a denormalized `additional_attributes.company_name` for
display and filtering. Company rename/delete flows could leave that
copied value stale even though the actual `company_id` relationship
changed.

## What changed
- Enqueues an async company contact-name sync job when a company name
changes.
- Moves company deletion into `Companies::DeleteJob`.
- The delete job unlinks linked contacts, clears only the copied
`company_name`, and then deletes the company.
- Uses bulk JSON updates for the cleanup path so contact records are not
saved, which avoids contact update callbacks, webhook dispatch, and
automation side effects.

## How to test
- Link a contact to a company, rename the company, and confirm the
contact company name updates after the job runs.
- Delete a company with linked contacts and confirm the delete job
removes the company, unassigns linked contacts, and preserves other
contact additional attributes.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
This commit is contained in:
Sojan Jose
2026-06-22 06:49:52 -07:00
committed by GitHub
co-authored by Sony Mathew Sony Mathew
parent 44b32eacec
commit 2b37fef0e0
8 changed files with 138 additions and 4 deletions
@@ -49,7 +49,7 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
end
def destroy
@company.destroy!
Companies::DeleteJob.perform_later(company_id: @company.id)
head :ok
end
@@ -0,0 +1,28 @@
class Companies::DeleteJob < ApplicationJob
queue_as :low
BATCH_SIZE = 1000
CONTACT_COMPANY_CLEAR_SQL = <<~SQL.squish.freeze
company_id = NULL,
additional_attributes = COALESCE(additional_attributes, '{}'::jsonb) - 'company_name'
SQL
def perform(company_id:)
company = Company.find_by(id: company_id)
return if company.blank?
clear_contact_company_names(company)
company.destroy!
end
private
# Avoid contact callbacks so this cleanup does not dispatch contact automations/webhooks.
# rubocop:disable Rails/SkipsModelValidations
def clear_contact_company_names(company)
company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
contacts.update_all(CONTACT_COMPANY_CLEAR_SQL)
end
end
# rubocop:enable Rails/SkipsModelValidations
end
@@ -0,0 +1,33 @@
class Companies::SyncContactNamesJob < ApplicationJob
queue_as :low
BATCH_SIZE = 1000
CONTACT_COMPANY_NAME_UPDATE_SQL = <<~SQL.squish.freeze
additional_attributes = jsonb_set(
COALESCE(additional_attributes, '{}'::jsonb),
'{company_name}',
?::jsonb,
true
)
SQL
def perform(company_id:)
return if company_id.blank?
company = Company.find_by(id: company_id)
return if company.blank?
sync_company_name(company)
end
private
# Denormalized display field sync; avoid contact validations, callbacks, and webhook/automation side effects.
# rubocop:disable Rails/SkipsModelValidations
def sync_company_name(company)
company.contacts.in_batches(of: BATCH_SIZE) do |contacts|
contacts.update_all([CONTACT_COMPANY_NAME_UPDATE_SQL, company.name.to_json])
end
end
# rubocop:enable Rails/SkipsModelValidations
end
+5
View File
@@ -39,6 +39,7 @@ class Company < ApplicationRecord
has_many :contacts, dependent: :nullify
before_validation :prepare_jsonb_attributes
after_create_commit :fetch_favicon, if: -> { domain.present? }
after_update_commit :enqueue_contact_company_name_sync, if: :saved_change_to_name?
scope :ordered_by_name, -> { order(:name) }
scope :search_by_name_or_domain, lambda { |query|
@@ -76,4 +77,8 @@ class Company < ApplicationRecord
def fetch_favicon
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
end
def enqueue_contact_company_name_sync
Companies::SyncContactNamesJob.perform_later(company_id: id)
end
end
@@ -385,13 +385,13 @@ RSpec.describe 'Companies API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'deletes the company' do
company
it 'enqueues company deletion' do
expect do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(-1)
end.to have_enqueued_job(Companies::DeleteJob).with(company_id: company.id)
expect(response).to have_http_status(:ok)
end
end
@@ -0,0 +1,19 @@
require 'rails_helper'
RSpec.describe Companies::DeleteJob, type: :job do
describe '#perform' do
it 'unlinks contacts, clears company names, and deletes the company' do
account = create(:account)
company = create(:company, account: account, name: 'Acme')
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
other_contact = create(:contact, account: account, additional_attributes: { 'company_name' => 'Acme' })
described_class.perform_now(company_id: company.id)
expect { company.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(contact.reload.company_id).to be_nil
expect(contact.additional_attributes).to eq('city' => 'Berlin')
expect(other_contact.reload.additional_attributes).to eq('company_name' => 'Acme')
end
end
end
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Companies::SyncContactNamesJob, type: :job do
let(:account) { create(:account) }
let(:company) { create(:company, account: account, name: 'Acme') }
describe '#perform' do
it 'updates linked contact company names' do
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme', 'city' => 'Berlin' })
company.update!(name: 'Acme Labs')
described_class.perform_now(company_id: company.id)
expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs', 'city' => 'Berlin')
end
it 'uses the current company name when a stale rename job runs' do
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
company.update!(name: 'Acme Labs')
described_class.perform_now(company_id: company.id)
expect(contact.reload.additional_attributes).to eq('company_name' => 'Acme Labs')
end
it 'does not save contacts while syncing the denormalized company name' do
contact = create(:contact, account: account, company: company, additional_attributes: { 'company_name' => 'Acme' })
original_updated_at = contact.reload.updated_at
company.update!(name: 'Acme Labs')
described_class.perform_now(company_id: company.id)
expect(contact.reload.updated_at).to eq(original_updated_at)
end
end
end
+11
View File
@@ -46,4 +46,15 @@ RSpec.describe Company, type: :model do
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
end
end
describe 'contact company name sync' do
let(:account) { create(:account) }
let(:company) { create(:company, account: account, name: 'Acme') }
it 'enqueues contact company name sync when the company name changes' do
expect do
company.update!(name: 'Acme Labs')
end.to have_enqueued_job(Companies::SyncContactNamesJob).with(company_id: company.id)
end
end
end