Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user