Files
leadchat/app/models/contact.rb
Pranav d070743383 feat(ee): Add Captain features (#10665)
Migration Guide: https://chwt.app/v4/migration

This PR imports all the work related to Captain into the EE codebase. Captain represents the AI-based features in Chatwoot and includes the following key components:

- Assistant: An assistant has a persona, the product it would be trained on. At the moment, the data at which it is trained is from websites. Future integrations on Notion documents, PDF etc. This PR enables connecting an assistant to an inbox. The assistant would run the conversation every time before transferring it to an agent.
- Copilot for Agents: When an agent is supporting a customer, we will be able to offer additional help to lookup some data or fetch information from integrations etc via copilot.
- Conversation FAQ generator: When a conversation is resolved, the Captain integration would identify questions which were not in the knowledge base.
- CRM memory: Learns from the conversations and identifies important information about the contact.

---------

Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-01-14 16:15:47 -08:00

228 lines
7.1 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
#
# Indexes
#
# index_contacts_on_account_id (account_id)
# 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_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}"
)
)
)
}
def get_source_id(inbox_id)
contact_inboxes.find_by!(inbox_id: inbox_id).source_id
end
def push_event_data
{
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
email: email,
id: id,
identifier: identifier,
name: name,
phone_number: phone_number,
thumbnail: avatar_url,
type: 'contact'
}
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
}
end
def self.resolved_contacts
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
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
end
end