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>
228 lines
7.1 KiB
Ruby
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
|