feat: Add company model and API with tests (#12548)

# Pull Request Template

## Description

* add Company model with validations for name, domain, description and
  avatar
* Add database migration fo
* Implement endpoints for company CRUD operations
* Add optional company relationship for contacts
* Add test for models, controllers, factories and policies
* Add authorization policies restricting delete to admins
* support JSON API responses
Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.

Fixes #(cw-5650)

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Tests are implemented using `RSpec`

```
$ bundle exec rails db:migrate
$ bundle exec rspec spec/models/company_spec.rb spec/controllers/api/v1/accounts/companies_controller_spec.rb
```

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] 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
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Vinay Keerthi
2025-10-08 20:23:43 +05:30
committed by GitHub
parent 606adffeeb
commit 170ea7691f
23 changed files with 398 additions and 2 deletions

View File

@@ -21,6 +21,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# company_id :bigint
#
# Indexes
#
@@ -28,6 +29,7 @@
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
# index_contacts_on_blocked (blocked)
# index_contacts_on_company_id (company_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
@@ -244,3 +246,4 @@ class Contact < ApplicationRecord
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
end
end
Contact.include_mod_with('Concerns::Contact')

View File

@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE), not null
# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string

View File

@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE), not null
# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string

View File

@@ -76,6 +76,9 @@ en:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
companies:
domain:
invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal

View File

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

View File

@@ -0,0 +1,14 @@
class CreateCompanies < ActiveRecord::Migration[7.1]
def change
create_table :companies do |t|
t.string :name, null: false
t.string :domain
t.text :description
t.references :account, null: false
t.timestamps
end
add_index :companies, [:name, :account_id]
add_index :companies, [:domain, :account_id]
end
end

View File

@@ -0,0 +1,5 @@
class AddCompanyToContacts < ActiveRecord::Migration[7.1]
def change
add_reference :contacts, :company, null: true
end
end

View File

@@ -570,6 +570,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
end
create_table "companies", force: :cascade do |t|
t.string "name", null: false
t.string "domain"
t.text "description"
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_companies_on_account_id"
t.index ["domain", "account_id"], name: "index_companies_on_domain_and_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
end
create_table "contact_inboxes", force: :cascade do |t|
t.bigint "contact_id"
t.bigint "inbox_id"
@@ -602,6 +614,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.string "location", default: ""
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
t.bigint "company_id"
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
@@ -609,6 +622,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["blocked"], name: "index_contacts_on_blocked"
t.index ["company_id"], name: "index_contacts_on_company_id"
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin

View File

@@ -0,0 +1,40 @@
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :check_authorization
before_action :fetch_company, only: [:show, :update, :destroy]
def index
@companies = Current.account.companies.ordered_by_name
end
def show; end
def create
@company = Current.account.companies.build(company_params)
@company.save!
end
def update
@company.update!(company_params)
end
def destroy
@company.destroy!
head :ok
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
authorize(Company)
end
def fetch_company
@company = Current.account.companies.find(params[:id])
end
def company_params
params.require(:company).permit(:name, :domain, :description, :avatar)
end
end

View File

@@ -0,0 +1,33 @@
# == Schema Information
#
# 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
#
# Indexes
#
# index_companies_on_account_id (account_id)
# index_companies_on_domain_and_account_id (domain,account_id)
# index_companies_on_name_and_account_id (name,account_id)
#
class Company < ApplicationRecord
include Avatarable
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 :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
belongs_to :account
has_many :contacts, dependent: :nullify
scope :ordered_by_name, -> { order(:name) }
end

View File

@@ -13,6 +13,7 @@ module Enterprise::Concerns::Account
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'

View File

@@ -0,0 +1,6 @@
module Enterprise::Concerns::Contact
extend ActiveSupport::Concern
included do
belongs_to :company, optional: true
end
end

View File

@@ -0,0 +1,21 @@
class CompanyPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def create?
true
end
def update?
true
end
def destroy?
@account_user.administrator?
end
end

View File

@@ -0,0 +1,7 @@
json.id company.id
json.name company.name
json.domain company.domain
json.description company.description
json.avatar_url company.avatar_url
json.created_at company.created_at
json.updated_at company.updated_at

View File

@@ -0,0 +1,3 @@
json.payload do
json.partial! 'company', company: @company
end

View File

@@ -0,0 +1,5 @@
json.payload do
json.array! @companies do |company|
json.partial! 'company', company: company
end
end

View File

@@ -0,0 +1,3 @@
json.payload do
json.partial! 'company', company: @company
end

View File

@@ -0,0 +1,3 @@
json.payload do
json.partial! 'company', company: @company
end

View File

@@ -6,6 +6,8 @@ module Limits
GREETING_MESSAGE_MAX_LENGTH = 10_000
CATEGORIES_PER_PAGE = 1000
AUTO_ASSIGNMENT_BULK_LIMIT = 100
COMPANY_NAME_LENGTH_LIMIT = 100
COMPANY_DESCRIPTION_LENGTH_LIMIT = 1000
MAX_CUSTOM_FILTERS_PER_USER = 1000
def self.conversation_message_per_minute_limit

View File

@@ -0,0 +1,141 @@
require 'rails_helper'
RSpec.describe 'Companies API', type: :request do
let(:account) { create(:account) }
describe 'GET /api/v1/accounts/{account.id}/companies' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/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!(:company1) { create(:company, name: 'Company 1', account: account) }
let!(:company2) { create(:company, account: account) }
it 'returns all companies' do
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(2)
expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'returns the company' do
get "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq(company.name)
expect(response_body['payload']['id']).to eq(company.id)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/companies' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:valid_params) do
{
company: {
name: 'New Company',
domain: 'newcompany.com',
description: 'A new company'
}
}
end
it 'creates a new company' do
expect do
post "/api/v1/accounts/#{account.id}/companies",
params: valid_params,
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(1)
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq('New Company')
expect(response_body['payload']['domain']).to eq('newcompany.com')
end
it 'returns error for invalid params' do
invalid_params = { company: { name: '' } }
post "/api/v1/accounts/#{account.id}/companies",
params: invalid_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
let(:update_params) do
{
company: {
name: 'Updated Company Name',
domain: 'updated.com'
}
}
end
it 'updates the company' do
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}",
params: update_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq('Updated Company Name')
expect(response_body['payload']['domain']).to eq('updated.com')
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated administrator' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'deletes the company' do
company
expect do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(-1)
expect(response).to have_http_status(:ok)
end
end
context 'when it is a regular agent' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:company) { create(:company, account: account) }
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end

View File

@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Company, type: :model do
context 'with validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(100) }
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
describe 'domain validation' do
it { is_expected.to allow_value('example.com').for(:domain) }
it { is_expected.to allow_value('sub.example.com').for(:domain) }
it { is_expected.to allow_value('').for(:domain) }
it { is_expected.to allow_value(nil).for(:domain) }
it { is_expected.not_to allow_value('invalid-domain').for(:domain) }
it { is_expected.not_to allow_value('.example.com').for(:domain) }
end
end
context 'with associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:contacts).dependent(:nullify) }
end
describe 'scopes' do
let(:account) { create(:account) }
let!(:company_b) { create(:company, name: 'B Company', account: account) }
let!(:company_a) { create(:company, name: 'A Company', account: account) }
let!(:company_c) { create(:company, name: 'C Company', account: account) }
describe '.ordered_by_name' do
it 'orders companies by name alphabetically' do
companies = described_class.where(account: account).ordered_by_name
expect(companies.map(&:name)).to eq([company_a.name, company_b.name, company_c.name])
end
end
end
end

View File

@@ -0,0 +1,33 @@
require 'rails_helper'
RSpec.describe CompanyPolicy, type: :policy do
subject(:company_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:company) { create(:company, account: account) }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index?, :show?, :create?, :update? do
context 'when administrator' do
it { expect(company_policy).to permit(administrator_context, company) }
end
context 'when agent' do
it { expect(company_policy).to permit(agent_context, company) }
end
end
permissions :destroy? do
context 'when administrator' do
it { expect(company_policy).to permit(administrator_context, company) }
end
context 'when agent' do
it { expect(company_policy).not_to permit(agent_context, company) }
end
end
end

View File

@@ -0,0 +1,20 @@
FactoryBot.define do
factory :company do
sequence(:name) { |n| "Company #{n}" }
sequence(:domain) { |n| "company#{n}.com" }
description { 'A sample company description' }
account
trait :without_domain do
domain { nil }
end
trait :with_avatar do
avatar { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
end
trait :with_long_description do
description { 'A' * 500 }
end
end
end