Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
module Enterprise::Account
|
||||
# TODO: Remove this when we upgrade administrate gem to the latest version
|
||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||
def manually_managed_features; end
|
||||
|
||||
# Auto-sync advanced_assignment with assignment_v2 when features are bulk-updated via admin UI
|
||||
def selected_feature_flags=(features)
|
||||
super
|
||||
sync_assignment_features
|
||||
end
|
||||
|
||||
def mark_for_deletion(reason = 'manual_deletion')
|
||||
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
|
||||
|
||||
result = custom_attributes.merge!(
|
||||
'marked_for_deletion_at' => 7.days.from_now.iso8601,
|
||||
'marked_for_deletion_reason' => reason
|
||||
) && save
|
||||
|
||||
# Send notification to admin users if the account was successfully marked for deletion
|
||||
if result
|
||||
mailer = AdministratorNotifications::AccountNotificationMailer.with(account: self)
|
||||
if reason == 'manual_deletion'
|
||||
mailer.account_deletion_user_initiated(self, reason).deliver_later
|
||||
else
|
||||
mailer.account_deletion_for_inactivity(self, reason).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def unmark_for_deletion
|
||||
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
|
||||
end
|
||||
|
||||
def saml_enabled?
|
||||
saml_settings&.saml_enabled? || false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_assignment_features
|
||||
if feature_enabled?('assignment_v2')
|
||||
# Enable advanced_assignment for Business/Enterprise plans
|
||||
send('feature_advanced_assignment=', true) if business_or_enterprise_plan?
|
||||
else
|
||||
# Disable advanced_assignment when assignment_v2 is disabled
|
||||
send('feature_advanced_assignment=', false)
|
||||
end
|
||||
end
|
||||
|
||||
def business_or_enterprise_plan?
|
||||
plan_name = custom_attributes['plan_name']
|
||||
%w[Business Enterprise].include?(plan_name)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,152 @@
|
||||
module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleLength
|
||||
CAPTAIN_RESPONSES = 'captain_responses'.freeze
|
||||
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
|
||||
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
|
||||
CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
|
||||
|
||||
def usage_limits
|
||||
{
|
||||
agents: agent_limits.to_i,
|
||||
inboxes: get_limits(:inboxes).to_i,
|
||||
captain: {
|
||||
documents: get_captain_limits(:documents),
|
||||
responses: get_captain_limits(:responses)
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def increment_response_usage
|
||||
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
|
||||
save
|
||||
end
|
||||
|
||||
def reset_response_usage
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE] = 0
|
||||
save
|
||||
end
|
||||
|
||||
def update_document_usage
|
||||
# this will ensure that the document count is always accurate
|
||||
custom_attributes[CAPTAIN_DOCUMENTS_USAGE] = captain_documents.count
|
||||
save
|
||||
end
|
||||
|
||||
def email_transcript_enabled?
|
||||
default_plan = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')&.value&.first
|
||||
return true if default_plan.blank?
|
||||
|
||||
plan_name.present? && plan_name != default_plan['name']
|
||||
end
|
||||
|
||||
def email_rate_limit
|
||||
account_limit || plan_email_limit || global_limit || default_limit
|
||||
end
|
||||
|
||||
def subscribed_features
|
||||
plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value
|
||||
return [] if plan_features.blank?
|
||||
|
||||
plan_features[plan_name]
|
||||
end
|
||||
|
||||
def captain_monthly_limit
|
||||
default_limits = default_captain_limits
|
||||
|
||||
{
|
||||
documents: self[:limits][CAPTAIN_DOCUMENTS] || default_limits['documents'],
|
||||
responses: self[:limits][CAPTAIN_RESPONSES] || default_limits['responses']
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_captain_limits(type)
|
||||
total_count = captain_monthly_limit[type.to_s].to_i
|
||||
|
||||
consumed = if type == :documents
|
||||
custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0
|
||||
else
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
||||
end
|
||||
|
||||
consumed = 0 if consumed.negative?
|
||||
|
||||
{
|
||||
total_count: total_count,
|
||||
current_available: (total_count - consumed).clamp(0, total_count),
|
||||
consumed: consumed
|
||||
}
|
||||
end
|
||||
|
||||
def plan_email_limit
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value
|
||||
return nil if config.blank? || plan_name.blank?
|
||||
|
||||
parsed = config.is_a?(String) ? JSON.parse(config) : config
|
||||
parsed[plan_name.downcase]&.to_i
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def default_captain_limits
|
||||
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
|
||||
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
|
||||
plan_quota = InstallationConfig.find_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')&.value
|
||||
|
||||
# If there are no limits configured, we allow max usage
|
||||
return max_limits if plan_quota.blank?
|
||||
|
||||
# if there is plan_quota configred, but plan_name is not present, we return zero limits
|
||||
return zero_limits if plan_name.blank?
|
||||
|
||||
begin
|
||||
# Now we parse the plan_quota and return the limits for the plan name
|
||||
# but if there's no plan_name present in the plan_quota, we return zero limits
|
||||
plan_quota = JSON.parse(plan_quota) if plan_quota.present?
|
||||
plan_quota[plan_name.downcase] || zero_limits
|
||||
rescue StandardError
|
||||
# if there's any error in parsing the plan_quota, we return max limits
|
||||
# this is to ensure that we don't block the user from using the product
|
||||
max_limits
|
||||
end
|
||||
end
|
||||
|
||||
def plan_name
|
||||
custom_attributes['plan_name']
|
||||
end
|
||||
|
||||
def agent_limits
|
||||
subscribed_quantity = custom_attributes['subscribed_quantity']
|
||||
subscribed_quantity || get_limits(:agents)
|
||||
end
|
||||
|
||||
def get_limits(limit_name)
|
||||
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
|
||||
return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present?
|
||||
|
||||
return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present?
|
||||
|
||||
ChatwootApp.max_limit
|
||||
end
|
||||
|
||||
def validate_limit_keys
|
||||
errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash
|
||||
self[:limits] = {} if self[:limits].blank?
|
||||
|
||||
limit_schema = {
|
||||
'type' => 'object',
|
||||
'properties' => {
|
||||
'inboxes' => { 'type': 'number' },
|
||||
'agents' => { 'type': 'number' },
|
||||
'captain_responses' => { 'type': 'number' },
|
||||
'captain_documents' => { 'type': 'number' },
|
||||
'emails' => { 'type': 'number' }
|
||||
},
|
||||
'required' => [],
|
||||
'additionalProperties' => false
|
||||
}
|
||||
|
||||
errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(limit_schema).valid?(self[:limits])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
module Enterprise::AccountUser
|
||||
def permissions
|
||||
custom_role.present? ? (custom_role.permissions + ['custom_role']) : super
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
module Enterprise::ActivityMessageHandler
|
||||
def automation_status_change_activity_content
|
||||
return super unless Current.executed_by.instance_of?(Captain::Assistant)
|
||||
|
||||
locale = Current.executed_by.account.locale
|
||||
key = captain_activity_key
|
||||
return unless key
|
||||
|
||||
I18n.t(key, user_name: Current.executed_by.name, reason: Current.captain_resolve_reason, locale: locale)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_activity_key
|
||||
if resolved? && Current.captain_resolve_reason.present?
|
||||
'conversations.activity.captain.resolved_by_tool'
|
||||
elsif resolved?
|
||||
'conversations.activity.captain.resolved'
|
||||
elsif open?
|
||||
'conversations.activity.captain.open'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
module Enterprise::ApplicationRecord
|
||||
def droppables
|
||||
super + %w[SlaPolicy]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
module Enterprise::Audit::Account
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited except: :updated_at, on: [:update]
|
||||
has_associated_audits
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
module Enterprise::Audit::AccountUser
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited only: [
|
||||
:availability,
|
||||
:role,
|
||||
:account_id,
|
||||
:inviter_id,
|
||||
:user_id
|
||||
], on: [:create, :update], associated_with: :account
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::AutomationRule
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::Conversation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited only: [], on: [:destroy]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::Inbox
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account, on: [:create, :update]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module Enterprise::Audit::InboxMember
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_commit :create_audit_log_entry_on_create, on: :create
|
||||
after_commit :create_audit_log_entry_on_delete, on: :destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_audit_log_entry_on_create
|
||||
create_audit_log_entry('create')
|
||||
end
|
||||
|
||||
def create_audit_log_entry_on_delete
|
||||
create_audit_log_entry('destroy')
|
||||
end
|
||||
|
||||
def create_audit_log_entry(action)
|
||||
return if inbox.blank?
|
||||
|
||||
Enterprise::AuditLog.create(
|
||||
auditable_id: id,
|
||||
auditable_type: 'InboxMember',
|
||||
action: action,
|
||||
associated_id: inbox&.account_id,
|
||||
audited_changes: attributes.except('updated_at', 'created_at'),
|
||||
associated_type: 'Account'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::Macro
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::Team
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module Enterprise::Audit::TeamMember
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_commit :create_audit_log_entry_on_create, on: :create
|
||||
after_commit :create_audit_log_entry_on_delete, on: :destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_audit_log_entry_on_create
|
||||
create_audit_log_entry('create')
|
||||
end
|
||||
|
||||
def create_audit_log_entry_on_delete
|
||||
create_audit_log_entry('destroy')
|
||||
end
|
||||
|
||||
def create_audit_log_entry(action)
|
||||
return if team.blank?
|
||||
|
||||
Enterprise::AuditLog.create(
|
||||
auditable_id: id,
|
||||
auditable_type: 'TeamMember',
|
||||
action: action,
|
||||
associated_id: team&.account_id,
|
||||
audited_changes: attributes.except('updated_at', 'created_at'),
|
||||
associated_type: 'Account'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
module Enterprise::Audit::User
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# required only for sign_in and sign_out events, which we are logging manually
|
||||
# hence the proc that always returns false
|
||||
audited only: [
|
||||
:availability,
|
||||
:display_name,
|
||||
:email,
|
||||
:name
|
||||
], unless: proc { |_u| true }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Audit::Webhook
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: audits
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# action :string
|
||||
# associated_type :string
|
||||
# auditable_type :string
|
||||
# audited_changes :jsonb
|
||||
# comment :string
|
||||
# remote_address :string
|
||||
# request_uuid :string
|
||||
# user_type :string
|
||||
# username :string
|
||||
# version :integer default(0)
|
||||
# created_at :datetime
|
||||
# associated_id :bigint
|
||||
# auditable_id :bigint
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# associated_index (associated_type,associated_id)
|
||||
# auditable_index (auditable_type,auditable_id,version)
|
||||
# index_audits_on_created_at (created_at)
|
||||
# index_audits_on_request_uuid (request_uuid)
|
||||
# user_index (user_id,user_type)
|
||||
#
|
||||
class Enterprise::AuditLog < Audited::Audit
|
||||
after_save :log_additional_information
|
||||
|
||||
private
|
||||
|
||||
def log_additional_information
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
if auditable_type == 'Account' && auditable_id.present?
|
||||
update_columns(associated_type: auditable_type, associated_id: auditable_id, username: user&.email)
|
||||
else
|
||||
update_columns(username: user&.email)
|
||||
end
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module Enterprise::AutomationRule
|
||||
def conditions_attributes
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
def actions_attributes
|
||||
super + %w[add_sla]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
module Enterprise::Channelable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Active support concern has `included` which changes the order of the method lookup chain
|
||||
# https://stackoverflow.com/q/40061982/3824876
|
||||
# manually prepend the instance methods to combat this
|
||||
included do
|
||||
prepend InstanceMethods
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def create_audit_log_entry
|
||||
account = self.account
|
||||
associated_type = 'Account'
|
||||
|
||||
return if inbox.nil?
|
||||
|
||||
auditable_id = inbox.id
|
||||
auditable_type = 'Inbox'
|
||||
audited_changes = saved_changes.except('updated_at')
|
||||
|
||||
return if audited_changes.blank?
|
||||
|
||||
# skip audit log creation if the only change is whatsapp channel template update
|
||||
return if messaging_template_updates?(audited_changes)
|
||||
|
||||
Enterprise::AuditLog.create(
|
||||
auditable_id: auditable_id,
|
||||
auditable_type: auditable_type,
|
||||
action: 'update',
|
||||
associated_id: account.id,
|
||||
associated_type: associated_type,
|
||||
audited_changes: audited_changes
|
||||
)
|
||||
end
|
||||
|
||||
def messaging_template_updates?(changes)
|
||||
# if there is more than one key, return false
|
||||
return false unless changes.keys.length == 1
|
||||
|
||||
# if the only key is message_templates_last_updated, return true
|
||||
changes.key?('message_templates_last_updated')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
module Enterprise::Concerns::Account
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
store_accessor :settings, :conversation_required_attributes
|
||||
|
||||
has_many :sla_policies, dependent: :destroy_async
|
||||
has_many :applied_slas, dependent: :destroy_async
|
||||
has_many :custom_roles, dependent: :destroy_async
|
||||
has_many :agent_capacity_policies, dependent: :destroy_async
|
||||
|
||||
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||
|
||||
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
module Enterprise::Concerns::AccountUser
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
belongs_to :custom_role, optional: true
|
||||
belongs_to :agent_capacity_policy, optional: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
module Enterprise::Concerns::Article
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_save :add_article_embedding, if: -> { saved_change_to_title? || saved_change_to_description? || saved_change_to_content? }
|
||||
|
||||
def self.add_article_embedding_association
|
||||
has_many :article_embeddings, dependent: :destroy_async
|
||||
end
|
||||
|
||||
add_article_embedding_association
|
||||
|
||||
def self.vector_search(params)
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: params[:account_id]).get_embedding(params['query'])
|
||||
records = joins(
|
||||
:category
|
||||
).search_by_category_slug(
|
||||
params[:category_slug]
|
||||
).search_by_category_locale(params[:locale]).search_by_author(params[:author_id]).search_by_status(params[:status])
|
||||
filtered_article_ids = records.pluck(:id)
|
||||
|
||||
# Fetch nearest neighbors and their distances, then filter directly
|
||||
|
||||
# experimenting with filtering results based on result threshold
|
||||
# distance_threshold = 0.2
|
||||
# if using add the filter block to the below query
|
||||
# .filter { |ae| ae.neighbor_distance <= distance_threshold }
|
||||
|
||||
article_ids = ArticleEmbedding.where(article_id: filtered_article_ids)
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
.limit(5)
|
||||
.pluck(:article_id)
|
||||
|
||||
# Fetch the articles by the IDs obtained from the nearest neighbors search
|
||||
where(id: article_ids)
|
||||
end
|
||||
end
|
||||
|
||||
def add_article_embedding
|
||||
return unless account.feature_enabled?('help_center_embedding_search')
|
||||
|
||||
Portal::ArticleIndexingJob.perform_later(self)
|
||||
end
|
||||
|
||||
def generate_and_save_article_seach_terms
|
||||
terms = generate_article_search_terms
|
||||
article_embeddings.destroy_all
|
||||
terms.each { |term| article_embeddings.create!(term: term) }
|
||||
end
|
||||
|
||||
def article_to_search_terms_prompt
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
For the provided article content, generate potential search query keywords and snippets that can be used to generate the embeddings.
|
||||
Ensure the search terms are as diverse as possible but capture the essence of the article and are super related to the articles.
|
||||
Don't return any terms if there aren't any terms of relevance.
|
||||
Always return results in valid JSON of the following format
|
||||
{
|
||||
"search_terms": []
|
||||
}
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def generate_article_search_terms
|
||||
messages = [
|
||||
{ role: 'system', content: article_to_search_terms_prompt },
|
||||
{ role: 'user', content: "title: #{title} \n description: #{description} \n content: #{content}" }
|
||||
]
|
||||
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', nil)}" }
|
||||
body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json
|
||||
Rails.logger.info "Requesting Chat GPT with body: #{body}"
|
||||
response = HTTParty.post(openai_api_url, headers: headers, body: body)
|
||||
Rails.logger.info "Chat GPT response: #{response.body}"
|
||||
JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms']
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def openai_api_url
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
|
||||
endpoint = endpoint.chomp('/')
|
||||
"#{endpoint}/v1/chat/completions"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Concerns::AssignmentPolicy
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
enum assignment_order: { round_robin: 0, balanced: 1 } if ChatwootApp.enterprise?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
module Enterprise::Concerns::Attachment
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_create_commit :enqueue_audio_transcription
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_audio_transcription
|
||||
return unless file_type.to_sym == :audio
|
||||
|
||||
Messages::AudioTranscriptionJob.perform_later(id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module Enterprise::Concerns::Contact
|
||||
extend ActiveSupport::Concern
|
||||
included do
|
||||
belongs_to :company, optional: true, counter_cache: true
|
||||
|
||||
after_commit :associate_company_from_email,
|
||||
on: [:create, :update],
|
||||
if: :should_associate_company?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_associate_company?
|
||||
# Only trigger if:
|
||||
# 1. Contact has an email
|
||||
# 2. Contact doesn't have a compan yet
|
||||
# 3. Email was just set/changed
|
||||
# 4. Email was previously nil (first time getting email)
|
||||
email.present? &&
|
||||
company_id.nil? &&
|
||||
saved_change_to_email? &&
|
||||
saved_change_to_email.first.nil?
|
||||
end
|
||||
|
||||
def associate_company_from_email
|
||||
Contacts::CompanyAssociationService.new.associate_company_from_email(self)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to associate company for contact #{id}: #{e.message}")
|
||||
# Don't fail the contact save if the company association fails
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
module Enterprise::Concerns::Conversation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
belongs_to :sla_policy, optional: true
|
||||
has_one :applied_sla, dependent: :destroy_async
|
||||
has_many :sla_events, dependent: :destroy_async
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
|
||||
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_sla_policy
|
||||
# TODO: remove these validations once we figure out how to deal with these cases
|
||||
if sla_policy_id.nil? && changes[:sla_policy_id].first.present?
|
||||
errors.add(:sla_policy, 'cannot remove sla policy from conversation')
|
||||
return
|
||||
end
|
||||
|
||||
if changes[:sla_policy_id].first.present?
|
||||
errors.add(:sla_policy, 'conversation already has a different sla')
|
||||
return
|
||||
end
|
||||
|
||||
errors.add(:sla_policy, 'sla policy account mismatch') if sla_policy&.account_id != account_id
|
||||
end
|
||||
|
||||
# handling inside a transaction to ensure applied sla record is also created
|
||||
def ensure_applied_sla_is_created
|
||||
ActiveRecord::Base.transaction do
|
||||
yield
|
||||
create_applied_sla(sla_policy_id: sla_policy_id) if applied_sla.blank?
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module Enterprise::Concerns::CustomAttributeDefinition
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_destroy :cleanup_conversation_required_attributes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cleanup_conversation_required_attributes
|
||||
return unless conversation_attribute? && account.conversation_required_attributes&.include?(attribute_key)
|
||||
|
||||
account.conversation_required_attributes = account.conversation_required_attributes - [attribute_key]
|
||||
account.save!
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
module Enterprise::Concerns::Inbox
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_one :captain_inbox, dependent: :destroy, class_name: 'CaptainInbox'
|
||||
has_one :captain_assistant,
|
||||
through: :captain_inbox,
|
||||
class_name: 'Captain::Assistant'
|
||||
has_many :inbox_capacity_limits, dependent: :destroy
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
module Enterprise::Concerns::Portal
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_save :enqueue_cloudflare_verification, if: :saved_change_to_custom_domain?
|
||||
end
|
||||
|
||||
def enqueue_cloudflare_verification
|
||||
return if custom_domain.blank?
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
Enterprise::CloudflareVerificationJob.perform_later(id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module Enterprise::Concerns::User
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_validation :ensure_installation_pricing_plan_quantity, on: :create
|
||||
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
end
|
||||
|
||||
def ensure_installation_pricing_plan_quantity
|
||||
return unless ChatwootHub.pricing_plan == 'premium'
|
||||
|
||||
errors.add(:base, 'User limit reached. Please purchase more licenses from super admin') if User.count >= ChatwootHub.pricing_plan_quantity
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module Enterprise::Conversation
|
||||
def list_of_keys
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
# Include select additional_attributes keys (call related) for update events
|
||||
def allowed_keys?
|
||||
return true if super
|
||||
|
||||
attrs_change = previous_changes['additional_attributes']
|
||||
return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
|
||||
|
||||
changed_attr_keys = attrs_change[1].keys
|
||||
changed_attr_keys.intersect?(%w[call_status])
|
||||
end
|
||||
end
|
||||
40
research/chatwoot/enterprise/app/models/enterprise/inbox.rb
Normal file
40
research/chatwoot/enterprise/app/models/enterprise/inbox.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
module Enterprise::Inbox
|
||||
def member_ids_with_assignment_capacity
|
||||
return super unless enable_auto_assignment?
|
||||
|
||||
max_assignment_limit = auto_assignment_config['max_assignment_limit']
|
||||
overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : []
|
||||
super - overloaded_agent_ids
|
||||
end
|
||||
|
||||
def active_bot?
|
||||
super || captain_active?
|
||||
end
|
||||
|
||||
def captain_active?
|
||||
captain_assistant.present? && more_responses?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def more_responses?
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def get_agent_ids_over_assignment_limit(limit)
|
||||
conversations
|
||||
.open
|
||||
.where(account_id: account_id)
|
||||
.select(:assignee_id)
|
||||
.group(:assignee_id)
|
||||
.having("count(*) >= #{limit.to_i}")
|
||||
.filter_map(&:assignee_id)
|
||||
end
|
||||
|
||||
def ensure_valid_max_assignment_limit
|
||||
return if auto_assignment_config['max_assignment_limit'].blank?
|
||||
return if auto_assignment_config['max_assignment_limit'].to_i.positive?
|
||||
|
||||
errors.add(:auto_assignment_config, 'max_assignment_limit must be greater than 0')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module Enterprise::InboxAgentAvailability
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def member_ids_with_assignment_capacity
|
||||
return member_ids unless capacity_filtering_enabled?
|
||||
|
||||
# Get online agents with capacity
|
||||
agents = available_agents
|
||||
agents = filter_by_capacity(agents)
|
||||
agents.map(&:user_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless capacity_filtering_enabled?
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
capacity_service.agent_has_capacity?(inbox_member.user, self)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_filtering_enabled?
|
||||
account.feature_enabled?('assignment_v2') &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def capacity_service
|
||||
@capacity_service ||= Enterprise::AutoAssignment::CapacityService.new
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user