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>
256 lines
8.3 KiB
Ruby
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')
|