feat: Backend - Companies API endpoint with pagination and search (#12840)

## Description

Adds API endpoint to list companies with pagination, search, and
sorting.

Fixes
https://linear.app/chatwoot/issue/CW-5930/add-backend-routes-to-get-companies-result
Parent issue:
https://linear.app/chatwoot/issue/CW-5928/add-companies-tab-to-dashboard

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Added comprehensive specs to
`spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb`:
- Pagination (25 per page, multiple pages)
- Search by name and domain (case-insensitive)
- Counter cache for contacts_count
- Account scoping
- Authorization

To reproduce:
```bash
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
bundle exec rubocop enterprise/app/controllers/api/v1/accounts/companies_controller.rb
```

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Vinay Keerthi
2025-11-18 14:28:56 +05:30
committed by GitHub
parent 7acf3c8817
commit 58ca82c720
15 changed files with 282 additions and 16 deletions

View File

@@ -0,0 +1,13 @@
class Migration::BackfillCompaniesContactsCountJob < ApplicationJob
queue_as :async_database_migration
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
end

View File

@@ -80,6 +80,8 @@ en:
companies:
domain:
invalid: must be a valid domain name
search:
query_missing: Specify search string with parameter q
categories:
locale:
unique: should be unique in the category and portal

View File

@@ -154,7 +154,11 @@ Rails.application.routes.draw do
end
end
resources :companies, only: [:index, :show, :create, :update, :destroy]
resources :companies, only: [:index, :show, :create, :update, :destroy] do
collection do
get :search
end
end
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
collection do
get :active

View File

@@ -0,0 +1,5 @@
class AddContactsCountToCompanies < ActiveRecord::Migration[7.1]
def change
add_column :companies, :contacts_count, :integer, default: 0, null: false
end
end

View File

@@ -577,6 +577,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_14_173609) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "contacts_count"
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
t.index ["account_id"], name: "index_companies_on_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"

View File

@@ -1,9 +1,29 @@
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
include Sift
sort_on :name, type: :string
sort_on :domain, type: :string
sort_on :created_at, type: :datetime
RESULTS_PER_PAGE = 25
before_action :check_authorization
before_action :set_current_page, only: [:index, :search]
before_action :fetch_company, only: [:show, :update, :destroy]
def index
@companies = Current.account.companies.ordered_by_name
@companies = fetch_companies(resolved_companies)
@companies_count = @companies.total_count
end
def search
if params[:q].blank?
return render json: { error: I18n.t('errors.companies.search.query_missing') },
status: :unprocessable_entity
end
companies = resolved_companies.search_by_name_or_domain(params[:q])
@companies = fetch_companies(companies)
@companies_count = @companies.total_count
end
def show; end
@@ -24,6 +44,20 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
private
def resolved_companies
@resolved_companies ||= Current.account.companies
end
def set_current_page
@current_page = params[:page] || 1
end
def fetch_companies(companies)
filtrate(companies)
.page(@current_page)
.per(RESULTS_PER_PAGE)
end
def check_authorization
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?

View File

@@ -2,13 +2,14 @@
#
# Table name: companies
#
# id :bigint not null, primary key
# description :text
# domain :string
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# id :bigint not null, primary key
# contacts_count :integer
# description :text
# domain :string
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
@@ -31,4 +32,7 @@ class Company < ApplicationRecord
has_many :contacts, dependent: :nullify
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}%")
}
end

View File

@@ -1,7 +1,7 @@
module Enterprise::Concerns::Contact
extend ActiveSupport::Concern
included do
belongs_to :company, optional: true
belongs_to :company, optional: true, counter_cache: true
after_commit :associate_company_from_email,
on: [:create, :update],

View File

@@ -3,6 +3,10 @@ class CompanyPolicy < ApplicationPolicy
true
end
def search?
true
end
def show?
true
end

View File

@@ -3,12 +3,13 @@ class Contacts::CompanyAssociationService
return nil if skip_association?(contact)
company = find_or_create_company(contact)
# rubocop:disable Rails/SkipsModelValidations
# Intentionally using update_column here to:
# 1. Avoid triggering callbacks
# 2. Improve performance (We're only setting company_id, no need for validation)
contact.update_column(:company_id, company.id) if company
# rubocop:enable Rails/SkipsModelValidations
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
end
company
end

View File

@@ -1,5 +1,6 @@
json.id company.id
json.name company.name
json.contacts_count company.contacts_count
json.domain company.domain
json.description company.description
json.avatar_url company.avatar_url

View File

@@ -1,3 +1,8 @@
json.meta do
json.total_count @companies_count
json.page @current_page
end
json.payload do
json.array! @companies do |company|
json.partial! 'company', company: company

View File

@@ -0,0 +1,10 @@
json.meta do
json.total_count @companies_count
json.page @current_page
end
json.payload do
json.array! @companies do |company|
json.partial! 'company', company: company
end
end

View File

@@ -25,10 +25,150 @@ RSpec.describe 'Companies API', type: :request do
expect(response_body['payload'].size).to eq(2)
expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
end
it 'returns companies with pagination' do
create_list(:company, 30, account: account)
get "/api/v1/accounts/#{account.id}/companies",
params: { page: 1 },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].size).to eq(25)
expect(response_body['meta']['total_count']).to eq(32)
expect(response_body['meta']['page']).to eq(1)
end
it 'returns second page of companies' do
create_list(:company, 30, account: account)
get "/api/v1/accounts/#{account.id}/companies",
params: { page: 2 },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].size).to eq(7)
expect(response_body['meta']['total_count']).to eq(32)
expect(response_body['meta']['page']).to eq(2)
end
it 'returns companies with contacts_count' do
company_with_contacts = create(:company, name: 'Company With Contacts', account: account)
create_list(:contact, 5, company: company_with_contacts, account: account)
get "/api/v1/accounts/#{account.id}/companies",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
company_data = response_body['payload'].find { |c| c['id'] == company_with_contacts.id }
expect(company_data['contacts_count']).to eq(5)
end
it 'does not return companies from other accounts' do
other_account = create(:account)
create(:company, name: 'Other Account Company', account: other_account)
create(:company, name: 'My Company', account: account)
get "/api/v1/accounts/#{account.id}/companies",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].size).to eq(3)
expect(response_body['payload'].map { |c| c['name'] }).not_to include('Other Account Company')
end
end
end
describe 'GET /api/v1/accounts/{account.id}/companies/search' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/companies/search"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'returns error when q parameter is missing' do
get "/api/v1/accounts/#{account.id}/companies/search",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Specify search string with parameter q')
end
it 'searches companies by name' do
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
create(:company, name: 'Tech Solutions', domain: 'tech.com', account: account)
create(:company, name: 'Global Inc', domain: 'global.com', account: account)
get "/api/v1/accounts/#{account.id}/companies/search",
params: { q: 'tech' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].size).to eq(1)
expect(response_body['payload'].first['name']).to eq('Tech Solutions')
end
it 'searches companies by domain' do
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
create(:company, name: 'Tech Solutions', domain: 'tech.com', account: account)
create(:company, name: 'Global Inc', domain: 'global.com', account: account)
get "/api/v1/accounts/#{account.id}/companies/search",
params: { q: 'acme.com' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].size).to eq(1)
expect(response_body['payload'].first['domain']).to eq('acme.com')
end
it 'search is case insensitive' do
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
get "/api/v1/accounts/#{account.id}/companies/search",
params: { q: 'ACME' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].size).to eq(1)
end
it 'returns empty array when no companies match search' do
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
get "/api/v1/accounts/#{account.id}/companies/search",
params: { q: 'nonexistent' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].size).to eq(0)
expect(response_body['meta']['total_count']).to eq(0)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
company = create(:company, account: account)
get "/api/v1/accounts/#{account.id}/companies/#{company.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
@@ -46,6 +186,13 @@ RSpec.describe 'Companies API', type: :request do
end
describe 'POST /api/v1/accounts/{account.id}/companies' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/companies"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:valid_params) do
@@ -85,6 +232,14 @@ RSpec.describe 'Companies API', type: :request do
end
describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
company = create(:company, account: account)
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
@@ -111,6 +266,14 @@ RSpec.describe 'Companies API', type: :request do
end
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
company = create(:company, account: account)
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated administrator' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }

View File

@@ -45,6 +45,25 @@ RSpec.describe Contacts::CompanyAssociationService, type: :service do
contact.reload
expect(contact.company).to eq(existing_company)
end
it 'increments company contacts_count when associating contact' do
# Create contact without email to avoid auto-association
contact = create(:contact, email: nil, account: account)
# Manually set email to bypass callbacks
# rubocop:disable Rails/SkipsModelValidations
contact.update_column(:email, 'jane@techcorp.com')
# rubocop:enable Rails/SkipsModelValidations
valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false)
allow(ValidEmail2::Address).to receive(:new).with('jane@techcorp.com').and_return(valid_email_address)
allow(EmailProviderInfo).to receive(:call).with('jane@techcorp.com').and_return(nil)
service.associate_company_from_email(contact)
contact.reload
expect(contact.company).to be_present
expect(contact.company.contacts_count).to eq(1)
end
end
context 'when contact already has a company' do