Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
- Add Logistics component with progress tracking - Add OrderDetail component for order information - Support data-driven steps and actions - Add blue color scale to widget SCSS - Fix node overflow and progress bar rendering issues - Add English translations for dashboard components Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
0
app/models/concerns/.keep
Normal file
0
app/models/concerns/.keep
Normal file
11
app/models/concerns/access_tokenable.rb
Normal file
11
app/models/concerns/access_tokenable.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
module AccessTokenable
|
||||
extend ActiveSupport::Concern
|
||||
included do
|
||||
has_one :access_token, as: :owner, dependent: :destroy_async
|
||||
after_create :create_access_token
|
||||
end
|
||||
|
||||
def create_access_token
|
||||
AccessToken.create!(owner: self)
|
||||
end
|
||||
end
|
||||
11
app/models/concerns/account_cache_revalidator.rb
Normal file
11
app/models/concerns/account_cache_revalidator.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
module AccountCacheRevalidator
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_commit :update_account_cache, on: [:create, :update, :destroy]
|
||||
end
|
||||
|
||||
def update_account_cache
|
||||
account.update_cache_key(self.class.name.underscore)
|
||||
end
|
||||
end
|
||||
130
app/models/concerns/activity_message_handler.rb
Normal file
130
app/models/concerns/activity_message_handler.rb
Normal file
@@ -0,0 +1,130 @@
|
||||
module ActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include PriorityActivityMessageHandler
|
||||
include LabelActivityMessageHandler
|
||||
include SlaActivityMessageHandler
|
||||
include TeamActivityMessageHandler
|
||||
|
||||
private
|
||||
|
||||
def create_activity
|
||||
user_name = determine_user_name
|
||||
|
||||
handle_status_change(user_name)
|
||||
handle_priority_change(user_name)
|
||||
handle_label_change(user_name)
|
||||
handle_sla_policy_change(user_name)
|
||||
end
|
||||
|
||||
def determine_user_name
|
||||
Current.user&.name
|
||||
end
|
||||
|
||||
def handle_status_change(user_name)
|
||||
return unless saved_change_to_status?
|
||||
|
||||
status_change_activity(user_name)
|
||||
end
|
||||
|
||||
def handle_priority_change(user_name)
|
||||
return unless saved_change_to_priority?
|
||||
|
||||
priority_change_activity(user_name)
|
||||
end
|
||||
|
||||
def handle_label_change(user_name)
|
||||
return unless saved_change_to_label_list?
|
||||
|
||||
create_label_change(activity_message_owner(user_name))
|
||||
end
|
||||
|
||||
def handle_sla_policy_change(user_name)
|
||||
return unless saved_change_to_sla_policy_id?
|
||||
|
||||
sla_change_type = determine_sla_change_type
|
||||
create_sla_change_activity(sla_change_type, activity_message_owner(user_name))
|
||||
end
|
||||
|
||||
def status_change_activity(user_name)
|
||||
content = if Current.executed_by.present?
|
||||
automation_status_change_activity_content
|
||||
else
|
||||
user_status_change_activity_content(user_name)
|
||||
end
|
||||
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def auto_resolve_message_key(minutes)
|
||||
if minutes >= 1440 && (minutes % 1440).zero?
|
||||
{ key: 'auto_resolved_days', count: minutes / 1440 }
|
||||
elsif minutes >= 60 && (minutes % 60).zero?
|
||||
{ key: 'auto_resolved_hours', count: minutes / 60 }
|
||||
else
|
||||
{ key: 'auto_resolved_minutes', count: minutes }
|
||||
end
|
||||
end
|
||||
|
||||
def user_status_change_activity_content(user_name)
|
||||
if user_name
|
||||
I18n.t("conversations.activity.status.#{status}", user_name: user_name)
|
||||
elsif Current.contact.present? && resolved?
|
||||
I18n.t('conversations.activity.status.contact_resolved', contact_name: Current.contact.name.capitalize)
|
||||
elsif resolved?
|
||||
message_data = auto_resolve_message_key(auto_resolve_after || 0)
|
||||
I18n.t("conversations.activity.status.#{message_data[:key]}", count: message_data[:count])
|
||||
end
|
||||
end
|
||||
|
||||
def automation_status_change_activity_content
|
||||
if Current.executed_by.instance_of?(AutomationRule)
|
||||
I18n.t("conversations.activity.status.#{status}", user_name: I18n.t('automation.system_name'))
|
||||
elsif Current.executed_by.instance_of?(Contact)
|
||||
Current.executed_by = nil
|
||||
I18n.t('conversations.activity.status.system_auto_open')
|
||||
end
|
||||
end
|
||||
|
||||
def activity_message_params(content)
|
||||
{ account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content }
|
||||
end
|
||||
|
||||
def create_muted_message
|
||||
create_mute_change_activity('muted')
|
||||
end
|
||||
|
||||
def create_unmuted_message
|
||||
create_mute_change_activity('unmuted')
|
||||
end
|
||||
|
||||
def create_mute_change_activity(change_type)
|
||||
return unless Current.user
|
||||
|
||||
content = I18n.t("conversations.activity.#{change_type}", user_name: Current.user.name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_assignee_change_activity_content(user_name)
|
||||
params = { assignee_name: assignee&.name || '', user_name: user_name }
|
||||
key = assignee_id ? 'assigned' : 'removed'
|
||||
key = 'self_assigned' if self_assign? assignee_id
|
||||
I18n.t("conversations.activity.assignee.#{key}", **params)
|
||||
end
|
||||
|
||||
def create_assignee_change_activity(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
|
||||
return unless user_name
|
||||
|
||||
content = generate_assignee_change_activity_content(user_name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def activity_message_owner(user_name)
|
||||
user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present?
|
||||
user_name
|
||||
end
|
||||
end
|
||||
|
||||
ActivityMessageHandler.prepend_mod_with('ActivityMessageHandler')
|
||||
55
app/models/concerns/assignment_handler.rb
Normal file
55
app/models/concerns/assignment_handler.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
module AssignmentHandler
|
||||
extend ActiveSupport::Concern
|
||||
include Events::Types
|
||||
|
||||
included do
|
||||
before_save :ensure_assignee_is_from_team
|
||||
after_commit :notify_assignment_change, :process_assignment_changes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_assignee_is_from_team
|
||||
return unless team_id_changed?
|
||||
|
||||
validate_current_assignee_team
|
||||
self.assignee ||= find_assignee_from_team
|
||||
end
|
||||
|
||||
def validate_current_assignee_team
|
||||
self.assignee_id = nil if team&.members&.exclude?(assignee)
|
||||
end
|
||||
|
||||
def find_assignee_from_team
|
||||
return if team&.allow_auto_assign.blank?
|
||||
|
||||
team_members_with_capacity = inbox.member_ids_with_assignment_capacity & team.members.ids
|
||||
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: team_members_with_capacity).find_assignee
|
||||
end
|
||||
|
||||
def notify_assignment_change
|
||||
{
|
||||
ASSIGNEE_CHANGED => -> { saved_change_to_assignee_id? },
|
||||
TEAM_CHANGED => -> { saved_change_to_team_id? }
|
||||
}.each do |event, condition|
|
||||
condition.call && dispatcher_dispatch(event, previous_changes)
|
||||
end
|
||||
end
|
||||
|
||||
def process_assignment_changes
|
||||
process_assignment_activities
|
||||
end
|
||||
|
||||
def process_assignment_activities
|
||||
user_name = Current.user.name if Current.user.present?
|
||||
if saved_change_to_team_id?
|
||||
create_team_change_activity(user_name)
|
||||
elsif saved_change_to_assignee_id?
|
||||
create_assignee_change_activity(user_name)
|
||||
end
|
||||
end
|
||||
|
||||
def self_assign?(assignee_id)
|
||||
assignee_id.present? && Current.user&.id == assignee_id
|
||||
end
|
||||
end
|
||||
32
app/models/concerns/auto_assignment_handler.rb
Normal file
32
app/models/concerns/auto_assignment_handler.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
module AutoAssignmentHandler
|
||||
extend ActiveSupport::Concern
|
||||
include Events::Types
|
||||
|
||||
included do
|
||||
after_save :run_auto_assignment
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run_auto_assignment
|
||||
# Round robin kicks in on conversation create & update
|
||||
# run it only when conversation status changes to open
|
||||
return unless conversation_status_changed_to_open?
|
||||
return unless should_run_auto_assignment?
|
||||
|
||||
if inbox.auto_assignment_v2_enabled?
|
||||
# Use new assignment system
|
||||
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
|
||||
else
|
||||
# Use legacy assignment system
|
||||
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
|
||||
end
|
||||
end
|
||||
|
||||
def should_run_auto_assignment?
|
||||
return false unless inbox.enable_auto_assignment?
|
||||
|
||||
# run only if assignee is blank or doesn't have access to inbox
|
||||
assignee.blank? || inbox.members.exclude?(assignee)
|
||||
end
|
||||
end
|
||||
30
app/models/concerns/availability_statusable.rb
Normal file
30
app/models/concerns/availability_statusable.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
module AvailabilityStatusable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def online_presence?
|
||||
obj_id = is_a?(Contact) ? id : user_id
|
||||
::OnlineStatusTracker.get_presence(account_id, self.class.name, obj_id)
|
||||
end
|
||||
|
||||
def availability_status
|
||||
if is_a? Contact
|
||||
contact_availability_status
|
||||
else
|
||||
user_availability_status
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contact_availability_status
|
||||
online_presence? ? 'online' : 'offline'
|
||||
end
|
||||
|
||||
def user_availability_status
|
||||
# we are not considering presence in this case. Just returns the availability
|
||||
return availability unless auto_offline
|
||||
|
||||
# availability as a fallback in case the status is not present in redis
|
||||
online_presence? ? (::OnlineStatusTracker.get_status(account_id, user_id) || availability) : 'offline'
|
||||
end
|
||||
end
|
||||
36
app/models/concerns/avatarable.rb
Normal file
36
app/models/concerns/avatarable.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Avatarable
|
||||
extend ActiveSupport::Concern
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
included do
|
||||
has_one_attached :avatar
|
||||
validate :acceptable_avatar, if: -> { avatar.changed? }
|
||||
after_save :fetch_avatar_from_gravatar
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
return url_for(avatar.representation(resize_to_fill: [250, nil])) if avatar.attached? && avatar.representable?
|
||||
|
||||
''
|
||||
end
|
||||
|
||||
def fetch_avatar_from_gravatar
|
||||
return unless saved_changes.key?(:email)
|
||||
return if email.blank?
|
||||
|
||||
# Incase avatar_url is supplied, we don't want to fetch avatar from gravatar
|
||||
# So we will wait for it to be processed
|
||||
Avatar::AvatarFromGravatarJob.set(wait: 30.seconds).perform_later(self, email)
|
||||
end
|
||||
|
||||
def acceptable_avatar
|
||||
return unless avatar.attached?
|
||||
|
||||
errors.add(:avatar, 'is too big') if avatar.byte_size > 15.megabytes
|
||||
|
||||
acceptable_types = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||
errors.add(:avatar, 'filetype not supported') unless acceptable_types.include?(avatar.content_type)
|
||||
end
|
||||
end
|
||||
46
app/models/concerns/cache_keys.rb
Normal file
46
app/models/concerns/cache_keys.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
module CacheKeys
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include CacheKeysHelper
|
||||
include Events::Types
|
||||
|
||||
CACHE_KEYS_EXPIRY = 72.hours
|
||||
|
||||
included do
|
||||
class_attribute :cacheable_models
|
||||
self.cacheable_models = [Label, Inbox, Team]
|
||||
end
|
||||
|
||||
def cache_keys
|
||||
keys = {}
|
||||
self.class.cacheable_models.each do |model|
|
||||
keys[model.name.underscore.to_sym] = fetch_value_for_key(id, model.name.underscore)
|
||||
end
|
||||
|
||||
keys
|
||||
end
|
||||
|
||||
def update_cache_key(key)
|
||||
update_cache_key_for_account(id, key)
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
def reset_cache_keys
|
||||
self.class.cacheable_models.each do |model|
|
||||
update_cache_key_for_account(id, model.name.underscore)
|
||||
end
|
||||
|
||||
dispatch_cache_update_event
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_cache_key_for_account(account_id, key)
|
||||
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
|
||||
Redis::Alfred.setex(prefixed_cache_key, Time.now.utc.to_i, CACHE_KEYS_EXPIRY)
|
||||
end
|
||||
|
||||
def dispatch_cache_update_event
|
||||
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
|
||||
end
|
||||
end
|
||||
62
app/models/concerns/captain_featurable.rb
Normal file
62
app/models/concerns/captain_featurable.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module CaptainFeaturable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
validate :validate_captain_models
|
||||
|
||||
# Dynamically define accessor methods for each captain feature
|
||||
Llm::Models.feature_keys.each do |feature_key|
|
||||
# Define enabled? methods (e.g., captain_editor_enabled?)
|
||||
define_method("captain_#{feature_key}_enabled?") do
|
||||
captain_features_with_defaults[feature_key]
|
||||
end
|
||||
|
||||
# Define model accessor methods (e.g., captain_editor_model)
|
||||
define_method("captain_#{feature_key}_model") do
|
||||
captain_models_with_defaults[feature_key]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def captain_preferences
|
||||
{
|
||||
models: captain_models_with_defaults,
|
||||
features: captain_features_with_defaults
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_models_with_defaults
|
||||
stored_models = captain_models || {}
|
||||
Llm::Models.feature_keys.each_with_object({}) do |feature_key, result|
|
||||
stored_value = stored_models[feature_key]
|
||||
result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value)
|
||||
stored_value
|
||||
else
|
||||
Llm::Models.default_model_for(feature_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def captain_features_with_defaults
|
||||
stored_features = captain_features || {}
|
||||
Llm::Models.feature_keys.index_with do |feature_key|
|
||||
stored_features[feature_key] == true
|
||||
end
|
||||
end
|
||||
|
||||
def validate_captain_models
|
||||
return if captain_models.blank?
|
||||
|
||||
captain_models.each do |feature_key, model_name|
|
||||
next if model_name.blank?
|
||||
next if Llm::Models.valid_model_for?(feature_key, model_name)
|
||||
|
||||
allowed_models = Llm::Models.models_for(feature_key)
|
||||
errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}")
|
||||
end
|
||||
end
|
||||
end
|
||||
13
app/models/concerns/channelable.rb
Normal file
13
app/models/concerns/channelable.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Channelable
|
||||
extend ActiveSupport::Concern
|
||||
included do
|
||||
validates :account_id, presence: true
|
||||
belongs_to :account
|
||||
has_one :inbox, as: :channel, dependent: :destroy_async, touch: true
|
||||
after_update :create_audit_log_entry
|
||||
end
|
||||
|
||||
def create_audit_log_entry; end
|
||||
end
|
||||
|
||||
Channelable.prepend_mod_with('Channelable')
|
||||
52
app/models/concerns/content_attribute_validator.rb
Normal file
52
app/models/concerns/content_attribute_validator.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
class ContentAttributeValidator < ActiveModel::Validator
|
||||
ALLOWED_SELECT_ITEM_KEYS = [:title, :value].freeze
|
||||
ALLOWED_CARD_ITEM_KEYS = [:title, :description, :media_url, :actions].freeze
|
||||
ALLOWED_CARD_ITEM_ACTION_KEYS = [:text, :type, :payload, :uri].freeze
|
||||
ALLOWED_FORM_ITEM_KEYS = [:type, :placeholder, :label, :name, :options, :default, :required, :pattern, :title, :pattern_error].freeze
|
||||
ALLOWED_ARTICLE_KEYS = [:title, :description, :link].freeze
|
||||
|
||||
def validate(record)
|
||||
case record.content_type
|
||||
when 'input_select'
|
||||
validate_items!(record)
|
||||
validate_item_attributes!(record, ALLOWED_SELECT_ITEM_KEYS)
|
||||
when 'cards'
|
||||
validate_items!(record)
|
||||
validate_item_attributes!(record, ALLOWED_CARD_ITEM_KEYS)
|
||||
validate_item_actions!(record)
|
||||
when 'form'
|
||||
validate_items!(record)
|
||||
validate_item_attributes!(record, ALLOWED_FORM_ITEM_KEYS)
|
||||
when 'article'
|
||||
validate_items!(record)
|
||||
validate_item_attributes!(record, ALLOWED_ARTICLE_KEYS)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_items!(record)
|
||||
record.errors.add(:content_attributes, 'At least one item is required.') if record.items.blank?
|
||||
record.errors.add(:content_attributes, 'Items should be a hash.') if record.items.reject { |item| item.is_a?(Hash) }.present?
|
||||
end
|
||||
|
||||
def validate_item_attributes!(record, valid_keys)
|
||||
item_keys = record.items.collect(&:keys).flatten.filter_map(&:to_sym)
|
||||
invalid_keys = item_keys - valid_keys
|
||||
record.errors.add(:content_attributes, "contains invalid keys for items : #{invalid_keys}") if invalid_keys.present?
|
||||
end
|
||||
|
||||
def validate_item_actions!(record)
|
||||
if record.items.select { |item| item[:actions].blank? }.present?
|
||||
record.errors.add(:content_attributes, 'contains items missing actions') && return
|
||||
end
|
||||
|
||||
validate_item_action_attributes!(record)
|
||||
end
|
||||
|
||||
def validate_item_action_attributes!(record)
|
||||
item_action_keys = record.items.collect { |item| item[:actions].collect(&:keys) }
|
||||
invalid_keys = item_action_keys.flatten.compact.map(&:to_sym) - ALLOWED_CARD_ITEM_ACTION_KEYS
|
||||
record.errors.add(:content_attributes, "contains invalid keys for actions: #{invalid_keys}") if invalid_keys.present?
|
||||
end
|
||||
end
|
||||
22
app/models/concerns/conversation_mute_helpers.rb
Normal file
22
app/models/concerns/conversation_mute_helpers.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
module ConversationMuteHelpers
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def mute!
|
||||
return unless contact
|
||||
|
||||
resolved!
|
||||
contact.update(blocked: true)
|
||||
create_muted_message
|
||||
end
|
||||
|
||||
def unmute!
|
||||
return unless contact
|
||||
|
||||
contact.update(blocked: false)
|
||||
create_unmuted_message
|
||||
end
|
||||
|
||||
def muted?
|
||||
contact&.blocked? || false
|
||||
end
|
||||
end
|
||||
71
app/models/concerns/featurable.rb
Normal file
71
app/models/concerns/featurable.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
module Featurable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
QUERY_MODE = {
|
||||
flag_query_mode: :bit_operator,
|
||||
check_for_column: false
|
||||
}.freeze
|
||||
|
||||
FEATURE_LIST = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
|
||||
|
||||
FEATURES = FEATURE_LIST.each_with_object({}) do |feature, result|
|
||||
result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym
|
||||
end
|
||||
|
||||
included do
|
||||
include FlagShihTzu
|
||||
has_flags FEATURES.merge(column: 'feature_flags').merge(QUERY_MODE)
|
||||
|
||||
before_create :enable_default_features
|
||||
end
|
||||
|
||||
def enable_features(*names)
|
||||
names.each do |name|
|
||||
send("feature_#{name}=", true)
|
||||
end
|
||||
end
|
||||
|
||||
def enable_features!(*names)
|
||||
enable_features(*names)
|
||||
save
|
||||
end
|
||||
|
||||
def disable_features(*names)
|
||||
names.each do |name|
|
||||
send("feature_#{name}=", false)
|
||||
end
|
||||
end
|
||||
|
||||
def disable_features!(*names)
|
||||
disable_features(*names)
|
||||
save
|
||||
end
|
||||
|
||||
def feature_enabled?(name)
|
||||
send("feature_#{name}?")
|
||||
end
|
||||
|
||||
def all_features
|
||||
FEATURE_LIST.pluck('name').index_with do |feature_name|
|
||||
feature_enabled?(feature_name)
|
||||
end
|
||||
end
|
||||
|
||||
def enabled_features
|
||||
all_features.select { |_feature, enabled| enabled == true }
|
||||
end
|
||||
|
||||
def disabled_features
|
||||
all_features.select { |_feature, enabled| enabled == false }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enable_default_features
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
return true if config.blank?
|
||||
|
||||
features_to_enabled = config.value.select { |f| f[:enabled] }.pluck(:name)
|
||||
enable_features(*features_to_enabled)
|
||||
end
|
||||
end
|
||||
28
app/models/concerns/inbox_agent_availability.rb
Normal file
28
app/models/concerns/inbox_agent_availability.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module InboxAgentAvailability
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def available_agents
|
||||
online_agent_ids = fetch_online_agent_ids
|
||||
return inbox_members.none if online_agent_ids.empty?
|
||||
|
||||
inbox_members
|
||||
.joins(:user)
|
||||
.where(users: { id: online_agent_ids })
|
||||
.includes(:user)
|
||||
end
|
||||
|
||||
def member_ids_with_assignment_capacity
|
||||
member_ids
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_online_agent_ids
|
||||
OnlineStatusTracker.get_available_users(account_id)
|
||||
.select { |_key, value| value.eql?('online') }
|
||||
.keys
|
||||
.map(&:to_i)
|
||||
end
|
||||
end
|
||||
|
||||
InboxAgentAvailability.prepend_mod_with('InboxAgentAvailability')
|
||||
95
app/models/concerns/json_schema_validator.rb
Normal file
95
app/models/concerns/json_schema_validator.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
# This file defines a custom validator class `JsonSchemaValidator` for validating a JSON object against a schema.
|
||||
# To use this validator, define a schema as a Ruby hash and include it in the validation options when validating a model.
|
||||
# The schema should define the expected structure and types of the JSON object, as well as any validation rules.
|
||||
# Here's an example schema:
|
||||
#
|
||||
# schema = {
|
||||
# 'type' => 'object',
|
||||
# 'properties' => {
|
||||
# 'name' => { 'type' => 'string' },
|
||||
# 'age' => { 'type' => 'integer' },
|
||||
# 'is_active' => { 'type' => 'boolean' },
|
||||
# 'tags' => { 'type' => 'array' },
|
||||
# 'address' => {
|
||||
# 'type' => 'object',
|
||||
# 'properties' => {
|
||||
# 'street' => { 'type' => 'string' },
|
||||
# 'city' => { 'type' => 'string' }
|
||||
# },
|
||||
# 'required' => ['street', 'city']
|
||||
# }
|
||||
# },
|
||||
# 'required': ['name', 'age']
|
||||
# }.to_json.freeze
|
||||
#
|
||||
# To validate a model using this schema, include the `JsonSchemaValidator` in the model's validations and pass the schema
|
||||
# as an option:
|
||||
#
|
||||
# class MyModel < ApplicationRecord
|
||||
# validates_with JsonSchemaValidator, schema: schema
|
||||
# end
|
||||
|
||||
class JsonSchemaValidator < ActiveModel::Validator
|
||||
def validate(record)
|
||||
# Get the attribute resolver function from options or use a default one
|
||||
attribute_resolver = options[:attribute_resolver] || ->(rec) { rec.additional_attributes }
|
||||
|
||||
# Resolve the JSON data to be validated
|
||||
json_data = attribute_resolver.call(record)
|
||||
|
||||
# Get the schema to be used for validation
|
||||
schema = options[:schema]
|
||||
|
||||
# Create a JSONSchemer instance using the schema
|
||||
schemer = JSONSchemer.schema(schema)
|
||||
|
||||
# Validate the JSON data against the schema
|
||||
validation_errors = schemer.validate(json_data)
|
||||
|
||||
# Add validation errors to the record with a formatted statement
|
||||
validation_errors.each do |error|
|
||||
format_and_append_error(error, record)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_and_append_error(error, record)
|
||||
return handle_required(error, record) if error['type'] == 'required'
|
||||
return handle_minimum(error, record) if error['type'] == 'minimum'
|
||||
return handle_maximum(error, record) if error['type'] == 'maximum'
|
||||
|
||||
type = error['type'] == 'object' ? 'hash' : error['type']
|
||||
|
||||
handle_type(error, record, type)
|
||||
end
|
||||
|
||||
def handle_required(error, record)
|
||||
missing_values = error['details']['missing_keys']
|
||||
missing_values.each do |missing|
|
||||
record.errors.add(missing, 'is required')
|
||||
end
|
||||
end
|
||||
|
||||
def handle_type(error, record, expected_type)
|
||||
data = get_name_from_data_pointer(error)
|
||||
record.errors.add(data, "must be of type #{expected_type}")
|
||||
end
|
||||
|
||||
def handle_minimum(error, record)
|
||||
data = get_name_from_data_pointer(error)
|
||||
record.errors.add(data, "must be greater than or equal to #{error['schema']['minimum']}")
|
||||
end
|
||||
|
||||
def handle_maximum(error, record)
|
||||
data = get_name_from_data_pointer(error)
|
||||
record.errors.add(data, "must be less than or equal to #{error['schema']['maximum']}")
|
||||
end
|
||||
|
||||
def get_name_from_data_pointer(error)
|
||||
data = error['data_pointer']
|
||||
|
||||
# if data starts with a "/" remove it
|
||||
data[1..] if data[0] == '/'
|
||||
end
|
||||
end
|
||||
20
app/models/concerns/label_activity_message_handler.rb
Normal file
20
app/models/concerns/label_activity_message_handler.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module LabelActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def create_label_added(user_name, labels = [])
|
||||
create_label_change_activity('added', user_name, labels)
|
||||
end
|
||||
|
||||
def create_label_removed(user_name, labels = [])
|
||||
create_label_change_activity('removed', user_name, labels)
|
||||
end
|
||||
|
||||
def create_label_change_activity(change_type, user_name, labels = [])
|
||||
return unless labels.size.positive?
|
||||
|
||||
content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', '))
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
end
|
||||
19
app/models/concerns/labelable.rb
Normal file
19
app/models/concerns/labelable.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module Labelable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
acts_as_taggable_on :labels
|
||||
end
|
||||
|
||||
def update_labels(labels = nil)
|
||||
update!(label_list: labels)
|
||||
end
|
||||
|
||||
def add_labels(new_labels = nil)
|
||||
return if new_labels.blank?
|
||||
|
||||
new_labels = Array(new_labels) # Make sure new_labels is an array
|
||||
combined_labels = labels + new_labels
|
||||
update!(label_list: combined_labels)
|
||||
end
|
||||
end
|
||||
96
app/models/concerns/liquidable.rb
Normal file
96
app/models/concerns/liquidable.rb
Normal file
@@ -0,0 +1,96 @@
|
||||
module Liquidable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_create :process_liquid_in_content
|
||||
before_create :process_liquid_in_template_params
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def message_drops
|
||||
{
|
||||
'contact' => ContactDrop.new(conversation.contact),
|
||||
'agent' => UserDrop.new(sender),
|
||||
'conversation' => ConversationDrop.new(conversation),
|
||||
'inbox' => InboxDrop.new(inbox),
|
||||
'account' => AccountDrop.new(conversation.account)
|
||||
}
|
||||
end
|
||||
|
||||
def liquid_processable_message?
|
||||
content.present? && (message_type == 'outgoing' || message_type == 'template')
|
||||
end
|
||||
|
||||
def process_liquid_in_content
|
||||
return unless liquid_processable_message?
|
||||
|
||||
template = Liquid::Template.parse(modified_liquid_content)
|
||||
self.content = template.render(message_drops)
|
||||
rescue Liquid::Error
|
||||
# If there is an error in the liquid syntax, we don't want to process it
|
||||
end
|
||||
|
||||
def modified_liquid_content
|
||||
# This regex is used to match the code blocks in the content
|
||||
# We don't want to process liquid in code blocks
|
||||
content.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
|
||||
end
|
||||
|
||||
def process_liquid_in_template_params
|
||||
return unless template_params_present? && liquid_processable_template_params?
|
||||
|
||||
processed_params = process_liquid_in_hash(template_params_data['processed_params'])
|
||||
|
||||
# Update the additional_attributes with processed template_params
|
||||
self.additional_attributes = additional_attributes.merge(
|
||||
'template_params' => template_params_data.merge('processed_params' => processed_params)
|
||||
)
|
||||
rescue Liquid::Error
|
||||
# If there is an error in the liquid syntax, we don't want to process it
|
||||
end
|
||||
|
||||
def template_params_present?
|
||||
additional_attributes&.dig('template_params', 'processed_params').present?
|
||||
end
|
||||
|
||||
def liquid_processable_template_params?
|
||||
message_type == 'outgoing' || message_type == 'template'
|
||||
end
|
||||
|
||||
def template_params_data
|
||||
additional_attributes['template_params']
|
||||
end
|
||||
|
||||
def process_liquid_in_hash(hash)
|
||||
return hash unless hash.is_a?(Hash)
|
||||
|
||||
hash.transform_values { |value| process_liquid_value(value) }
|
||||
end
|
||||
|
||||
def process_liquid_value(value)
|
||||
case value
|
||||
when String
|
||||
process_liquid_string(value)
|
||||
when Hash
|
||||
process_liquid_in_hash(value)
|
||||
when Array
|
||||
process_liquid_array(value)
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def process_liquid_array(array)
|
||||
array.map { |item| process_liquid_value(item) }
|
||||
end
|
||||
|
||||
def process_liquid_string(string)
|
||||
return string if string.blank?
|
||||
|
||||
template = Liquid::Template.parse(string)
|
||||
template.render(message_drops)
|
||||
rescue Liquid::Error
|
||||
string
|
||||
end
|
||||
end
|
||||
7
app/models/concerns/llm_formattable.rb
Normal file
7
app/models/concerns/llm_formattable.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module LlmFormattable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def to_llm_text(config = {})
|
||||
LlmFormatter::LlmTextFormatterService.new(self).format(config)
|
||||
end
|
||||
end
|
||||
31
app/models/concerns/message_filter_helpers.rb
Normal file
31
app/models/concerns/message_filter_helpers.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
module MessageFilterHelpers
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def reportable?
|
||||
incoming? || outgoing?
|
||||
end
|
||||
|
||||
def webhook_sendable?
|
||||
incoming? || outgoing? || template?
|
||||
end
|
||||
|
||||
def slack_hook_sendable?
|
||||
incoming? || outgoing? || template?
|
||||
end
|
||||
|
||||
def notifiable?
|
||||
incoming? || outgoing?
|
||||
end
|
||||
|
||||
def conversation_transcriptable?
|
||||
incoming? || outgoing?
|
||||
end
|
||||
|
||||
def email_reply_summarizable?
|
||||
incoming? || outgoing? || input_csat?
|
||||
end
|
||||
|
||||
def instagram_story_mention?
|
||||
inbox.instagram? && try(:content_attributes)[:image_type] == 'story_mention'
|
||||
end
|
||||
end
|
||||
53
app/models/concerns/out_of_offisable.rb
Normal file
53
app/models/concerns/out_of_offisable.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module OutOfOffisable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
OFFISABLE_ATTRS = %w[day_of_week closed_all_day open_hour open_minutes close_hour close_minutes open_all_day].freeze
|
||||
|
||||
included do
|
||||
has_many :working_hours, dependent: :destroy_async
|
||||
after_create :create_default_working_hours
|
||||
end
|
||||
|
||||
def out_of_office?
|
||||
working_hours_enabled? && working_hours.today.closed_now?
|
||||
end
|
||||
|
||||
def working_now?
|
||||
!out_of_office?
|
||||
end
|
||||
|
||||
def weekly_schedule
|
||||
working_hours.order(day_of_week: :asc).select(*OFFISABLE_ATTRS).as_json(except: :id)
|
||||
end
|
||||
|
||||
# accepts an array of hashes similiar to the format of weekly_schedule
|
||||
# [
|
||||
# { "day_of_week"=>1,
|
||||
# "closed_all_day"=>false,
|
||||
# "open_hour"=>9,
|
||||
# "open_minutes"=>0,
|
||||
# "close_hour"=>17,
|
||||
# "close_minutes"=>0,
|
||||
# "open_all_day=>false" },...]
|
||||
def update_working_hours(params)
|
||||
ActiveRecord::Base.transaction do
|
||||
params.each do |working_hour|
|
||||
working_hours.find_by(day_of_week: working_hour['day_of_week']).update(working_hour.slice(*OFFISABLE_ATTRS))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_default_working_hours
|
||||
working_hours.create!(day_of_week: 0, closed_all_day: true, open_all_day: false)
|
||||
working_hours.create!(day_of_week: 1, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
|
||||
working_hours.create!(day_of_week: 2, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
|
||||
working_hours.create!(day_of_week: 3, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
|
||||
working_hours.create!(day_of_week: 4, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
|
||||
working_hours.create!(day_of_week: 5, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
|
||||
working_hours.create!(day_of_week: 6, closed_all_day: true, open_all_day: false)
|
||||
end
|
||||
end
|
||||
33
app/models/concerns/priority_activity_message_handler.rb
Normal file
33
app/models/concerns/priority_activity_message_handler.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
module PriorityActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def priority_change_activity(user_name)
|
||||
old_priority, new_priority = previous_changes.values_at('priority')[0]
|
||||
return unless priority_change?(old_priority, new_priority)
|
||||
|
||||
user = Current.executed_by.instance_of?(AutomationRule) ? I18n.t('automation.system_name') : user_name
|
||||
content = build_priority_change_content(user, old_priority, new_priority)
|
||||
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def priority_change?(old_priority, new_priority)
|
||||
old_priority.present? || new_priority.present?
|
||||
end
|
||||
|
||||
def build_priority_change_content(user_name, old_priority = nil, new_priority = nil)
|
||||
change_type = get_priority_change_type(old_priority, new_priority)
|
||||
|
||||
I18n.t("conversations.activity.priority.#{change_type}", user_name: user_name, new_priority: new_priority, old_priority: old_priority)
|
||||
end
|
||||
|
||||
def get_priority_change_type(old_priority, new_priority)
|
||||
case [old_priority.present?, new_priority.present?]
|
||||
when [true, true] then 'updated'
|
||||
when [false, true] then 'added'
|
||||
when [true, false] then 'removed'
|
||||
end
|
||||
end
|
||||
end
|
||||
26
app/models/concerns/pubsubable.rb
Normal file
26
app/models/concerns/pubsubable.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Pubsubable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# Used by the actionCable/PubSub Service we use for real time communications
|
||||
has_secure_token :pubsub_token
|
||||
before_save :rotate_pubsub_token
|
||||
end
|
||||
|
||||
def rotate_pubsub_token
|
||||
# ATM we are only rotating the token if the user is changing their password
|
||||
return unless is_a?(User)
|
||||
|
||||
# Using the class method to avoid the extra Save
|
||||
# TODO: Should we do this on signin ?
|
||||
self.pubsub_token = self.class.generate_unique_secure_token if will_save_change_to_encrypted_password?
|
||||
end
|
||||
|
||||
def pubsub_token
|
||||
# backfills tokens for existing records
|
||||
regenerate_pubsub_token if self[:pubsub_token].blank? && persisted?
|
||||
self[:pubsub_token]
|
||||
end
|
||||
end
|
||||
15
app/models/concerns/push_data_helper.rb
Normal file
15
app/models/concerns/push_data_helper.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module PushDataHelper
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def push_event_data
|
||||
Conversations::EventDataPresenter.new(self).push_data
|
||||
end
|
||||
|
||||
def lock_event_data
|
||||
Conversations::EventDataPresenter.new(self).lock_data
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
Conversations::EventDataPresenter.new(self).push_data
|
||||
end
|
||||
end
|
||||
97
app/models/concerns/reauthorizable.rb
Normal file
97
app/models/concerns/reauthorizable.rb
Normal file
@@ -0,0 +1,97 @@
|
||||
# This concern is primarily targeted for business models dependent on external services
|
||||
# The auth tokens we obtained on their behalf could expire or becomes invalid.
|
||||
# We would be aware of it until we make the API call to the service and it throws error
|
||||
|
||||
# Example:
|
||||
# when a user changes his/her password, the auth token they provided to chatwoot becomes invalid
|
||||
|
||||
# This module helps to capture the errors into a counter and when threshold is passed would mark
|
||||
# the object to be reauthorized. We will also send an email to the owners alerting them of the error.
|
||||
|
||||
# In the UI, we will check for the reauthorization_required? status and prompt the reauthorization flow
|
||||
|
||||
module Reauthorizable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
AUTHORIZATION_ERROR_THRESHOLD = 2
|
||||
|
||||
# model attribute
|
||||
def reauthorization_required?
|
||||
::Redis::Alfred.get(reauthorization_required_key).present?
|
||||
end
|
||||
|
||||
# model attribute
|
||||
def authorization_error_count
|
||||
::Redis::Alfred.get(authorization_error_count_key).to_i
|
||||
end
|
||||
|
||||
# action to be performed when we receive authorization errors
|
||||
# Implement in your exception handling logic for authorization errors
|
||||
def authorization_error!
|
||||
::Redis::Alfred.incr(authorization_error_count_key)
|
||||
# we are giving precendence to the authorization error threshhold defined in the class
|
||||
# so that channels can override the default value
|
||||
prompt_reauthorization! if authorization_error_count >= self.class::AUTHORIZATION_ERROR_THRESHOLD
|
||||
end
|
||||
|
||||
# Performed automatically if error threshold is breached
|
||||
# could used to manually prompt reauthorization if auth scope changes
|
||||
def prompt_reauthorization!
|
||||
::Redis::Alfred.set(reauthorization_required_key, true)
|
||||
|
||||
reauthorization_handlers[self.class.name]&.call(self)
|
||||
|
||||
invalidate_inbox_cache unless instance_of?(::AutomationRule)
|
||||
end
|
||||
|
||||
def process_integration_hook_reauthorization_emails
|
||||
if slack?
|
||||
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).slack_disconnect.deliver_later
|
||||
elsif dialogflow?
|
||||
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).dialogflow_disconnect.deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
def send_channel_reauthorization_email(disconnect_type)
|
||||
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).public_send(disconnect_type, inbox).deliver_later
|
||||
end
|
||||
|
||||
def handle_automation_rule_reauthorization
|
||||
update!(active: false)
|
||||
AdministratorNotifications::AccountNotificationMailer.with(account: account).automation_rule_disabled(self).deliver_later
|
||||
end
|
||||
|
||||
# call this after you successfully Reauthorized the object in UI
|
||||
def reauthorized!
|
||||
::Redis::Alfred.delete(authorization_error_count_key)
|
||||
::Redis::Alfred.delete(reauthorization_required_key)
|
||||
|
||||
invalidate_inbox_cache unless instance_of?(::AutomationRule)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reauthorization_handlers
|
||||
{
|
||||
'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails },
|
||||
'Channel::FacebookPage' => ->(obj) { obj.send_channel_reauthorization_email(:facebook_disconnect) },
|
||||
'Channel::Instagram' => ->(obj) { obj.send_channel_reauthorization_email(:instagram_disconnect) },
|
||||
'Channel::Tiktok' => ->(obj) { obj.send_channel_reauthorization_email(:tiktok_disconnect) },
|
||||
'Channel::Whatsapp' => ->(obj) { obj.send_channel_reauthorization_email(:whatsapp_disconnect) },
|
||||
'Channel::Email' => ->(obj) { obj.send_channel_reauthorization_email(:email_disconnect) },
|
||||
'AutomationRule' => ->(obj) { obj.handle_automation_rule_reauthorization }
|
||||
}
|
||||
end
|
||||
|
||||
def invalidate_inbox_cache
|
||||
inbox.update_account_cache if inbox.present?
|
||||
end
|
||||
|
||||
def authorization_error_count_key
|
||||
format(::Redis::Alfred::AUTHORIZATION_ERROR_COUNT, obj_type: self.class.table_name.singularize, obj_id: id)
|
||||
end
|
||||
|
||||
def reauthorization_required_key
|
||||
format(::Redis::Alfred::REAUTHORIZATION_REQUIRED, obj_type: self.class.table_name.singularize, obj_id: id)
|
||||
end
|
||||
end
|
||||
9
app/models/concerns/reportable.rb
Normal file
9
app/models/concerns/reportable.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Reportable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :reporting_events, dependent: :destroy
|
||||
end
|
||||
end
|
||||
31
app/models/concerns/sla_activity_message_handler.rb
Normal file
31
app/models/concerns/sla_activity_message_handler.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
module SlaActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def create_sla_change_activity(change_type, user_name)
|
||||
content = case change_type
|
||||
when 'added'
|
||||
I18n.t('conversations.activity.sla.added', user_name: user_name, sla_name: sla_policy_name)
|
||||
when 'removed'
|
||||
I18n.t('conversations.activity.sla.removed', user_name: user_name, sla_name: sla_policy_name)
|
||||
when 'updated'
|
||||
I18n.t('conversations.activity.sla.updated', user_name: user_name, sla_name: sla_policy_name)
|
||||
end
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def sla_policy_name
|
||||
SlaPolicy.find_by(id: sla_policy_id)&.name || ''
|
||||
end
|
||||
|
||||
def determine_sla_change_type
|
||||
sla_policy_id_before, sla_policy_id_after = previous_changes[:sla_policy_id]
|
||||
|
||||
if sla_policy_id_before.nil? && sla_policy_id_after.present?
|
||||
'added'
|
||||
elsif sla_policy_id_before.present? && sla_policy_id_after.nil?
|
||||
'removed'
|
||||
end
|
||||
end
|
||||
end
|
||||
37
app/models/concerns/sort_handler.rb
Normal file
37
app/models/concerns/sort_handler.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
module SortHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def sort_on_last_activity_at(sort_direction = :desc)
|
||||
order(last_activity_at: sort_direction)
|
||||
end
|
||||
|
||||
def sort_on_created_at(sort_direction = :asc)
|
||||
order(created_at: sort_direction)
|
||||
end
|
||||
|
||||
def sort_on_priority(sort_direction = :desc)
|
||||
order(generate_sql_query("priority #{sort_direction.to_s.upcase} NULLS LAST, last_activity_at DESC"))
|
||||
end
|
||||
|
||||
def sort_on_waiting_since(sort_direction = :asc)
|
||||
order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
|
||||
end
|
||||
|
||||
def last_messaged_conversations
|
||||
Message.except(:order).select(
|
||||
'DISTINCT ON (conversation_id) conversation_id, id, created_at, message_type'
|
||||
).order('conversation_id, created_at DESC')
|
||||
end
|
||||
|
||||
def sort_on_last_user_message_at
|
||||
order('grouped_conversations.message_type', 'grouped_conversations.created_at ASC')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_sql_query(query)
|
||||
Arel::Nodes::SqlLiteral.new(sanitize_sql_for_order(query))
|
||||
end
|
||||
end
|
||||
end
|
||||
32
app/models/concerns/sso_authenticatable.rb
Normal file
32
app/models/concerns/sso_authenticatable.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
module SsoAuthenticatable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def generate_sso_auth_token
|
||||
token = SecureRandom.hex(32)
|
||||
::Redis::Alfred.setex(sso_token_key(token), true, 5.minutes)
|
||||
token
|
||||
end
|
||||
|
||||
def invalidate_sso_auth_token(token)
|
||||
::Redis::Alfred.delete(sso_token_key(token))
|
||||
end
|
||||
|
||||
def valid_sso_auth_token?(token)
|
||||
::Redis::Alfred.get(sso_token_key(token)).present?
|
||||
end
|
||||
|
||||
def generate_sso_link
|
||||
encoded_email = ERB::Util.url_encode(email)
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
|
||||
end
|
||||
|
||||
def generate_sso_link_with_impersonation
|
||||
"#{generate_sso_link}&impersonation=true"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sso_token_key(token)
|
||||
format(::Redis::RedisKeys::USER_SSO_AUTH_TOKEN, user_id: id, token: token)
|
||||
end
|
||||
end
|
||||
29
app/models/concerns/team_activity_message_handler.rb
Normal file
29
app/models/concerns/team_activity_message_handler.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
module TeamActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def create_team_change_activity(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
return unless user_name
|
||||
|
||||
key = generate_team_change_activity_key
|
||||
params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name }
|
||||
params[:team_name] = generate_team_name_for_activity if key == 'removed'
|
||||
content = I18n.t("conversations.activity.team.#{key}", **params)
|
||||
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_team_change_activity_key
|
||||
team = Team.find_by(id: team_id)
|
||||
key = team.present? ? 'assigned' : 'removed'
|
||||
key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee
|
||||
key
|
||||
end
|
||||
|
||||
def generate_team_name_for_activity
|
||||
previous_team_id = previous_changes[:team_id][0]
|
||||
Team.find_by(id: previous_team_id)&.name if previous_team_id.present?
|
||||
end
|
||||
end
|
||||
53
app/models/concerns/user_attribute_helpers.rb
Normal file
53
app/models/concerns/user_attribute_helpers.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
module UserAttributeHelpers
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def available_name
|
||||
self[:display_name].presence || name
|
||||
end
|
||||
|
||||
def availability_status
|
||||
current_account_user&.availability_status
|
||||
end
|
||||
|
||||
def auto_offline
|
||||
current_account_user&.auto_offline
|
||||
end
|
||||
|
||||
def inviter
|
||||
current_account_user&.inviter
|
||||
end
|
||||
|
||||
def active_account_user
|
||||
account_users.order(Arel.sql('active_at DESC NULLS LAST'))&.first
|
||||
end
|
||||
|
||||
def current_account_user
|
||||
# We want to avoid subsequent queries in case where the association is preloaded.
|
||||
# using where here will trigger n+1 queries.
|
||||
account_users.find { |ac_usr| ac_usr.account_id == Current.account.id } if Current.account
|
||||
end
|
||||
|
||||
def account
|
||||
current_account_user&.account
|
||||
end
|
||||
|
||||
def administrator?
|
||||
current_account_user&.administrator?
|
||||
end
|
||||
|
||||
def agent?
|
||||
current_account_user&.agent?
|
||||
end
|
||||
|
||||
def role
|
||||
current_account_user&.role
|
||||
end
|
||||
|
||||
# Used internally for Chatwoot in Chatwoot
|
||||
def hmac_identifier
|
||||
hmac_key = GlobalConfig.get('CHATWOOT_INBOX_HMAC_KEY')['CHATWOOT_INBOX_HMAC_KEY']
|
||||
return OpenSSL::HMAC.hexdigest('sha256', hmac_key, email) if hmac_key.present?
|
||||
|
||||
''
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user