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:
13
app/jobs/migration/backfill_companies_contacts_count_job.rb
Normal file
13
app/jobs/migration/backfill_companies_contacts_count_job.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddContactsCountToCompanies < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :companies, :contacts_count, :integer, default: 0, null: false
|
||||
end
|
||||
end
|
||||
@@ -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"
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -3,6 +3,10 @@ class CompanyPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def search?
|
||||
true
|
||||
end
|
||||
|
||||
def show?
|
||||
true
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user