From 2b37fef0e0ce7ce5b9461f522a228facfd35f2b6 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 22 Jun 2026 06:49:52 -0700 Subject: [PATCH] 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 Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> --- .../api/v1/accounts/companies_controller.rb | 2 +- enterprise/app/jobs/companies/delete_job.rb | 28 ++++++++++++++ .../jobs/companies/sync_contact_names_job.rb | 33 ++++++++++++++++ enterprise/app/models/company.rb | 5 +++ .../v1/accounts/companies_controller_spec.rb | 6 +-- .../jobs/companies/delete_job_spec.rb | 19 ++++++++++ .../companies/sync_contact_names_job_spec.rb | 38 +++++++++++++++++++ spec/enterprise/models/company_spec.rb | 11 ++++++ 8 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 enterprise/app/jobs/companies/delete_job.rb create mode 100644 enterprise/app/jobs/companies/sync_contact_names_job.rb create mode 100644 spec/enterprise/jobs/companies/delete_job_spec.rb create mode 100644 spec/enterprise/jobs/companies/sync_contact_names_job_spec.rb diff --git a/enterprise/app/controllers/api/v1/accounts/companies_controller.rb b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb index 1df0e7d88..1f2c38c91 100644 --- a/enterprise/app/controllers/api/v1/accounts/companies_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb @@ -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 diff --git a/enterprise/app/jobs/companies/delete_job.rb b/enterprise/app/jobs/companies/delete_job.rb new file mode 100644 index 000000000..62c36750d --- /dev/null +++ b/enterprise/app/jobs/companies/delete_job.rb @@ -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 diff --git a/enterprise/app/jobs/companies/sync_contact_names_job.rb b/enterprise/app/jobs/companies/sync_contact_names_job.rb new file mode 100644 index 000000000..36f38aacb --- /dev/null +++ b/enterprise/app/jobs/companies/sync_contact_names_job.rb @@ -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 diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb index c60e9423c..b4c42ff3d 100644 --- a/enterprise/app/models/company.rb +++ b/enterprise/app/models/company.rb @@ -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 diff --git a/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb index 84b182669..1d6f4870c 100644 --- a/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb @@ -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 diff --git a/spec/enterprise/jobs/companies/delete_job_spec.rb b/spec/enterprise/jobs/companies/delete_job_spec.rb new file mode 100644 index 000000000..1d4e4249d --- /dev/null +++ b/spec/enterprise/jobs/companies/delete_job_spec.rb @@ -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 diff --git a/spec/enterprise/jobs/companies/sync_contact_names_job_spec.rb b/spec/enterprise/jobs/companies/sync_contact_names_job_spec.rb new file mode 100644 index 000000000..39ed4fc08 --- /dev/null +++ b/spec/enterprise/jobs/companies/sync_contact_names_job_spec.rb @@ -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 diff --git a/spec/enterprise/models/company_spec.rb b/spec/enterprise/models/company_spec.rb index 1b681973d..8f65abe5f 100644 --- a/spec/enterprise/models/company_spec.rb +++ b/spec/enterprise/models/company_spec.rb @@ -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