Files
gochat/docs/requirements/M1-codegraph-context.md
T
2026-06-04 15:44:48 +08:00

10 KiB

Code Context

Query: account management, user authentication, profile, roles, permissions, SAML SSO, account_user, custom_role, company

Entry Points

  • Saml::UpdateAccountUsersProviderJob (class) - enterprise/app/jobs/saml/update_account_users_provider_job.rb:1
  • AddAgentCapacityPolicyToAccountUsers (class) - db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb:3
  • Company (class) - enterprise/app/models/company.rb:23
  • app/jobs/application_job.rb: ApplicationJob:1
  • app/models/application_record.rb: ApplicationRecord:1
  • enterprise/app/jobs/saml/update_account_users_provider_job.rb: perform:6, should_update_user_provider?:22, user_has_other_saml_accounts?:30
  • enterprise/app/models/account_saml_settings.rb: update_account_users_provider:64, reset_account_users_provider:68
  • db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb: change:4
  • enterprise/app/models/company.rb: record_activity_at!:63, prepare_jsonb_attributes:71

Code

Saml::UpdateAccountUsersProviderJob (enterprise/app/jobs/saml/update_account_users_provider_job.rb:1)

class Saml::UpdateAccountUsersProviderJob < ApplicationJob
  queue_as :default

  # Updates the authentication provider for users in an account
  # This job is triggered when SAML settings are created or destroyed
  def perform(account_id, provider)
    account = Account.find(account_id)
    account.users.find_each(batch_size: 1000) do |user|
      next unless should_update_user_provider?(user, provider)

      # rubocop:disable Rails/SkipsModelValidations
      user.update_column(:provider, provider)
      # rubocop:enable Rails/SkipsModelValidations
    end
  end

  private

  # Determines if a user's provider should be updated based on their multi-account status
  # When resetting to 'email', only update users who don't have SAML enabled on other accounts
  # This prevents breaking SAML authentication for users who belong to multiple accounts
  def should_update_user_provider?(user, provider)
    return !user_has_other_saml_accounts?(user) if provider == 'email'

    true
  end

  # Checks if the user belongs to any other accounts that have SAML configured
  # Used to preserve SAML authentication when one account disables SAML but others still use it
  def user_has_other_saml_accounts?(user)
    user.accounts.joins(:saml_settings).exists?
  end
end

AddAgentCapacityPolicyToAccountUsers (db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb:3)

class AddAgentCapacityPolicyToAccountUsers < ActiveRecord::Migration[7.1]
  def change
    add_reference :account_users, :agent_capacity_policy, null: true, index: true
  end
end

Company (enterprise/app/models/company.rb:23)

class Company < ApplicationRecord
  include Avatarable

  ACTIVITY_ROLLUP_INTERVAL = 5.minutes

  validates :account_id, presence: true
  validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
  validates :domain, allow_blank: true, format: {
    with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/,
    message: I18n.t('errors.companies.domain.invalid')
  }
  validates :domain, uniqueness: { scope: :account_id }, if: -> { domain.present? }
  validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
  validates :custom_attributes, jsonb_attributes_length: true

  belongs_to :account
  has_many :contacts, dependent: :nullify
  before_validation :prepare_jsonb_attributes
  after_create_commit :fetch_favicon, if: -> { domain.present? }

  scope :ordered_by_name, -> { order(:name) }
  scope :search_by_name_or_domain, lambda { |query|
    where('name ILIKE :search OR domain ILIKE :search', search: "%#{query.strip}%")
  }

  scope :order_on_contacts_count, lambda { |direction|
    order(
      Arel::Nodes::SqlLiteral.new(
        sanitize_sql_for_order("\"companies\".\"contacts_count\" #{direction} NULLS LAST")
      )
    )
  }
  scope :order_on_last_activity_at, lambda { |direction|
    order(
      Arel::Nodes::SqlLiteral.new(
        sanitize_sql_for_order("\"companies\".\"last_activity_at\" #{direction} NULLS LAST")
      )
    )
  }

  def record_activity_at!(activity_at)
    
// ... truncated ...

perform (enterprise/app/jobs/saml/update_account_users_provider_job.rb:6)

  def perform(account_id, provider)
    account = Account.find(account_id)
    account.users.find_each(batch_size: 1000) do |user|
      next unless should_update_user_provider?(user, provider)

      # rubocop:disable Rails/SkipsModelValidations
      user.update_column(:provider, provider)
      # rubocop:enable Rails/SkipsModelValidations
    end
  end

should_update_user_provider? (enterprise/app/jobs/saml/update_account_users_provider_job.rb:22)

  def should_update_user_provider?(user, provider)
    return !user_has_other_saml_accounts?(user) if provider == 'email'

    true
  end

user_has_other_saml_accounts? (enterprise/app/jobs/saml/update_account_users_provider_job.rb:30)

  def user_has_other_saml_accounts?(user)
    user.accounts.joins(:saml_settings).exists?
  end

update_account_users_provider (enterprise/app/models/account_saml_settings.rb:64)

  def update_account_users_provider
    Saml::UpdateAccountUsersProviderJob.perform_later(account_id, 'saml')
  end

reset_account_users_provider (enterprise/app/models/account_saml_settings.rb:68)

  def reset_account_users_provider
    Saml::UpdateAccountUsersProviderJob.perform_later(account_id, 'email')
  end

change (db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb:4)

  def change
    add_reference :account_users, :agent_capacity_policy, null: true, index: true
  end

record_activity_at! (enterprise/app/models/company.rb:63)

  def record_activity_at!(activity_at)
    return if last_activity_at.present? && last_activity_at > activity_at - ACTIVITY_ROLLUP_INTERVAL

    update!(last_activity_at: activity_at)
  end

prepare_jsonb_attributes (enterprise/app/models/company.rb:71)

  def prepare_jsonb_attributes
    self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
    self.custom_attributes = {} unless custom_attributes.is_a?(Hash)
  end

fetch_favicon (enterprise/app/models/company.rb:76)

  def fetch_favicon
    Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
  end

perform (app/jobs/migration/backfill_companies_contacts_count_job.rb:4)

  def perform
    return unless ChatwootApp.enterprise?

    Company.find_in_batches(batch_size: 100) do |company_batch|
      company_batch.each do |company|
        Company.reset_counters(company.id, :contacts)
      end
    end
  end

find_or_create_company (enterprise/app/jobs/migration/company_account_batch_job.rb:32)

  def find_or_create_company(contact, account)
    domain = extract_domain(contact.email)
    company_name = derive_company_name(contact, domain)

    Company.find_or_create_by!(account: account, domain: domain) do |company|
      company.name = company_name
    end
  rescue ActiveRecord::RecordNotUnique
    # Race condition: Another job created it between our check and create
    # just find the one that was created

    Company.find_by(account: account, domain: domain)
  end

associate_company_from_email (enterprise/app/services/contacts/company_association_service.rb:2)

  def associate_company_from_email(contact)
    return nil if skip_association?(contact)

    company = find_or_create_company(contact)
    if company
      # rubocop:disable Rails/SkipsModelValidations
      # Using update_column and increment_counter to avoid triggering callbacks while maintaining counter cache
      contact.update_column(:company_id, company.id)
      Company.increment_counter(:contacts_count, company.id)
      # rubocop:enable Rails/SkipsModelValidations
      company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
    end
    company
  end

find_or_create_company (enterprise/app/services/contacts/company_association_service.rb:29)

  def find_or_create_company(contact)
    domain = extract_domain(contact.email)
    company_name = derive_company_name(contact, domain)

    Company.find_or_create_by!(account: contact.account, domain: domain) do |company|
      company.name = company_name
    end
  rescue ActiveRecord::RecordNotUnique
    # If another process created it first, just find that
    Company.find_by(account: contact.account, domain: domain)
  end

ApplicationJob (app/jobs/application_job.rb:1)

class ApplicationJob < ActiveJob::Base
  # https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html
  discard_on ActiveJob::DeserializationError do |job, error|
    Rails.logger.info("Skipping #{job.class} with #{
      job.instance_variable_get(:@serialized_arguments)
    } because of ActiveJob::DeserializationError (#{error.message})")
  end
end

ApplicationRecord (app/models/application_record.rb:1)

class ApplicationRecord < ActiveRecord::Base
  include Events::Types
  self.abstract_class = true

  before_validation :validates_column_content_length

  # the models that exposed in email templates through liquid
  def droppables
    %w[Account Channel Conversation Inbox User Message]
  end

  # ModelDrop class should exist in app/drops
  def to_drop
    return unless droppables.include?(self.class.name)

    "#{self.class.name}Drop".constantize.new(self)
  end

  private

  # Generic validation for all columns of type string and text
  # Validates the length of the column to prevent DOS via large payloads
  # if a custom length validation is already present, skip the validation
  def validates_column_content_length
    self.class.columns.each do |column|
      check_and_validate_content_length(column) if column_of_type_string_or_text?(column)
    end
  end

  def column_of_type_string_or_text?(column)
    %i[string text].include?(column.type)
  end

  def check_and_validate_content_length(column)
    length_validator = self.class.validators_on(column.name).find { |v| v.kind == :length }
    validate_content_length(column) if length_validator.blank?
  end

  def validate_content_length(column)
    max_length = column.type == :text ? 20_000 : 255
    return if self[column.name].nil? || self[column.name].length <= max_length

    errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
  end

  def normalize_empty_string_to_nil(attrs = [])
    attrs
// ... truncated ...