From 341487b93e26c990f8b1db5d9093c3adb02eccc0 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 19 Aug 2025 07:45:21 +0530 Subject: [PATCH 1/6] feat: Add assignment policies controllers with jbuilder views (#12199) ## Linear reference: https://linear.app/chatwoot/issue/CW-4649/re-imagine-assignments ## Description This PR introduces the foundation for Assignment V2 system by implementing assignment policies and their association with inboxes. Assignment policies allow configuring how conversations are distributed among agents, with support for different assignment orders (round_robin in community, balanced in enterprise) and conversation prioritization strategies Fixes # (issue) ## Type of change Please delete options that are not relevant. - [ ] 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? Test Coverage: - Controller specs for assignment policies CRUD operations - Enterprise-specific specs for balanced assignment order - Model specs for community/enterprise separation Manual Testing: 1. Create assignment policy: POST /api/v1/accounts/{id}/assignment_policies 2. List policies: GET /api/v1/accounts/{id}/assignment_policies 3. Assign policy to inbox: POST /api/v1/accounts/{id}/assignment_policies/{id}/inboxes 4. View inbox policy: GET /api/v1/accounts/{id}/inboxes/{id}/assignment_policy 5. Verify community edition ignores "balanced" assignment order 6. Verify enterprise edition supports both "round_robin" and "balanced" - testing the flows after enterprise folder deletion ## 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 Co-authored-by: Pranav --- .../assignment_policies/inboxes_controller.rb | 20 ++ .../assignment_policies_controller.rb | 36 ++ .../inboxes/assignment_policies_controller.rb | 46 +++ app/models/account.rb | 1 + app/models/assignment_policy.rb | 37 ++ app/models/inbox.rb | 2 + app/models/inbox_assignment_policy.rb | 21 ++ app/policies/assignment_policy_policy.rb | 21 ++ .../_assignment_policy.json.jbuilder | 10 + .../assignment_policies/create.json.jbuilder | 1 + .../inboxes/create.json.jbuilder | 5 + .../inboxes/index.json.jbuilder | 3 + .../assignment_policies/index.json.jbuilder | 3 + .../assignment_policies/show.json.jbuilder | 1 + .../assignment_policies/update.json.jbuilder | 1 + .../assignment_policies/create.json.jbuilder | 1 + .../assignment_policies/show.json.jbuilder | 1 + config/features.yml | 4 + config/locales/en.yml | 2 + config/routes.rb | 9 + .../enterprise/concerns/assignment_policy.rb | 7 + .../inboxes_controller_spec.rb | 63 ++++ .../assignment_policies_controller_spec.rb | 326 ++++++++++++++++++ .../assignment_policies_controller_spec.rb | 195 +++++++++++ .../models/assignment_policy_spec.rb | 18 + spec/factories/assignment_policies.rb | 12 + spec/factories/inbox_assignment_policies.rb | 6 + spec/models/assignment_policy_spec.rb | 56 +++ 28 files changed, 908 insertions(+) create mode 100644 app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb create mode 100644 app/controllers/api/v1/accounts/assignment_policies_controller.rb create mode 100644 app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb create mode 100644 app/models/assignment_policy.rb create mode 100644 app/models/inbox_assignment_policy.rb create mode 100644 app/policies/assignment_policy_policy.rb create mode 100644 app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder create mode 100644 app/views/api/v1/accounts/assignment_policies/create.json.jbuilder create mode 100644 app/views/api/v1/accounts/assignment_policies/inboxes/create.json.jbuilder create mode 100644 app/views/api/v1/accounts/assignment_policies/inboxes/index.json.jbuilder create mode 100644 app/views/api/v1/accounts/assignment_policies/index.json.jbuilder create mode 100644 app/views/api/v1/accounts/assignment_policies/show.json.jbuilder create mode 100644 app/views/api/v1/accounts/assignment_policies/update.json.jbuilder create mode 100644 app/views/api/v1/accounts/inboxes/assignment_policies/create.json.jbuilder create mode 100644 app/views/api/v1/accounts/inboxes/assignment_policies/show.json.jbuilder create mode 100644 enterprise/app/models/enterprise/concerns/assignment_policy.rb create mode 100644 spec/controllers/api/v1/accounts/assignment_policies/inboxes_controller_spec.rb create mode 100644 spec/controllers/api/v1/accounts/assignment_policies_controller_spec.rb create mode 100644 spec/controllers/api/v1/accounts/inboxes/assignment_policies_controller_spec.rb create mode 100644 spec/enterprise/models/assignment_policy_spec.rb create mode 100644 spec/factories/assignment_policies.rb create mode 100644 spec/factories/inbox_assignment_policies.rb create mode 100644 spec/models/assignment_policy_spec.rb diff --git a/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb b/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb new file mode 100644 index 000000000..ac1d0a712 --- /dev/null +++ b/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb @@ -0,0 +1,20 @@ +class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController + before_action :fetch_assignment_policy + before_action -> { check_authorization(AssignmentPolicy) } + + def index + @inboxes = @assignment_policy.inboxes + end + + private + + def fetch_assignment_policy + @assignment_policy = Current.account.assignment_policies.find( + params[:assignment_policy_id] + ) + end + + def permitted_params + params.permit(:assignment_policy_id) + end +end diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb new file mode 100644 index 000000000..1807d6afb --- /dev/null +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController + before_action :fetch_assignment_policy, only: [:show, :update, :destroy] + before_action :check_authorization + + def index + @assignment_policies = Current.account.assignment_policies + end + + def show; end + + def create + @assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params) + end + + def update + @assignment_policy.update!(assignment_policy_params) + end + + def destroy + @assignment_policy.destroy! + head :ok + end + + private + + def fetch_assignment_policy + @assignment_policy = Current.account.assignment_policies.find(params[:id]) + end + + def assignment_policy_params + params.require(:assignment_policy).permit( + :name, :description, :assignment_order, :conversation_priority, + :fair_distribution_limit, :fair_distribution_window, :enabled + ) + end +end diff --git a/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb new file mode 100644 index 000000000..cf52951a5 --- /dev/null +++ b/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb @@ -0,0 +1,46 @@ +class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController + before_action :fetch_inbox + before_action :fetch_assignment_policy, only: [:create] + before_action -> { check_authorization(AssignmentPolicy) } + before_action :validate_assignment_policy, only: [:show, :destroy] + + def show + @assignment_policy = @inbox.assignment_policy + end + + def create + # There should be only one assignment policy for an inbox. + # If there is a new request to add an assignment policy, we will + # delete the old one and attach the new policy + remove_inbox_assignment_policy + @inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy) + @assignment_policy = @inbox.assignment_policy + end + + def destroy + remove_inbox_assignment_policy + head :ok + end + + private + + def remove_inbox_assignment_policy + @inbox.inbox_assignment_policy&.destroy + end + + def fetch_inbox + @inbox = Current.account.inboxes.find(permitted_params[:inbox_id]) + end + + def fetch_assignment_policy + @assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id]) + end + + def permitted_params + params.permit(:assignment_policy_id, :inbox_id) + end + + def validate_assignment_policy + return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy + end +end diff --git a/app/models/account.rb b/app/models/account.rb index f8eb998f0..b84d7f526 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -61,6 +61,7 @@ class Account < ApplicationRecord has_many :agent_bots, dependent: :destroy_async has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api' has_many :articles, dependent: :destroy_async, class_name: '::Article' + has_many :assignment_policies, dependent: :destroy_async has_many :automation_rules, dependent: :destroy_async has_many :macros, dependent: :destroy_async has_many :campaigns, dependent: :destroy_async diff --git a/app/models/assignment_policy.rb b/app/models/assignment_policy.rb new file mode 100644 index 000000000..c01ab91c4 --- /dev/null +++ b/app/models/assignment_policy.rb @@ -0,0 +1,37 @@ +# == Schema Information +# +# Table name: assignment_policies +# +# id :bigint not null, primary key +# assignment_order :integer default(0), not null +# conversation_priority :integer default("earliest_created"), not null +# description :text +# enabled :boolean default(TRUE), not null +# fair_distribution_limit :integer default(100), not null +# fair_distribution_window :integer default(3600), not null +# name :string(255) not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# +# Indexes +# +# index_assignment_policies_on_account_id (account_id) +# index_assignment_policies_on_account_id_and_name (account_id,name) UNIQUE +# index_assignment_policies_on_enabled (enabled) +# +class AssignmentPolicy < ApplicationRecord + belongs_to :account + has_many :inbox_assignment_policies, dependent: :destroy + has_many :inboxes, through: :inbox_assignment_policies + + validates :name, presence: true, uniqueness: { scope: :account_id } + validates :fair_distribution_limit, numericality: { greater_than: 0 } + validates :fair_distribution_window, numericality: { greater_than: 0 } + + enum conversation_priority: { earliest_created: 0, longest_waiting: 1 } + + enum assignment_order: { round_robin: 0 } unless ChatwootApp.enterprise? +end + +AssignmentPolicy.include_mod_with('Concerns::AssignmentPolicy') diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 1c898ba7f..27f096bfa 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -67,6 +67,8 @@ class Inbox < ApplicationRecord has_many :conversations, dependent: :destroy_async has_many :messages, dependent: :destroy_async + has_one :inbox_assignment_policy, dependent: :destroy + has_one :assignment_policy, through: :inbox_assignment_policy has_one :agent_bot_inbox, dependent: :destroy_async has_one :agent_bot, through: :agent_bot_inbox has_many :webhooks, dependent: :destroy_async diff --git a/app/models/inbox_assignment_policy.rb b/app/models/inbox_assignment_policy.rb new file mode 100644 index 000000000..c263ab40e --- /dev/null +++ b/app/models/inbox_assignment_policy.rb @@ -0,0 +1,21 @@ +# == Schema Information +# +# Table name: inbox_assignment_policies +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# assignment_policy_id :bigint not null +# inbox_id :bigint not null +# +# Indexes +# +# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id) +# index_inbox_assignment_policies_on_inbox_id (inbox_id) UNIQUE +# +class InboxAssignmentPolicy < ApplicationRecord + belongs_to :inbox + belongs_to :assignment_policy + + validates :inbox_id, uniqueness: true +end diff --git a/app/policies/assignment_policy_policy.rb b/app/policies/assignment_policy_policy.rb new file mode 100644 index 000000000..fcd0ee9bc --- /dev/null +++ b/app/policies/assignment_policy_policy.rb @@ -0,0 +1,21 @@ +class AssignmentPolicyPolicy < ApplicationPolicy + def index? + @account_user.administrator? + end + + def show? + @account_user.administrator? + end + + def create? + @account_user.administrator? + end + + def update? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end +end diff --git a/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder new file mode 100644 index 000000000..b48307a94 --- /dev/null +++ b/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder @@ -0,0 +1,10 @@ +json.id assignment_policy.id +json.name assignment_policy.name +json.description assignment_policy.description +json.assignment_order assignment_policy.assignment_order +json.conversation_priority assignment_policy.conversation_priority +json.fair_distribution_limit assignment_policy.fair_distribution_limit +json.fair_distribution_window assignment_policy.fair_distribution_window +json.enabled assignment_policy.enabled +json.created_at assignment_policy.created_at.to_i +json.updated_at assignment_policy.updated_at.to_i diff --git a/app/views/api/v1/accounts/assignment_policies/create.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/create.json.jbuilder new file mode 100644 index 000000000..8fd9543c3 --- /dev/null +++ b/app/views/api/v1/accounts/assignment_policies/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'assignment_policy', assignment_policy: @assignment_policy diff --git a/app/views/api/v1/accounts/assignment_policies/inboxes/create.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/inboxes/create.json.jbuilder new file mode 100644 index 000000000..c5aede050 --- /dev/null +++ b/app/views/api/v1/accounts/assignment_policies/inboxes/create.json.jbuilder @@ -0,0 +1,5 @@ +json.id @inbox_assignment_policy.id +json.inbox_id @inbox_assignment_policy.inbox_id +json.assignment_policy_id @inbox_assignment_policy.assignment_policy_id +json.created_at @inbox_assignment_policy.created_at.to_i +json.updated_at @inbox_assignment_policy.updated_at.to_i diff --git a/app/views/api/v1/accounts/assignment_policies/inboxes/index.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/inboxes/index.json.jbuilder new file mode 100644 index 000000000..5a22aa917 --- /dev/null +++ b/app/views/api/v1/accounts/assignment_policies/inboxes/index.json.jbuilder @@ -0,0 +1,3 @@ +json.inboxes @inboxes do |inbox| + json.partial! 'api/v1/models/inbox', formats: [:json], resource: inbox +end diff --git a/app/views/api/v1/accounts/assignment_policies/index.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/index.json.jbuilder new file mode 100644 index 000000000..0be431f87 --- /dev/null +++ b/app/views/api/v1/accounts/assignment_policies/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @assignment_policies do |assignment_policy| + json.partial! 'assignment_policy', assignment_policy: assignment_policy +end diff --git a/app/views/api/v1/accounts/assignment_policies/show.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/show.json.jbuilder new file mode 100644 index 000000000..8fd9543c3 --- /dev/null +++ b/app/views/api/v1/accounts/assignment_policies/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'assignment_policy', assignment_policy: @assignment_policy diff --git a/app/views/api/v1/accounts/assignment_policies/update.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/update.json.jbuilder new file mode 100644 index 000000000..8fd9543c3 --- /dev/null +++ b/app/views/api/v1/accounts/assignment_policies/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'assignment_policy', assignment_policy: @assignment_policy diff --git a/app/views/api/v1/accounts/inboxes/assignment_policies/create.json.jbuilder b/app/views/api/v1/accounts/inboxes/assignment_policies/create.json.jbuilder new file mode 100644 index 000000000..105658704 --- /dev/null +++ b/app/views/api/v1/accounts/inboxes/assignment_policies/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/accounts/assignment_policies/assignment_policy', formats: [:json], assignment_policy: @assignment_policy diff --git a/app/views/api/v1/accounts/inboxes/assignment_policies/show.json.jbuilder b/app/views/api/v1/accounts/inboxes/assignment_policies/show.json.jbuilder new file mode 100644 index 000000000..105658704 --- /dev/null +++ b/app/views/api/v1/accounts/inboxes/assignment_policies/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/accounts/assignment_policies/assignment_policy', formats: [:json], assignment_policy: @assignment_policy diff --git a/config/features.yml b/config/features.yml index db2d46700..d6f2d24a3 100644 --- a/config/features.yml +++ b/config/features.yml @@ -191,3 +191,7 @@ display_name: CRM V2 enabled: false chatwoot_internal: true +- name: assignment_v2 + display_name: Assignment V2 + enabled: false + chatwoot_internal: true diff --git a/config/locales/en.yml b/config/locales/en.yml index 1d8347679..e55132709 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -53,6 +53,8 @@ en: email_already_exists: 'You have already signed up for an account with %{email}' invalid_params: 'Invalid, please check the signup paramters and try again' failed: Signup failed + assignment_policy: + not_found: Assignment policy not found data_import: data_type: invalid: Invalid data type diff --git a/config/routes.rb b/config/routes.rb index 10749062e..1409b11fd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -217,6 +217,15 @@ Rails.application.routes.draw do end end + # Assignment V2 Routes + resources :assignment_policies do + resources :inboxes, only: [:index, :create, :destroy], module: :assignment_policies + end + + resources :inboxes, only: [] do + resource :assignment_policy, only: [:show, :create, :destroy], module: :inboxes + end + namespace :twitter do resource :authorization, only: [:create] end diff --git a/enterprise/app/models/enterprise/concerns/assignment_policy.rb b/enterprise/app/models/enterprise/concerns/assignment_policy.rb new file mode 100644 index 000000000..bddcc5e75 --- /dev/null +++ b/enterprise/app/models/enterprise/concerns/assignment_policy.rb @@ -0,0 +1,7 @@ +module Enterprise::Concerns::AssignmentPolicy + extend ActiveSupport::Concern + + included do + enum assignment_order: { round_robin: 0, balanced: 1 } if ChatwootApp.enterprise? + end +end diff --git a/spec/controllers/api/v1/accounts/assignment_policies/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/assignment_policies/inboxes_controller_spec.rb new file mode 100644 index 000000000..6b3c677c5 --- /dev/null +++ b/spec/controllers/api/v1/accounts/assignment_policies/inboxes_controller_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe 'Assignment Policy Inboxes API', type: :request do + let(:account) { create(:account) } + let(:assignment_policy) { create(:assignment_policy, account: account) } + + describe 'GET /api/v1/accounts/{account_id}/assignment_policies/{assignment_policy_id}/inboxes' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin' do + let(:admin) { create(:user, account: account, role: :administrator) } + + context 'when assignment policy has associated inboxes' do + before do + inbox1 = create(:inbox, account: account) + inbox2 = create(:inbox, account: account) + create(:inbox_assignment_policy, inbox: inbox1, assignment_policy: assignment_policy) + create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: assignment_policy) + end + + it 'returns all inboxes associated with the assignment policy' do + get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['inboxes']).to be_an(Array) + expect(json_response['inboxes'].length).to eq(2) + end + end + + context 'when assignment policy has no associated inboxes' do + it 'returns empty array' do + get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['inboxes']).to eq([]) + end + end + end + + context 'when it is an agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}/inboxes", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/controllers/api/v1/accounts/assignment_policies_controller_spec.rb b/spec/controllers/api/v1/accounts/assignment_policies_controller_spec.rb new file mode 100644 index 000000000..f882ec992 --- /dev/null +++ b/spec/controllers/api/v1/accounts/assignment_policies_controller_spec.rb @@ -0,0 +1,326 @@ +require 'rails_helper' + +RSpec.describe 'Assignment Policies API', type: :request do + let(:account) { create(:account) } + + describe 'GET /api/v1/accounts/{account.id}/assignment_policies' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/assignment_policies" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin' do + let(:admin) { create(:user, account: account, role: :administrator) } + + before do + create_list(:assignment_policy, 3, account: account) + end + + it 'returns all assignment policies for the account' do + get "/api/v1/accounts/#{account.id}/assignment_policies", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response.length).to eq(3) + expect(json_response.first.keys).to include('id', 'name', 'description') + end + end + + context 'when it is an agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/assignment_policies", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/assignment_policies/:id' do + let(:assignment_policy) { create(:assignment_policy, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin' do + let(:admin) { create(:user, account: account, role: :administrator) } + + it 'returns the assignment policy' do + get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['id']).to eq(assignment_policy.id) + expect(json_response['name']).to eq(assignment_policy.name) + end + + it 'returns not found for non-existent policy' do + get "/api/v1/accounts/#{account.id}/assignment_policies/999999", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + + context 'when it is an agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/accounts/{account.id}/assignment_policies' do + let(:valid_params) do + { + assignment_policy: { + name: 'New Assignment Policy', + description: 'Policy for new team', + conversation_priority: 'longest_waiting', + fair_distribution_limit: 15, + enabled: true + } + } + end + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/assignment_policies", params: valid_params + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin' do + let(:admin) { create(:user, account: account, role: :administrator) } + + it 'creates a new assignment policy' do + expect do + post "/api/v1/accounts/#{account.id}/assignment_policies", + headers: admin.create_new_auth_token, + params: valid_params, + as: :json + end.to change(AssignmentPolicy, :count).by(1) + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['name']).to eq('New Assignment Policy') + expect(json_response['conversation_priority']).to eq('longest_waiting') + end + + it 'creates policy with minimal required params' do + minimal_params = { assignment_policy: { name: 'Minimal Policy' } } + + expect do + post "/api/v1/accounts/#{account.id}/assignment_policies", + headers: admin.create_new_auth_token, + params: minimal_params, + as: :json + end.to change(AssignmentPolicy, :count).by(1) + + expect(response).to have_http_status(:success) + end + + it 'prevents duplicate policy names within account' do + create(:assignment_policy, account: account, name: 'Duplicate Policy') + duplicate_params = { assignment_policy: { name: 'Duplicate Policy' } } + + expect do + post "/api/v1/accounts/#{account.id}/assignment_policies", + headers: admin.create_new_auth_token, + params: duplicate_params, + as: :json + end.not_to change(AssignmentPolicy, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'validates required fields' do + invalid_params = { assignment_policy: { name: '' } } + + post "/api/v1/accounts/#{account.id}/assignment_policies", + headers: admin.create_new_auth_token, + params: invalid_params, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when it is an agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/assignment_policies", + headers: agent.create_new_auth_token, + params: valid_params, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /api/v1/accounts/{account.id}/assignment_policies/:id' do + let(:assignment_policy) { create(:assignment_policy, account: account, name: 'Original Policy') } + let(:update_params) do + { + assignment_policy: { + name: 'Updated Policy', + description: 'Updated description', + fair_distribution_limit: 20 + } + } + end + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}", + params: update_params + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin' do + let(:admin) { create(:user, account: account, role: :administrator) } + + it 'updates the assignment policy' do + put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}", + headers: admin.create_new_auth_token, + params: update_params, + as: :json + + expect(response).to have_http_status(:success) + assignment_policy.reload + expect(assignment_policy.name).to eq('Updated Policy') + expect(assignment_policy.fair_distribution_limit).to eq(20) + end + + it 'allows partial updates' do + partial_params = { assignment_policy: { enabled: false } } + + put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}", + headers: admin.create_new_auth_token, + params: partial_params, + as: :json + + expect(response).to have_http_status(:success) + expect(assignment_policy.reload.enabled).to be(false) + expect(assignment_policy.name).to eq('Original Policy') # unchanged + end + + it 'prevents duplicate names during update' do + create(:assignment_policy, account: account, name: 'Existing Policy') + duplicate_params = { assignment_policy: { name: 'Existing Policy' } } + + put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}", + headers: admin.create_new_auth_token, + params: duplicate_params, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns not found for non-existent policy' do + put "/api/v1/accounts/#{account.id}/assignment_policies/999999", + headers: admin.create_new_auth_token, + params: update_params, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + + context 'when it is an agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized' do + put "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}", + headers: agent.create_new_auth_token, + params: update_params, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /api/v1/accounts/{account.id}/assignment_policies/:id' do + let(:assignment_policy) { create(:assignment_policy, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin' do + let(:admin) { create(:user, account: account, role: :administrator) } + + it 'deletes the assignment policy' do + assignment_policy # create it first + + expect do + delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}", + headers: admin.create_new_auth_token, + as: :json + end.to change(AssignmentPolicy, :count).by(-1) + + expect(response).to have_http_status(:ok) + end + + it 'cascades deletion to associated inbox assignment policies' do + inbox = create(:inbox, account: account) + create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) + + expect do + delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}", + headers: admin.create_new_auth_token, + as: :json + end.to change(InboxAssignmentPolicy, :count).by(-1) + + expect(response).to have_http_status(:ok) + end + + it 'returns not found for non-existent policy' do + delete "/api/v1/accounts/#{account.id}/assignment_policies/999999", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + + context 'when it is an agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/assignment_policies/#{assignment_policy.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/controllers/api/v1/accounts/inboxes/assignment_policies_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes/assignment_policies_controller_spec.rb new file mode 100644 index 000000000..71ff464f8 --- /dev/null +++ b/spec/controllers/api/v1/accounts/inboxes/assignment_policies_controller_spec.rb @@ -0,0 +1,195 @@ +require 'rails_helper' + +RSpec.describe 'Inbox Assignment Policies API', type: :request do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:assignment_policy) { create(:assignment_policy, account: account) } + + describe 'GET /api/v1/accounts/{account_id}/inboxes/{inbox_id}/assignment_policy' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin' do + let(:admin) { create(:user, account: account, role: :administrator) } + + context 'when inbox has an assignment policy' do + before do + create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) + end + + it 'returns the assignment policy for the inbox' do + get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['id']).to eq(assignment_policy.id) + expect(json_response['name']).to eq(assignment_policy.name) + end + end + + context 'when inbox has no assignment policy' do + it 'returns not found' do + get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + end + + context 'when it is an agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /api/v1/accounts/{account_id}/inboxes/{inbox_id}/assignment_policy' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + params: { assignment_policy_id: assignment_policy.id } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin' do + let(:admin) { create(:user, account: account, role: :administrator) } + + it 'assigns a policy to the inbox' do + expect do + post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + params: { assignment_policy_id: assignment_policy.id }, + headers: admin.create_new_auth_token, + as: :json + end.to change(InboxAssignmentPolicy, :count).by(1) + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['id']).to eq(assignment_policy.id) + end + + it 'replaces existing assignment policy for inbox' do + other_policy = create(:assignment_policy, account: account) + create(:inbox_assignment_policy, inbox: inbox, assignment_policy: other_policy) + + expect do + post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + params: { assignment_policy_id: assignment_policy.id }, + headers: admin.create_new_auth_token, + as: :json + end.not_to change(InboxAssignmentPolicy, :count) + + expect(response).to have_http_status(:success) + expect(inbox.reload.inbox_assignment_policy.assignment_policy).to eq(assignment_policy) + end + + it 'returns not found for invalid assignment policy' do + post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + params: { assignment_policy_id: 999_999 }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + + it 'returns not found for invalid inbox' do + post "/api/v1/accounts/#{account.id}/inboxes/999999/assignment_policy", + params: { assignment_policy_id: assignment_policy.id }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + + context 'when it is an agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + params: { assignment_policy_id: assignment_policy.id }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /api/v1/accounts/{account_id}/inboxes/{inbox_id}/assignment_policy' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated admin' do + let(:admin) { create(:user, account: account, role: :administrator) } + + context 'when inbox has an assignment policy' do + before do + create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) + end + + it 'removes the assignment policy from inbox' do + expect do + delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + headers: admin.create_new_auth_token, + as: :json + end.to change(InboxAssignmentPolicy, :count).by(-1) + + expect(response).to have_http_status(:success) + expect(inbox.reload.inbox_assignment_policy).to be_nil + end + end + + context 'when inbox has no assignment policy' do + it 'returns error' do + expect do + delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + headers: admin.create_new_auth_token, + as: :json + end.not_to change(InboxAssignmentPolicy, :count) + + expect(response).to have_http_status(:not_found) + end + end + + it 'returns not found for invalid inbox' do + delete "/api/v1/accounts/#{account.id}/inboxes/999999/assignment_policy", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + + context 'when it is an agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignment_policy", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/enterprise/models/assignment_policy_spec.rb b/spec/enterprise/models/assignment_policy_spec.rb new file mode 100644 index 000000000..0d21cc3c4 --- /dev/null +++ b/spec/enterprise/models/assignment_policy_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe AssignmentPolicy do + let(:account) { create(:account) } + + describe 'enum values' do + let(:assignment_policy) { create(:assignment_policy, account: account) } + + describe 'assignment_order' do + it 'can be set to balanced' do + assignment_policy.update!(assignment_order: :balanced) + expect(assignment_policy.assignment_order).to eq('balanced') + expect(assignment_policy.round_robin?).to be false + expect(assignment_policy.balanced?).to be true + end + end + end +end diff --git a/spec/factories/assignment_policies.rb b/spec/factories/assignment_policies.rb new file mode 100644 index 000000000..6a696caa4 --- /dev/null +++ b/spec/factories/assignment_policies.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :assignment_policy do + account + sequence(:name) { |n| "Assignment Policy #{n}" } + description { 'Test assignment policy description' } + assignment_order { 0 } + conversation_priority { 0 } + fair_distribution_limit { 10 } + fair_distribution_window { 3600 } + enabled { true } + end +end diff --git a/spec/factories/inbox_assignment_policies.rb b/spec/factories/inbox_assignment_policies.rb new file mode 100644 index 000000000..80bcae223 --- /dev/null +++ b/spec/factories/inbox_assignment_policies.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :inbox_assignment_policy do + inbox + assignment_policy + end +end diff --git a/spec/models/assignment_policy_spec.rb b/spec/models/assignment_policy_spec.rb new file mode 100644 index 000000000..1a97bbda0 --- /dev/null +++ b/spec/models/assignment_policy_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +RSpec.describe AssignmentPolicy do + describe 'associations' do + it { is_expected.to belong_to(:account) } + it { is_expected.to have_many(:inbox_assignment_policies).dependent(:destroy) } + it { is_expected.to have_many(:inboxes).through(:inbox_assignment_policies) } + end + + describe 'validations' do + subject { build(:assignment_policy) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_uniqueness_of(:name).scoped_to(:account_id) } + end + + describe 'fair distribution validations' do + it 'requires fair_distribution_limit to be greater than 0' do + policy = build(:assignment_policy, fair_distribution_limit: 0) + expect(policy).not_to be_valid + expect(policy.errors[:fair_distribution_limit]).to include('must be greater than 0') + end + + it 'requires fair_distribution_window to be greater than 0' do + policy = build(:assignment_policy, fair_distribution_window: -1) + expect(policy).not_to be_valid + expect(policy.errors[:fair_distribution_window]).to include('must be greater than 0') + end + end + + describe 'enum values' do + let(:assignment_policy) { create(:assignment_policy) } + + describe 'conversation_priority' do + it 'can be set to earliest_created' do + assignment_policy.update!(conversation_priority: :earliest_created) + expect(assignment_policy.conversation_priority).to eq('earliest_created') + expect(assignment_policy.earliest_created?).to be true + end + + it 'can be set to longest_waiting' do + assignment_policy.update!(conversation_priority: :longest_waiting) + expect(assignment_policy.conversation_priority).to eq('longest_waiting') + expect(assignment_policy.longest_waiting?).to be true + end + end + + describe 'assignment_order' do + it 'can be set to round_robin' do + assignment_policy.update!(assignment_order: :round_robin) + expect(assignment_policy.assignment_order).to eq('round_robin') + expect(assignment_policy.round_robin?).to be true + end + end + end +end From faf35738b3b0010ca151ef77340e4a7c987d5e59 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:06:22 +0530 Subject: [PATCH 2/6] fix: Prevent reopening a resolved conversation (#11168) # Pull Request Template ## Description This PR addresses the issue where navigating back and starting a new conversation incorrectly shows the previous messages or message screen. Fixes https://linear.app/chatwoot/issue/CW-4169/prevent-continue-conversation-in-previously-resolved-conversation ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Loom video **Before** https://www.loom.com/share/18172a3b26ff4e8faf8e1c3c1a0ba279?sid=ffbda52a-93e1-400f-bedc-17b925bae4d3 **After** https://www.loom.com/share/584177d411424ad38c6812be868eb060?sid=fe5e771a-3faa-4c14-a5fe-918a3ccdb408 ## 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 - [ ] 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: Pranav Co-authored-by: Muhsin Keloth --- app/javascript/widget/components/ChatFooter.vue | 13 ++----------- app/javascript/widget/views/PreChatForm.vue | 5 +++++ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/app/javascript/widget/components/ChatFooter.vue b/app/javascript/widget/components/ChatFooter.vue index 841b37216..2bc6ba7ec 100755 --- a/app/javascript/widget/components/ChatFooter.vue +++ b/app/javascript/widget/components/ChatFooter.vue @@ -55,15 +55,8 @@ export default { emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo); }, methods: { - ...mapActions('conversation', [ - 'sendMessage', - 'sendAttachment', - 'clearConversations', - ]), - ...mapActions('conversationAttributes', [ - 'getAttributes', - 'clearConversationAttributes', - ]), + ...mapActions('conversation', ['sendMessage', 'sendAttachment']), + ...mapActions('conversationAttributes', ['getAttributes']), async handleSendMessage(content) { await this.sendMessage({ content, @@ -84,8 +77,6 @@ export default { this.inReplyTo = null; }, startNewConversation() { - this.clearConversations(); - this.clearConversationAttributes(); this.replaceRoute('prechat-form'); IFrameHelper.sendMessage({ event: 'onEvent', diff --git a/app/javascript/widget/views/PreChatForm.vue b/app/javascript/widget/views/PreChatForm.vue index 8eac55808..ca25b47e4 100644 --- a/app/javascript/widget/views/PreChatForm.vue +++ b/app/javascript/widget/views/PreChatForm.vue @@ -1,4 +1,5 @@ + + diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 4f0a27cae..a5c9549f1 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -17,6 +17,11 @@ "IP_ADDRESS": "IP Address", "CREATED_AT_LABEL": "Created", "NEW_MESSAGE": "New message", + "CALL": "Call", + "CALL_UNDER_DEVELOPMENT": "Calling is under development", + "VOICE_INBOX_PICKER": { + "TITLE": "Choose a voice inbox" + }, "CONVERSATIONS": { "NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.", "TITLE": "Previous Conversations" diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue index 327bc95ae..bfdb5d68a 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue @@ -11,6 +11,7 @@ import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue'; import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import NextButton from 'dashboard/components-next/button/Button.vue'; +import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue'; import { isAConversationRoute, @@ -28,6 +29,7 @@ export default { ComposeConversation, SocialIcons, ContactMergeModal, + VoiceCallButton, }, props: { contact: { @@ -278,6 +280,14 @@ export default { /> + Date: Wed, 20 Aug 2025 15:45:19 +0200 Subject: [PATCH 5/6] fix(migrations): skip AddFeatureCitationToAssistantConfig on OSS (#12244) --- ...20250808123008_add_feature_citation_to_assistant_config.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/db/migrate/20250808123008_add_feature_citation_to_assistant_config.rb b/db/migrate/20250808123008_add_feature_citation_to_assistant_config.rb index e1f5c3f03..d5b037ec6 100644 --- a/db/migrate/20250808123008_add_feature_citation_to_assistant_config.rb +++ b/db/migrate/20250808123008_add_feature_citation_to_assistant_config.rb @@ -1,5 +1,7 @@ class AddFeatureCitationToAssistantConfig < ActiveRecord::Migration[7.1] def up + return unless ChatwootApp.enterprise? + Captain::Assistant.find_each do |assistant| assistant.update!( config: assistant.config.merge('feature_citation' => true) @@ -8,6 +10,8 @@ class AddFeatureCitationToAssistantConfig < ActiveRecord::Migration[7.1] end def down + return unless ChatwootApp.enterprise? + Captain::Assistant.find_each do |assistant| config = assistant.config.dup config.delete('feature_citation') From c6113852d7debd064272800f65468f3d86472920 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 20 Aug 2025 16:23:38 +0200 Subject: [PATCH 6/6] Bump version to 4.5.1 --- config/app.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/app.yml b/config/app.yml index c9b08cd07..cc7233c18 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.5.0' + version: '4.5.1' development: <<: *shared diff --git a/package.json b/package.json index 441dcbd90..8dc6b2565 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.5.0", + "version": "4.5.1", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}",