Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,12 @@
module Enterprise::ActionService
def add_sla(sla_policy_id)
return if sla_policy_id.blank?
sla_policy = @account.sla_policies.find_by(id: sla_policy_id.first)
return if sla_policy.nil?
return if @conversation.sla_policy.present?
Rails.logger.info "SLA:: Adding SLA #{sla_policy.id} to conversation: #{@conversation.id}"
@conversation.update!(sla_policy_id: sla_policy.id)
end
end

View File

@@ -0,0 +1,98 @@
module Enterprise::AutoAssignment::AssignmentService
private
# Override assignment config to use policy if available
def assignment_config
return super unless policy
{
'conversation_priority' => policy.conversation_priority,
'fair_distribution_limit' => policy.fair_distribution_limit,
'fair_distribution_window' => policy.fair_distribution_window,
'balanced' => policy.balanced?
}.compact
end
# Extend agent finding to add capacity checks
def find_available_agent(conversation = nil)
agents = filter_agents_by_team(inbox.available_agents, conversation)
return nil if agents.nil?
agents = filter_agents_by_rate_limit(agents)
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
return nil if agents.empty?
# Use balanced selector only if advanced_assignment feature is enabled
selector = policy&.balanced? && account.feature_enabled?('advanced_assignment') ? balanced_selector : round_robin_selector
selector.select_agent(agents)
end
def filter_agents_by_capacity(agents)
return agents unless capacity_filtering_enabled?
capacity_service = Enterprise::AutoAssignment::CapacityService.new
agents.select { |agent_member| capacity_service.agent_has_capacity?(agent_member.user, inbox) }
end
def capacity_filtering_enabled?
account.feature_enabled?('advanced_assignment') &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end
def balanced_selector
@balanced_selector ||= Enterprise::AutoAssignment::BalancedSelector.new(inbox: inbox)
end
def policy
@policy ||= inbox.assignment_policy
end
def account
inbox.account
end
# Override to apply exclusion rules
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Apply exclusion rules from capacity policy or assignment policy
scope = apply_exclusion_rules(scope)
# Apply conversation priority using enum methods if policy exists
scope = if policy&.longest_waiting?
scope.reorder(last_activity_at: :asc, created_at: :asc)
else
scope.reorder(created_at: :asc)
end
scope.limit(limit)
end
def apply_exclusion_rules(scope)
capacity_policy = inbox.inbox_capacity_limits.first&.agent_capacity_policy
return scope unless capacity_policy
exclusion_rules = capacity_policy.exclusion_rules || {}
scope = apply_label_exclusions(scope, exclusion_rules['excluded_labels'])
apply_age_exclusions(scope, exclusion_rules['exclude_older_than_hours'])
end
def apply_label_exclusions(scope, excluded_labels)
return scope if excluded_labels.blank?
scope.tagged_with(excluded_labels, exclude: true, on: :labels)
end
def apply_age_exclusions(scope, hours_threshold)
return scope if hours_threshold.blank?
hours = hours_threshold.to_i
return scope unless hours.positive?
scope.where('conversations.created_at >= ?', hours.hours.ago)
end
end

View File

@@ -0,0 +1,26 @@
class Enterprise::AutoAssignment::BalancedSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
agent_users = available_agents.map(&:user)
assignment_counts = fetch_assignment_counts(agent_users)
agent_users.min_by { |user| assignment_counts[user.id] || 0 }
end
private
def fetch_assignment_counts(users)
user_ids = users.map(&:id)
counts = inbox.conversations
.open
.where(assignee_id: user_ids)
.group(:assignee_id)
.count
Hash.new(0).merge(counts)
end
end

View File

@@ -0,0 +1,25 @@
class Enterprise::AutoAssignment::CapacityService
def agent_has_capacity?(user, inbox)
# Get the account_user for this specific account
account_user = user.account_users.find_by(account: inbox.account)
# If no account_user or no capacity policy, agent has unlimited capacity
return true unless account_user&.agent_capacity_policy
policy = account_user.agent_capacity_policy
# Check if there's a specific limit for this inbox
inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
# If no specific limit for this inbox, agent has unlimited capacity for this inbox
return true unless inbox_limit
# Count current open conversations for this agent in this inbox
current_count = user.assigned_conversations
.where(inbox: inbox, status: :open)
.count
# Agent has capacity if current count is below the limit
current_count < inbox_limit.conversation_limit
end
end

View File

@@ -0,0 +1,24 @@
class Enterprise::Billing::CancelCloudSubscriptionsService
pattr_initialize [:account!]
def perform
return if stripe_customer_id.blank?
return unless ChatwootApp.chatwoot_cloud?
subscriptions.each do |subscription|
next if subscription.cancel_at_period_end
Stripe::Subscription.update(subscription.id, cancel_at_period_end: true)
end
end
private
def subscriptions
Stripe::Subscription.list(customer: stripe_customer_id, status: 'active', limit: 100).data
end
def stripe_customer_id
account.custom_attributes['stripe_customer_id']
end
end

View File

@@ -0,0 +1,10 @@
class Enterprise::Billing::CreateSessionService
def create_session(customer_id, return_url = ENV.fetch('FRONTEND_URL'))
Stripe::BillingPortal::Session.create(
{
customer: customer_id,
return_url: return_url
}
)
end
end

View File

@@ -0,0 +1,69 @@
class Enterprise::Billing::CreateStripeCustomerService
pattr_initialize [:account!]
DEFAULT_QUANTITY = 2
def perform
return if existing_subscription?
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(
{
customer: customer_id,
items: [{ price: price_id, quantity: default_quantity }]
}
)
account.update!(
custom_attributes: {
stripe_customer_id: customer_id,
stripe_price_id: subscription['plan']['id'],
stripe_product_id: subscription['plan']['product'],
plan_name: default_plan['name'],
subscribed_quantity: subscription['quantity']
}
)
end
private
def prepare_customer_id
customer_id = account.custom_attributes['stripe_customer_id']
if customer_id.blank?
customer = Stripe::Customer.create({ name: account.name, email: billing_email })
customer_id = customer.id
end
customer_id
end
def default_quantity
default_plan['default_quantity'] || DEFAULT_QUANTITY
end
def billing_email
account.administrators.first.email
end
def default_plan
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
@default_plan ||= installation_config.value.first
end
def price_id
price_ids = default_plan['price_ids']
price_ids.first
end
def existing_subscription?
stripe_customer_id = account.custom_attributes['stripe_customer_id']
return false if stripe_customer_id.blank?
subscriptions = Stripe::Subscription.list(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
)
subscriptions.data.present?
end
end

View File

@@ -0,0 +1,216 @@
class Enterprise::Billing::HandleStripeEventService
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
# Each higher tier includes all features from the lower tiers
# Basic features available starting with the Startups plan
STARTUP_PLAN_FEATURES = %w[
inbound_emails
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
advanced_search
linear_integration
].freeze
# Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
def perform(event:)
@event = event
case @event.type
when 'customer.subscription.updated'
process_subscription_updated
when 'customer.subscription.deleted'
process_subscription_deleted
else
Rails.logger.debug { "Unhandled event type: #{event.type}" }
end
end
private
def process_subscription_updated
plan = find_plan(subscription['plan']['product']) if subscription['plan'].present?
# skipping self hosted plan events
return if plan.blank? || account.blank?
previous_usage = capture_previous_usage
update_account_attributes(subscription, plan)
update_plan_features
if billing_period_renewed?
ActiveRecord::Base.transaction do
handle_subscription_credits(plan, previous_usage)
account.reset_response_usage
end
elsif plan_changed?
handle_plan_change_credits(plan, previous_usage)
end
end
def capture_previous_usage
{ responses: account.custom_attributes['captain_responses_usage'].to_i, monthly: current_plan_credits[:responses] }
end
def current_plan_credits
plan_name = account.custom_attributes['plan_name']
return { responses: 0, documents: 0 } if plan_name.blank?
get_plan_credits(plan_name)
end
def update_account_attributes(subscription, plan)
# https://stripe.com/docs/api/subscriptions/object
account.update(
custom_attributes: account.custom_attributes.merge(
'stripe_customer_id' => subscription.customer,
'stripe_price_id' => subscription['plan']['id'],
'stripe_product_id' => subscription['plan']['product'],
'plan_name' => plan['name'],
'subscribed_quantity' => subscription['quantity'],
'subscription_status' => subscription['status'],
'subscription_ends_on' => Time.zone.at(subscription['current_period_end'])
)
)
end
def process_subscription_deleted
# skipping self hosted plan events
return if account.blank?
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
def update_plan_features
if default_plan?
disable_all_premium_features
else
enable_features_for_current_plan
end
# Enable any manually managed features configured in internal_attributes
enable_account_manually_managed_features
account.save!
end
def disable_all_premium_features
# Disable all features (for default Hacker plan)
account.disable_features(*STARTUP_PLAN_FEATURES)
account.disable_features(*BUSINESS_PLAN_FEATURES)
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
end
def enable_features_for_current_plan
# First disable all premium features to handle downgrades
disable_all_premium_features
# Then enable features based on the current plan
enable_plan_specific_features
end
def handle_subscription_credits(plan, previous_usage)
current_limits = account.limits || {}
current_credits = current_limits['captain_responses'].to_i
new_plan_credits = get_plan_credits(plan['name'])[:responses]
consumed_topup_credits = [previous_usage[:responses] - previous_usage[:monthly], 0].max
updated_credits = current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits
Rails.logger.info("Updating subscription credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
end
def handle_plan_change_credits(new_plan, previous_usage)
current_limits = account.limits || {}
current_credits = current_limits['captain_responses'].to_i
previous_plan_credits = previous_usage[:monthly]
new_plan_credits = get_plan_credits(new_plan['name'])[:responses]
updated_credits = current_credits - previous_plan_credits + new_plan_credits
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
end
def get_plan_credits(plan_name)
config = InstallationConfig.find_by(name: CAPTAIN_CLOUD_PLAN_LIMITS).value
config = JSON.parse(config) if config.is_a?(String)
config[plan_name.downcase]&.symbolize_keys
end
def enable_plan_specific_features
plan_name = account.custom_attributes['plan_name']
return if plan_name.blank?
case plan_name
when 'Startups' then account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Business'
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES)
when 'Enterprise'
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES, *ENTERPRISE_PLAN_FEATURES)
end
end
def subscription
@subscription ||= @event.data.object
end
def previous_attributes
@previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json)
end
def plan_changed?
return false if previous_attributes['plan'].blank?
previous_plan_id = previous_attributes.dig('plan', 'id')
current_plan_id = subscription['plan']['id']
previous_plan_id != current_plan_id
end
def billing_period_renewed?
return false if previous_attributes['current_period_start'].blank?
previous_attributes['current_period_start'] != subscription['current_period_start']
end
def account
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end
def find_plan(plan_id)
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
end
def default_plan?
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
default_plan = cloud_plans.first || {}
account.custom_attributes['plan_name'] == default_plan['name']
end
def enable_account_manually_managed_features
# Get manually managed features from internal attributes using the service
service = Internal::Accounts::InternalAttributesService.new(account)
features = service.manually_managed_features
# Enable each feature
account.enable_features(*features) if features.present?
end
end

View File

@@ -0,0 +1,95 @@
class Enterprise::Billing::TopupCheckoutService
include BillingHelper
class Error < StandardError; end
TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12_000, amount: 200.0, currency: 'usd' }
].freeze
pattr_initialize [:account!]
def create_checkout_session(credits:)
topup_option = validate_and_find_topup_option(credits)
charge_customer(topup_option, credits)
fulfill_credits(credits, topup_option)
{
credits: credits,
amount: topup_option[:amount],
currency: topup_option[:currency]
}
end
private
def validate_and_find_topup_option(credits)
raise Error, I18n.t('errors.topup.invalid_credits') unless credits.to_i.positive?
raise Error, I18n.t('errors.topup.plan_not_eligible') if default_plan?(account)
raise Error, I18n.t('errors.topup.stripe_customer_not_configured') if stripe_customer_id.blank?
topup_option = find_topup_option(credits)
raise Error, I18n.t('errors.topup.invalid_option') unless topup_option
# Validate payment method exists
validate_payment_method!
topup_option
end
def validate_payment_method!
customer = Stripe::Customer.retrieve(stripe_customer_id)
return if customer.invoice_settings.default_payment_method.present? || customer.default_source.present?
# Auto-set first payment method as default if available
payment_methods = Stripe::PaymentMethod.list(customer: stripe_customer_id, limit: 1)
raise Error, I18n.t('errors.topup.no_payment_method') if payment_methods.data.empty?
Stripe::Customer.update(stripe_customer_id, invoice_settings: { default_payment_method: payment_methods.data.first.id })
end
def charge_customer(topup_option, credits)
amount_cents = (topup_option[:amount] * 100).to_i
currency = topup_option[:currency]
description = "AI Credits Topup: #{credits} credits"
invoice = Stripe::Invoice.create(
customer: stripe_customer_id,
currency: currency,
collection_method: 'charge_automatically',
auto_advance: false,
description: description
)
Stripe::InvoiceItem.create(
customer: stripe_customer_id,
amount: amount_cents,
currency: currency,
invoice: invoice.id,
description: description
)
Stripe::Invoice.finalize_invoice(invoice.id, { auto_advance: false })
Stripe::Invoice.pay(invoice.id)
end
def fulfill_credits(credits, topup_option)
Enterprise::Billing::TopupFulfillmentService.new(account: account).fulfill(
credits: credits,
amount_cents: (topup_option[:amount] * 100).to_i,
currency: topup_option[:currency]
)
end
def stripe_customer_id
account.custom_attributes['stripe_customer_id']
end
def find_topup_option(credits)
TOPUP_OPTIONS.find { |opt| opt[:credits] == credits.to_i }
end
end

View File

@@ -0,0 +1,51 @@
class Enterprise::Billing::TopupFulfillmentService
pattr_initialize [:account!]
def fulfill(credits:, amount_cents:, currency:)
account.with_lock do
create_stripe_credit_grant(credits, amount_cents, currency)
update_account_credits(credits)
end
Rails.logger.info("Topup fulfilled for account #{account.id}: #{credits} credits, #{amount_cents} cents")
end
private
def create_stripe_credit_grant(credits, amount_cents, currency)
Stripe::Billing::CreditGrant.create(
customer: stripe_customer_id,
name: "Topup: #{credits} credits",
amount: {
type: 'monetary',
monetary: { currency: currency, value: amount_cents }
},
applicability_config: {
scope: { price_type: 'metered' }
},
category: 'paid',
expires_at: 6.months.from_now.to_i,
metadata: {
account_id: account.id.to_s,
source: 'topup',
credits: credits.to_s
}
)
end
def update_account_credits(credits)
current_limits = account.limits || {}
current_total = current_limits['captain_responses'].to_i
new_total = current_total + credits
account.update!(
limits: current_limits.merge(
'captain_responses' => new_total
)
)
end
def stripe_customer_id
account.custom_attributes['stripe_customer_id']
end
end

View File

@@ -0,0 +1,92 @@
# The Enterprise::ClearbitLookupService class is responsible for interacting with the Clearbit API.
# It provides methods to lookup a person's information using their email.
# Clearbit API documentation: {https://dashboard.clearbit.com/docs?ruby#api-reference}
# We use the combined API which returns both the person and comapnies together
# Combined API: {https://dashboard.clearbit.com/docs?ruby=#enrichment-api-combined-api}
# Persons API: {https://dashboard.clearbit.com/docs?ruby=#enrichment-api-person-api}
# Companies API: {https://dashboard.clearbit.com/docs?ruby=#enrichment-api-company-api}
#
# Note: The Clearbit gem is not used in this service, since it is not longer maintained
# GitHub: {https://github.com/clearbit/clearbit-ruby}
#
# @example
# Enterprise::ClearbitLookupService.lookup('test@example.com')
class Enterprise::ClearbitLookupService
# Clearbit API endpoint for combined lookup
CLEARBIT_ENDPOINT = 'https://person.clearbit.com/v2/combined/find'.freeze
# Performs a lookup on the Clearbit API using the provided email.
#
# @param email [String] The email address to lookup.
# @return [Hash, nil] A hash containing the person's full name, company name, and company timezone, or nil if an error occurs.
def self.lookup(email)
return nil unless clearbit_enabled?
response = perform_request(email)
process_response(response)
rescue StandardError => e
Rails.logger.error "[ClearbitLookup] #{e.message}"
nil
end
# Performs a request to the Clearbit API using the provided email.
#
# @param email [String] The email address to lookup.
# @return [HTTParty::Response] The response from the Clearbit API.
def self.perform_request(email)
options = {
headers: { 'Authorization' => "Bearer #{clearbit_token}" },
query: { email: email }
}
HTTParty.get(CLEARBIT_ENDPOINT, options)
end
# Handles an error response from the Clearbit API.
#
# @param response [HTTParty::Response] The response from the Clearbit API.
# @return [nil] Always returns nil.
def self.handle_error(response)
Rails.logger.error "[ClearbitLookup] API Error: #{response.message} (Status: #{response.code})"
nil
end
# Checks if Clearbit is enabled by checking for the presence of the CLEARBIT_API_KEY environment variable.
#
# @return [Boolean] True if Clearbit is enabled, false otherwise.
def self.clearbit_enabled?
clearbit_token.present?
end
def self.clearbit_token
GlobalConfigService.load('CLEARBIT_API_KEY', '')
end
# Processes the response from the Clearbit API.
#
# @param response [HTTParty::Response] The response from the Clearbit API.
# @return [Hash, nil] A hash containing the person's full name, company name, and company timezone, or nil if an error occurs.
def self.process_response(response)
return handle_error(response) unless response.success?
format_response(response)
end
# Formats the response data from the Clearbit API.
#
# @param data [Hash] The raw data from the Clearbit API.
# @return [Hash] A hash containing the person's full name, company name, and company timezone.
def self.format_response(response)
data = response.parsed_response
{
name: data.dig('person', 'name', 'fullName'),
avatar: data.dig('person', 'avatar'),
company_name: data.dig('company', 'name'),
timezone: data.dig('company', 'timeZone'),
logo: data.dig('company', 'logo'),
industry: data.dig('company', 'category', 'industry'),
company_size: data.dig('company', 'metrics', 'employees')
}
end
end

View File

@@ -0,0 +1,16 @@
module Enterprise::Contacts::ContactableInboxesService
private
# Extend base selection to include Voice inboxes
def get_contactable_inbox(inbox)
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice'
super
end
def voice_contactable_inbox(inbox)
return if @contact.phone_number.blank?
{ source_id: @contact.phone_number, inbox: inbox }
end
end

View File

@@ -0,0 +1,39 @@
module Enterprise::Conversations::PermissionFilterService
def perform
return filter_by_permissions(permissions) if user_has_custom_role?
super
end
private
def user_has_custom_role?
user_role == 'agent' && account_user&.custom_role_id.present?
end
def permissions
account_user&.permissions || []
end
def filter_by_permissions(permissions)
# Permission-based filtering with hierarchy
# conversation_manage > conversation_unassigned_manage > conversation_participating_manage
if permissions.include?('conversation_manage')
accessible_conversations
elsif permissions.include?('conversation_unassigned_manage')
filter_unassigned_and_mine
elsif permissions.include?('conversation_participating_manage')
accessible_conversations.assigned_to(user)
else
Conversation.none
end
end
def filter_unassigned_and_mine
mine = accessible_conversations.assigned_to(user)
unassigned = accessible_conversations.unassigned
Conversation.from("(#{mine.to_sql} UNION #{unassigned.to_sql}) as conversations")
.where(account_id: account.id)
end
end

View File

@@ -0,0 +1,81 @@
module Enterprise::MessageTemplates::HookExecutionService
MAX_ATTACHMENT_WAIT_SECONDS = 4
def trigger_templates
super
return unless should_process_captain_response?
return perform_handoff unless inbox.captain_active?
schedule_captain_response
end
def should_send_greeting?
return false if captain_handling_conversation?
super
end
def should_send_out_of_office_message?
return false if captain_handling_conversation?
super
end
def should_send_email_collect?
return false if captain_handling_conversation?
super
end
private
def schedule_captain_response
job_args = [conversation, conversation.inbox.captain_assistant]
if message.attachments.blank?
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
else
wait_time = calculate_attachment_wait_time
Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
end
end
def calculate_attachment_wait_time
attachment_count = message.attachments.size
base_wait = 1.second
# Wait longer for more attachments or larger files
additional_wait = [attachment_count * 1, MAX_ATTACHMENT_WAIT_SECONDS].min.seconds
base_wait + additional_wait
end
def should_process_captain_response?
conversation.pending? && message.incoming? && inbox.captain_assistant.present?
end
def perform_handoff
return unless conversation.pending?
Rails.logger.info("Captain limit exceeded, performing handoff mid-conversation for conversation: #{conversation.id}")
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account.id,
inbox_id: conversation.inbox.id,
content: 'Transferring to another agent for further assistance.'
)
conversation.bot_handoff!
send_out_of_office_message_after_handoff
end
def send_out_of_office_message_after_handoff
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
end
end

View File

@@ -0,0 +1,88 @@
module Enterprise::SearchService
def advanced_search
where_conditions = build_where_conditions
apply_filters(where_conditions)
Message.search(
search_query,
fields: %w[content attachments.transcribed_text content_attributes.email.subject],
where: where_conditions,
order: { created_at: :desc },
page: params[:page] || 1,
per_page: 15
)
end
private
def build_where_conditions
conditions = { account_id: current_account.id }
conditions[:inbox_id] = accessable_inbox_ids unless should_skip_inbox_filtering?
conditions
end
def apply_filters(where_conditions)
apply_from_filter(where_conditions)
apply_time_range_filter(where_conditions)
apply_inbox_filter(where_conditions)
end
def apply_from_filter(where_conditions)
sender_type, sender_id = parse_from_param(params[:from])
return unless sender_type && sender_id
where_conditions[:sender_type] = sender_type
where_conditions[:sender_id] = sender_id
end
def parse_from_param(from_param)
return [nil, nil] unless from_param&.match?(/\A(contact|agent):\d+\z/)
type, id = from_param.split(':')
sender_type = type == 'agent' ? 'User' : 'Contact'
[sender_type, id.to_i]
end
def apply_time_range_filter(where_conditions)
time_conditions = {}
time_conditions[:gte] = enforce_time_limit(params[:since])
time_conditions[:lte] = cap_until_time(params[:until]) if params[:until].present?
where_conditions[:created_at] = time_conditions if time_conditions.any?
end
def cap_until_time(until_param)
max_future = 90.days.from_now
requested_time = Time.zone.at(until_param.to_i)
[requested_time, max_future].min
end
def enforce_time_limit(since_param)
max_lookback = Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS.days.ago
if since_param.present?
requested_time = Time.zone.at(since_param.to_i)
# Silently cap to max_lookback if requested time is too far back
[requested_time, max_lookback].max
else
max_lookback
end
end
def apply_inbox_filter(where_conditions)
return if params[:inbox_id].blank?
inbox_id = params[:inbox_id].to_i
return if inbox_id.zero?
return unless validate_inbox_access(inbox_id)
where_conditions[:inbox_id] = inbox_id
end
def validate_inbox_access(inbox_id)
return true if should_skip_inbox_filtering?
accessable_inbox_ids.include?(inbox_id)
end
end