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,17 @@
module Enterprise::Api::V1::Accounts::AgentsController
def create
super
associate_agent_with_custom_role
end
def update
super
associate_agent_with_custom_role
end
private
def associate_agent_with_custom_role
@agent.current_account_user.update!(custom_role_id: params[:custom_role_id])
end
end

View File

@@ -0,0 +1,27 @@
module Enterprise::Api::V1::Accounts::ConversationsController
extend ActiveSupport::Concern
def inbox_assistant
assistant = @conversation.inbox.captain_assistant
if assistant
render json: { assistant: { id: assistant.id, name: assistant.name } }
else
render json: { assistant: nil }
end
end
def reporting_events
@reporting_events = @conversation.reporting_events.order(created_at: :asc)
end
def permitted_update_params
super.merge(params.permit(:sla_policy_id))
end
private
def copilot_params
params.permit(:previous_history, :message, :assistant_id)
end
end

View File

@@ -0,0 +1,12 @@
module Enterprise::Api::V1::Accounts::CsatSurveyResponsesController
def update
@csat_survey_response = Current.account.csat_survey_responses.find(params[:id])
authorize @csat_survey_response
@csat_survey_response.update!(
csat_review_notes: params[:csat_review_notes],
review_notes_updated_by: Current.user,
review_notes_updated_at: Time.current
)
end
end

View File

@@ -0,0 +1,33 @@
module Enterprise::Api::V1::Accounts::InboxesController
def inbox_attributes
super + ee_inbox_attributes
end
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
private
def allowed_channel_types
super + ['voice']
end
def channel_type_from_params
case permitted_params[:channel][:type]
when 'voice'
Channel::Voice
else
super
end
end
def account_channels_method
case permitted_params[:channel][:type]
when 'voice'
Current.account.voice_channels
else
super
end
end
end

View File

@@ -0,0 +1,15 @@
module Enterprise::Api::V1::Accounts::PortalsController
def ssl_status
return render_could_not_create_error(I18n.t('portals.ssl_status.custom_domain_not_configured')) if @portal.custom_domain.blank?
result = Cloudflare::CheckCustomHostnameService.new(portal: @portal).perform
return render_could_not_create_error(result[:errors]) if result[:errors].present?
ssl_settings = @portal.ssl_settings || {}
render json: {
status: ssl_settings['cf_status'],
verification_errors: ssl_settings['cf_verification_errors']
}
end
end

View File

@@ -0,0 +1,147 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
def subscription
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
@account.update(custom_attributes: { is_creating_customer: true })
Enterprise::CreateStripeCustomerJob.perform_later(@account)
end
head :no_content
end
def limits
limits = if default_plan?(@account)
{
'conversation' => {
'allowed' => 500,
'consumed' => conversations_this_month(@account)
},
'non_web_inboxes' => {
'allowed' => 0,
'consumed' => non_web_inboxes(@account)
},
'agents' => {
'allowed' => 2,
'consumed' => agents(@account)
}
}
else
default_limits
end
# include id in response to ensure that the store can be updated on the frontend
render json: { id: @account.id, limits: limits }, status: :ok
end
def checkout
return create_stripe_billing_session(stripe_customer_id) if stripe_customer_id.present?
render_invalid_billing_details
end
def toggle_deletion
action_type = params[:action_type]
case action_type
when 'delete'
mark_for_deletion
when 'undelete'
unmark_for_deletion
else
render json: { error: 'Invalid action_type. Must be either "delete" or "undelete"' }, status: :unprocessable_entity
end
end
def topup_checkout
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
result = service.create_checkout_session(credits: params[:credits].to_i)
@account.reload
render json: result.merge(
id: @account.id,
limits: @account.limits,
custom_attributes: @account.custom_attributes
)
rescue Enterprise::Billing::TopupCheckoutService::Error, Stripe::StripeError => e
render_could_not_create_error(e.message)
end
private
def check_cloud_env
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
end
def default_limits
{
'conversation' => {},
'non_web_inboxes' => {},
'agents' => {
'allowed' => @account.usage_limits[:agents],
'consumed' => agents(@account)
},
'captain' => @account.usage_limits[:captain]
}
end
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def stripe_customer_id
@account.custom_attributes['stripe_customer_id']
end
def mark_for_deletion
reason = 'manual_deletion'
if @account.mark_for_deletion(reason)
cancel_cloud_subscriptions_for_deletion
render json: { message: 'Account marked for deletion' }, status: :ok
else
render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def unmark_for_deletion
if @account.unmark_for_deletion
render json: { message: 'Account unmarked for deletion' }, status: :ok
else
render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def render_invalid_billing_details
render_could_not_create_error('Please subscribe to a plan before viewing the billing details')
end
def create_stripe_billing_session(customer_id)
session = Enterprise::Billing::CreateSessionService.new.create_session(customer_id)
render_redirect_url(session.url)
end
def cancel_cloud_subscriptions_for_deletion
Enterprise::Billing::CancelCloudSubscriptionsService.new(account: @account).perform
rescue Stripe::StripeError => e
Rails.logger.warn("Failed to cancel cloud subscriptions for account #{@account.id}: #{e.class} - #{e.message}")
end
def render_redirect_url(redirect_url)
render json: { redirect_url: redirect_url }
end
def pundit_user
{
user: current_user,
account: @account,
account_user: @current_account_user
}
end
end

View File

@@ -0,0 +1,7 @@
module Enterprise::Api::V1::AccountsSettings
private
def permitted_settings_attributes
super + [{ conversation_required_attributes: [] }]
end
end

View File

@@ -0,0 +1,40 @@
module Enterprise::Api::V2::AccountsController
private
def fetch_account_and_user_info
@data = fetch_from_clearbit
return if @data.blank?
update_user_info
end
def fetch_from_clearbit
Enterprise::ClearbitLookupService.lookup(@user.email)
rescue StandardError => e
Rails.logger.error "Error fetching data from clearbit: #{e}"
nil
end
def update_user_info
@user.update!(name: @data[:name]) if @data[:name].present?
end
def data_from_clearbit
return {} if @data.blank?
{ name: @data[:company_name],
custom_attributes: {
'industry' => @data[:industry],
'company_size' => @data[:company_size],
'timezone' => @data[:timezone],
'logo' => @data[:logo]
} }
end
def account_attributes
super.deep_merge(
data_from_clearbit
)
end
end

View File

@@ -0,0 +1,3 @@
module Enterprise::Concerns::ApplicationControllerConcern
extend ActiveSupport::Concern
end

View File

@@ -0,0 +1,100 @@
module Enterprise::DeviseOverrides::OmniauthCallbacksController
def saml
# Call parent's omniauth_success which handles the auth
omniauth_success
end
def redirect_callbacks
# derive target redirect route from 'resource_class' param, which was set
# before authentication.
devise_mapping = get_devise_mapping
redirect_route = get_redirect_route(devise_mapping)
# preserve omniauth info for success route. ignore 'extra' in twitter
# auth response to avoid CookieOverflow.
session['dta.omniauth.auth'] = request.env['omniauth.auth'].except('extra')
session['dta.omniauth.params'] = request.env['omniauth.params']
# For SAML, use 303 See Other to convert POST to GET and preserve session
if params[:provider] == 'saml'
redirect_to redirect_route, { status: 303 }.merge(redirect_options)
else
super
end
end
def omniauth_success
case auth_hash&.dig('provider')
when 'saml'
handle_saml_auth
else
super
end
end
def omniauth_failure
return super unless params[:provider] == 'saml'
relay_state = saml_relay_state
error = params[:message] || 'authentication-failed'
if for_mobile?(relay_state)
redirect_to_mobile_error(error, relay_state)
else
redirect_to login_page_url(error: "saml-#{error}")
end
end
private
def handle_saml_auth
account_id = extract_saml_account_id
relay_state = saml_relay_state
unless saml_enabled_for_account?(account_id)
return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
return redirect_to login_page_url(error: 'saml-not-enabled')
end
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
if @resource.persisted?
return sign_in_user_on_mobile if for_mobile?(relay_state)
sign_in_user
else
return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
redirect_to login_page_url(error: 'saml-authentication-failed')
end
end
def extract_saml_account_id
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
end
def saml_relay_state
session[:saml_relay_state] || request.env['omniauth.params']&.dig('RelayState')
end
def for_mobile?(relay_state)
relay_state.to_s.casecmp('mobile').zero?
end
def redirect_to_mobile_error(error)
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
end
def saml_enabled_for_account?(account_id)
return false if account_id.blank?
account = Account.find_by(id: account_id)
return false if account.nil?
return false unless account.feature_enabled?('saml')
AccountSamlSettings.find_by(account_id: account_id).present?
end
end

View File

@@ -0,0 +1,16 @@
module Enterprise::DeviseOverrides::PasswordsController
include SamlAuthenticationHelper
def create
if saml_user_attempting_password_auth?(params[:email])
render json: {
success: false,
message: I18n.t('messages.reset_password_saml_user'),
errors: [I18n.t('messages.reset_password_saml_user')]
}, status: :forbidden
return
end
super
end
end

View File

@@ -0,0 +1,40 @@
module Enterprise::DeviseOverrides::SessionsController
include SamlAuthenticationHelper
def create
if saml_user_attempting_password_auth?(params[:email], sso_auth_token: params[:sso_auth_token])
render json: {
success: false,
message: I18n.t('messages.login_saml_user'),
errors: [I18n.t('messages.login_saml_user')]
}, status: :unauthorized
return
end
super
end
def render_create_success
create_audit_event('sign_in')
super
end
def destroy
create_audit_event('sign_out')
super
end
def create_audit_event(action)
return unless @resource
associated_type = 'Account'
@resource.accounts.each do |account|
@resource.audits.create(
action: action,
user_id: @resource.id,
associated_id: account.id,
associated_type: associated_type
)
end
end
end

View File

@@ -0,0 +1,11 @@
module Enterprise::Public::Api::V1::Portals::ArticlesController
private
def search_articles
if @portal.account.feature_enabled?('help_center_embedding_search')
@articles = @articles.vector_search(list_params.merge(account_id: @portal.account_id)) if list_params[:query].present?
else
super
end
end
end

View File

@@ -0,0 +1,15 @@
module Enterprise::SuperAdmin::AccountsController
def update
# Handle manually managed features from form submission
if params[:account] && params[:account][:manually_managed_features].present?
# Update using the service - it will handle array conversion and validation
service = ::Internal::Accounts::InternalAttributesService.new(requested_resource)
service.manually_managed_features = params[:account][:manually_managed_features]
# Remove the manually_managed_features from params to prevent ActiveModel::UnknownAttributeError
params[:account].delete(:manually_managed_features)
end
super
end
end

View File

@@ -0,0 +1,56 @@
module Enterprise::SuperAdmin::AppConfigsController
private
def allowed_configs
return super if ChatwootHub.pricing_plan == 'community'
case @config
when 'custom_branding'
@allowed_configs = custom_branding_options
when 'internal'
@allowed_configs = internal_config_options
when 'captain'
@allowed_configs = captain_config_options
when 'saml'
@allowed_configs = saml_config_options
else
super
end
end
def custom_branding_options
%w[
LOGO_THUMBNAIL
LOGO
LOGO_DARK
BRAND_NAME
INSTALLATION_NAME
BRAND_URL
WIDGET_BRAND_URL
TERMS_URL
PRIVACY_URL
DISPLAY_MANIFEST
]
end
def internal_config_options
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY CLOUD_ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS
SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS
OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
end
def captain_config_options
%w[
CAPTAIN_OPEN_AI_API_KEY
CAPTAIN_OPEN_AI_MODEL
CAPTAIN_OPEN_AI_ENDPOINT
CAPTAIN_EMBEDDING_MODEL
CAPTAIN_FIRECRAWL_API_KEY
]
end
def saml_config_options
%w[ENABLE_SAML_SSO_LOGIN]
end
end

View File

@@ -0,0 +1,47 @@
class Enterprise::Webhooks::FirecrawlController < ActionController::API
before_action :validate_token
def process_payload
Captain::Tools::FirecrawlParserJob.perform_later(assistant_id: assistant.id, payload: payload) if crawl_page_event?
head :ok
end
private
include Captain::FirecrawlHelper
def payload
permitted_params[:data]&.first&.to_h
end
def validate_token
render json: { error: 'Invalid access_token' }, status: :unauthorized if assistant_token != permitted_params[:token]
end
def assistant
@assistant ||= Captain::Assistant.find(permitted_params[:assistant_id])
end
def assistant_token
generate_firecrawl_token(assistant.id, assistant.account_id)
end
def crawl_page_event?
permitted_params[:type] == 'crawl.page'
end
def permitted_params
params.permit(
:type,
:assistant_id,
:token,
:success,
:id,
:metadata,
:format,
:firecrawl,
data: [:markdown, { metadata: {} }]
)
end
end

View File

@@ -0,0 +1,21 @@
class Enterprise::Webhooks::StripeController < ActionController::API
def process_payload
# Get the event payload and signature
payload = request.body.read
sig_header = request.headers['Stripe-Signature']
# Attempt to verify the signature. If successful, we'll handle the event
begin
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
# If we fail to verify the signature, then something was wrong with the request
rescue JSON::ParserError, Stripe::SignatureVerificationError
# Invalid payload
head :bad_request
return
end
# We've successfully processed the event without blowing up
head :ok
end
end

View File

@@ -0,0 +1,14 @@
module Enterprise::WidgetsController
private
def ensure_location_is_supported
countries = @web_widget.inbox.account.custom_attributes['allowed_countries']
return if countries.blank?
geocoder_result = IpLookupService.new.perform(request.remote_ip)
return unless geocoder_result
country_enabled = countries.include?(geocoder_result.country_code)
render json: { error: 'Location is not supported' }, status: :unauthorized unless country_enabled
end
end