feat: Add agent capacity controllers (#12200)

## Linear reference:
https://linear.app/chatwoot/issue/CW-4649/re-imagine-assignments

## Description
This PR introduces the foundation for Assignment V2 system by
implementing agent_capacity and their association with inboxes and
users.

## Type of change

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

## How Has This Been Tested?

Test Coverage:
-  Controller specs for assignment policies CRUD operations
-  Enterprise-specific specs for balanced assignment order
-  Model specs for community/enterprise separation

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] My changes generate no new warnings
- [ ] 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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Tanmay Deep Sharma
2025-08-27 09:12:58 +07:00
committed by GitHub
parent 39dfa35229
commit ad90deb709
26 changed files with 679 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
require 'rails_helper'
RSpec.describe 'Agent Capacity Policy Inbox Limits API', type: :request do
let(:account) { create(:account) }
let!(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
let!(:inbox) { create(:inbox, account: account) }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
describe 'POST /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/inbox_limits' do
context 'when not admin' do
it 'requires admin role' do
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits",
params: { inbox_id: inbox.id, conversation_limit: 10 },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when admin' do
it 'creates an inbox limit' do
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits",
params: { inbox_id: inbox.id, conversation_limit: 10 },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['conversation_limit']).to eq(10)
expect(json_response['inbox_id']).to eq(inbox.id)
end
it 'prevents duplicate inbox assignments' do
create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: inbox)
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits",
params: { inbox_id: inbox.id, conversation_limit: 10 },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('agent_capacity_policy.inbox_already_assigned'))
end
end
end
describe 'PUT /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/inbox_limits/{id}' do
let!(:inbox_limit) { create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: inbox, conversation_limit: 5) }
context 'when admin' do
it 'updates the inbox limit' do
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits/#{inbox_limit.id}",
params: { conversation_limit: 15 },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['conversation_limit']).to eq(15)
expect(inbox_limit.reload.conversation_limit).to eq(15)
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/inbox_limits/{id}' do
let!(:inbox_limit) { create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: inbox) }
context 'when admin' do
it 'removes the inbox limit' do
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits/#{inbox_limit.id}",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:no_content)
expect(agent_capacity_policy.inbox_capacity_limits.find_by(id: inbox_limit.id)).to be_nil
end
end
end
end

View File

@@ -0,0 +1,66 @@
require 'rails_helper'
RSpec.describe 'Agent Capacity Policy Users API', type: :request do
let(:account) { create(:account) }
let!(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
let!(:user) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
describe 'GET /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/users' do
context 'when admin' do
it 'returns assigned users' do
user.account_users.first.update!(agent_capacity_policy: agent_capacity_policy)
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body.first['id']).to eq(user.id)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/users' do
context 'when not admin' do
it 'requires admin role' do
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users",
params: { user_id: user.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when admin' do
it 'assigns user to the policy' do
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users",
params: { user_id: user.id },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(user.account_users.first.reload.agent_capacity_policy).to eq(agent_capacity_policy)
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/users/{id}' do
context 'when admin' do
before do
user.account_users.first.update!(agent_capacity_policy: agent_capacity_policy)
end
it 'removes user from the policy' do
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users/#{user.id}",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(user.account_users.first.reload.agent_capacity_policy).to be_nil
end
end
end
end

View File

@@ -0,0 +1,202 @@
require 'rails_helper'
RSpec.describe 'Agent Capacity Policies API', type: :request do
let(:account) { create(:account) }
let!(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
describe 'GET /api/v1/accounts/{account.id}/agent_capacity_policies' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/agent_capacity_policies"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized for agent' do
get "/api/v1/accounts/#{account.id}/agent_capacity_policies",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an administrator' do
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'returns all agent capacity policies' do
get "/api/v1/accounts/#{account.id}/agent_capacity_policies",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body.first['id']).to eq(agent_capacity_policy.id)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/agent_capacity_policies/{id}' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized for agent' do
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an administrator' do
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'returns the agent capacity policy' do
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['id']).to eq(agent_capacity_policy.id)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/agent_capacity_policies' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/agent_capacity_policies"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'returns unauthorized for agent' do
params = { agent_capacity_policy: { name: 'Test Policy' } }
post "/api/v1/accounts/#{account.id}/agent_capacity_policies",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'creates a new agent capacity policy when administrator' do
params = {
agent_capacity_policy: {
name: 'Test Policy',
description: 'Test Description',
exclusion_rules: { overall_capacity: 10 }
}
}
post "/api/v1/accounts/#{account.id}/agent_capacity_policies",
params: params,
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['name']).to eq('Test Policy')
expect(response.parsed_body['description']).to eq('Test Description')
end
it 'returns validation errors for invalid data' do
params = { agent_capacity_policy: { name: '' } }
post "/api/v1/accounts/#{account.id}/agent_capacity_policies",
params: params,
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'PUT /api/v1/accounts/{account.id}/agent_capacity_policies/{id}' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'returns unauthorized for agent' do
params = { agent_capacity_policy: { name: 'Updated Policy' } }
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'updates the agent capacity policy when administrator' do
params = { agent_capacity_policy: { name: 'Updated Policy' } }
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
params: params,
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['name']).to eq('Updated Policy')
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/agent_capacity_policies/{id}' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'returns unauthorized for agent' do
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'deletes the agent capacity policy when administrator' do
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect { agent_capacity_policy.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end

View File

@@ -0,0 +1,29 @@
require 'rails_helper'
RSpec.describe AgentCapacityPolicy, type: :model do
let(:account) { create(:account) }
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
end
describe 'destruction' do
let(:policy) { create(:agent_capacity_policy, account: account) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
it 'destroys associated inbox capacity limits' do
create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
expect { policy.destroy }.to change(InboxCapacityLimit, :count).by(-1)
end
it 'nullifies associated account users' do
account_user = user.account_users.first
account_user.update!(agent_capacity_policy: policy)
policy.destroy
expect(account_user.reload.agent_capacity_policy).to be_nil
end
end
end

View File

@@ -0,0 +1,33 @@
require 'rails_helper'
RSpec.describe InboxCapacityLimit, type: :model do
let(:account) { create(:account) }
let(:policy) { create(:agent_capacity_policy, account: account) }
let(:inbox) { create(:inbox, account: account) }
describe 'validations' do
subject { create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox) }
it { is_expected.to validate_presence_of(:conversation_limit) }
it { is_expected.to validate_numericality_of(:conversation_limit).is_greater_than(0).only_integer }
it { is_expected.to validate_uniqueness_of(:inbox_id).scoped_to(:agent_capacity_policy_id) }
end
describe 'uniqueness constraint' do
it 'prevents duplicate inbox limits for the same policy' do
create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
duplicate = build(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
expect(duplicate).not_to be_valid
expect(duplicate.errors[:inbox_id]).to include('has already been taken')
end
it 'allows the same inbox in different policies' do
other_policy = create(:agent_capacity_policy, account: account)
create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
different_policy_limit = build(:inbox_capacity_limit, agent_capacity_policy: other_policy, inbox: inbox)
expect(different_policy_limit).to be_valid
end
end
end

View File

@@ -0,0 +1,21 @@
FactoryBot.define do
factory :agent_capacity_policy do
account
sequence(:name) { |n| "Agent Capacity Policy #{n}" }
description { 'Test agent capacity policy' }
exclusion_rules { {} }
trait :with_overall_capacity do
exclusion_rules { { 'overall_capacity' => 10 } }
end
trait :with_time_exclusions do
exclusion_rules do
{
'hours' => [0, 1, 2, 3, 4, 5],
'days' => %w[saturday sunday]
}
end
end
end
end

View File

@@ -0,0 +1,7 @@
FactoryBot.define do
factory :inbox_capacity_limit do
association :agent_capacity_policy, factory: :agent_capacity_policy
inbox
conversation_limit { 5 }
end
end