Files
41a3ab6dfa feat(companies): add contact company selector (#14496)
Adds a company selector to the contact details form so agents can
associate a contact with an existing company directly from the contact
page.

Closes

- None

Why

Contacts already expose company information through the CRM fields, but
the form only accepted free-text company names. As we split company CRM
work into smaller PRs, this keeps the contact page aligned with the
structured company model while preserving the existing company-name
behavior used by automations.

What changed

- Shows a company dropdown in the contact details form when the
Companies feature is enabled.
- Keeps legacy free-text company names editable when a contact has no
structured `company_id`.
- Allows Enterprise contact create/update APIs to accept account-scoped
`company_id`.
- Syncs `additional_attributes.company_name` when a contact is
associated with a company, including the existing email-domain
auto-association path.
- Serializes `company_id` in the contact model payload so the form can
show the current association.

How to test

1. Enable Companies for an account and open a contact details page.
2. In Edit contact details, use the Company field to select an existing
company.
3. Save the contact and refresh the page.
4. Confirm the selected company remains visible and the contact is
associated with that company.
5. Confirm contacts with only a legacy free-text company name still show
the text input instead of an empty selector.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-16 15:29:27 +05:30

256 lines
8.3 KiB
Ruby

# rubocop:disable Layout/LineLength
# == Schema Information
#
# Table name: contacts
#
# id :integer not null, primary key
# additional_attributes :jsonb
# blocked :boolean default(FALSE), not null
# contact_type :integer default("visitor")
# country_code :string default("")
# custom_attributes :jsonb
# email :string
# identifier :string
# last_activity_at :datetime
# last_name :string default("")
# location :string default("")
# middle_name :string default("")
# name :string default("")
# phone_number :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# company_id :bigint
#
# Indexes
#
# index_contacts_on_account_id (account_id)
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
# index_contacts_on_blocked (blocked)
# index_contacts_on_company_id (company_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
# index_contacts_on_phone_number_and_account_id (phone_number,account_id)
# index_resolved_contact_account_id (account_id) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
# uniq_email_per_account_contact (email,account_id) UNIQUE
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
#
# rubocop:enable Layout/LineLength
class Contact < ApplicationRecord
include Avatarable
include AvailabilityStatusable
include Labelable
include LlmFormattable
validates :account_id, presence: true
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false },
format: { with: Devise.email_regexp, message: I18n.t('errors.contacts.email.invalid') }
validates :identifier, allow_blank: true, uniqueness: { scope: [:account_id] }
validates :phone_number,
allow_blank: true, uniqueness: { scope: [:account_id] },
format: { with: /\+[1-9]\d{1,14}\z/, message: I18n.t('errors.contacts.phone_number.invalid') }
belongs_to :account
has_many :conversations, dependent: :destroy_async
has_many :contact_inboxes, dependent: :destroy_async
has_many :csat_survey_responses, dependent: :destroy_async
has_many :inboxes, through: :contact_inboxes
has_many :messages, as: :sender, dependent: :destroy_async
has_many :notes, dependent: :destroy_async
before_validation :prepare_contact_attributes
after_create_commit :dispatch_create_event, :ip_lookup
after_update_commit :dispatch_update_event
after_destroy_commit :dispatch_destroy_event
before_save :sync_contact_attributes
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
scope :order_on_last_activity_at, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order("\"contacts\".\"last_activity_at\" #{direction}
NULLS LAST")
)
)
}
scope :order_on_created_at, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order("\"contacts\".\"created_at\" #{direction}
NULLS LAST")
)
)
}
scope :order_on_company_name, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order(
"\"contacts\".\"additional_attributes\"->>'company_name' #{direction}
NULLS LAST"
)
)
)
}
scope :order_on_city, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order(
"\"contacts\".\"additional_attributes\"->>'city' #{direction}
NULLS LAST"
)
)
)
}
scope :order_on_country_name, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order(
"\"contacts\".\"additional_attributes\"->>'country' #{direction}
NULLS LAST"
)
)
)
}
scope :order_on_name, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order(
"CASE
WHEN \"contacts\".\"name\" ~~* '^+\d*' THEN 'z'
WHEN \"contacts\".\"name\" ~~* '^\b*' THEN 'z'
ELSE LOWER(\"contacts\".\"name\")
END #{direction}"
)
)
)
}
# Find contacts that:
# 1. Have no identification (email, phone_number, and identifier are NULL or empty string)
# 2. Have no conversations
# 3. Are older than the specified time period
scope :stale_without_conversations, lambda { |time_period|
where('contacts.email IS NULL OR contacts.email = ?', '')
.where('contacts.phone_number IS NULL OR contacts.phone_number = ?', '')
.where('contacts.identifier IS NULL OR contacts.identifier = ?', '')
.where('contacts.created_at < ?', time_period)
.where.missing(:conversations)
}
def get_source_id(inbox_id)
contact_inboxes.find_by!(inbox_id: inbox_id).source_id
end
def push_event_data
data = {
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
email: email,
id: id,
identifier: identifier,
name: name,
phone_number: phone_number,
thumbnail: avatar_url,
blocked: blocked,
type: 'contact'
}
data[:company_id] = company_id if account.feature_enabled?('companies')
data
end
def webhook_data
{
account: account.webhook_data,
additional_attributes: additional_attributes,
avatar: avatar_url,
custom_attributes: custom_attributes,
email: email,
id: id,
identifier: identifier,
name: name,
phone_number: phone_number,
thumbnail: avatar_url,
blocked: blocked
}
end
def self.resolved_contacts(use_crm_v2: false)
return where(contact_type: 'lead') if use_crm_v2
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
end
def discard_invalid_attrs
phone_number_format
email_format
end
def self.from_email(email)
find_by(email: email&.downcase)
end
private
def ip_lookup
return unless account.feature_enabled?('ip_lookup')
ContactIpLookupJob.perform_later(self)
end
def phone_number_format
return if phone_number.blank?
self.phone_number = phone_number_was unless phone_number.match?(/\+[1-9]\d{1,14}\z/)
end
def email_format
return if email.blank?
self.email = email_was unless email.match(Devise.email_regexp)
end
def prepare_contact_attributes
prepare_email_attribute
prepare_jsonb_attributes
end
def prepare_email_attribute
# So that the db unique constraint won't throw error when email is ''
self.email = email.present? ? email.downcase : nil
end
def prepare_jsonb_attributes
self.additional_attributes = {} if additional_attributes.blank?
self.custom_attributes = {} if custom_attributes.blank?
end
def sync_contact_attributes
::Contacts::SyncAttributes.new(self).perform
end
def dispatch_create_event
Rails.configuration.dispatcher.dispatch(CONTACT_CREATED, Time.zone.now, contact: self)
end
def dispatch_update_event
Rails.configuration.dispatcher.dispatch(CONTACT_UPDATED, Time.zone.now, contact: self, changed_attributes: previous_changes)
end
def dispatch_destroy_event
# Pass serialized data instead of ActiveRecord object to avoid DeserializationError
# when the async EventDispatcherJob runs after the contact has been deleted
Rails.configuration.dispatcher.dispatch(
CONTACT_DELETED,
Time.zone.now,
contact_data: push_event_data.merge(account_id: account_id)
)
end
end
Contact.include_mod_with('Concerns::Contact')