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:
co-authored by
Sony Mathew
Sony Mathew
parent
44b32eacec
commit
2b37fef0e0
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user