chore(refactor): Improve conversation permission filtering (#11166)

1. Add permission filter service to separate permission filtering logic
from conversation queries
2. Implement hierarchical permissions with cleaner logic:
   - conversation_manage gives access to all conversations
- conversation_unassigned_manage gives access to unassigned and user's
conversations
- conversation_participating_manage gives access only to user's
conversations

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Sojan Jose
2025-03-31 19:30:02 -07:00
committed by GitHub
parent f20a18b03f
commit ca83a27e95
12 changed files with 759 additions and 260 deletions

3
.gitignore vendored
View File

@@ -91,3 +91,6 @@ yarn-debug.log*
# Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files
*.local
# Claude.ai config file
CLAUDE.md

View File

@@ -1,17 +1,21 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
def index
@conversations = Current.account.conversations.includes(
# Start with all conversations for this contact
conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
end
).where(contact_id: @contact.id)
private
# Apply permission-based filtering using the existing service
conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
def inbox_ids
if Current.user.administrator? || Current.user.agent?
Current.user.assigned_inboxes.pluck(:id)
else
[]
end
# Only allow conversations from inboxes the user has access to
inbox_ids = Current.user.assigned_inboxes.pluck(:id)
conversations = conversations.where(inbox_id: inbox_ids)
@conversations = conversations.order(last_activity_at: :desc).limit(20)
end
end

View File

@@ -48,7 +48,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def filter
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform
@conversations = result[:conversations]
@conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,

View File

@@ -93,6 +93,12 @@ class ConversationFinder
def find_all_conversations
find_conversation_by_inbox
# Apply permission-based filtering
@conversations = Conversations::PermissionFilterService.new(
@conversations,
current_user,
current_account
).perform
filter_by_conversation_type if params[:conversation_type]
@conversations
end

View File

@@ -1,8 +1,8 @@
class Conversations::FilterService < FilterService
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def initialize(params, user, filter_account = nil)
@account = filter_account || Current.account
def initialize(params, user, account)
@account = account
super(params, user)
end
@@ -24,9 +24,25 @@ class Conversations::FilterService < FilterService
end
def base_relation
@account.conversations.includes(
conversations = @account.conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
)
account_user = @account.account_users.find_by(user_id: @user.id)
is_administrator = account_user&.role == 'administrator'
# Ensure we only include conversations from inboxes the user has access to
unless is_administrator
inbox_ids = @user.inboxes.where(account_id: @account.id).pluck(:id)
conversations = conversations.where(inbox_id: inbox_ids)
end
# Apply permission-based filtering
Conversations::PermissionFilterService.new(
conversations,
@user,
@account
).perform
end
def current_page

View File

@@ -0,0 +1,17 @@
class Conversations::PermissionFilterService
attr_reader :conversations, :user, :account
def initialize(conversations, user, account)
@conversations = conversations
@user = user
@account = account
end
def perform
# The base implementation simply returns all conversations
# Enterprise edition extends this with permission-based filtering
conversations
end
end
Conversations::PermissionFilterService.prepend_mod_with('Conversations::PermissionFilterService')

View File

@@ -0,0 +1,38 @@
module Enterprise::Conversations::PermissionFilterService
def perform
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
permissions = account_user&.permissions || []
user_role = account_user&.role
# Skip filtering for administrators
return conversations if user_role == 'administrator'
# Skip filtering for regular agents (without custom roles/permissions)
return conversations if user_role == 'agent' && account_user&.custom_role_id.nil?
filter_by_permissions(permissions)
end
private
def filter_by_permissions(permissions)
# Permission-based filtering with hierarchy
# conversation_manage > conversation_unassigned_manage > conversation_participating_manage
if permissions.include?('conversation_manage')
conversations
elsif permissions.include?('conversation_unassigned_manage')
filter_unassigned_and_mine
elsif permissions.include?('conversation_participating_manage')
conversations.assigned_to(user)
else
Conversation.none
end
end
def filter_unassigned_and_mine
mine = conversations.assigned_to(user)
unassigned = conversations.unassigned
Conversation.from("(#{mine.to_sql} UNION #{unassigned.to_sql}) as conversations")
.where(account_id: account.id)
end
end

View File

@@ -0,0 +1,123 @@
require 'rails_helper'
RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/conversations enterprise', type: :request do
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
describe 'GET /api/v1/accounts/{account.id}/contacts/:id/conversations with custom role permissions' do
context 'with user having custom role' do
let(:agent_with_custom_role) { create(:user, account: account, role: :agent) }
let(:custom_role) { create(:custom_role, account: account) }
before do
create(:inbox_member, user: agent_with_custom_role, inbox: inbox)
end
context 'with conversation_participating_manage permission' do
let(:assigned_conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact,
contact_inbox: contact_inbox, assignee: agent_with_custom_role)
end
before do
# Create a conversation assigned to this agent
assigned_conversation
# Create another conversation that shouldn't be visible
create(:conversation, account: account, inbox: inbox, contact: contact,
contact_inbox: contact_inbox, assignee: create(:user, account: account, role: :agent))
# Set up permissions
custom_role.update!(permissions: %w[conversation_participating_manage])
# Associate the custom role with the agent
account_user = AccountUser.find_by(user: agent_with_custom_role, account: account)
account_user.update!(role: :agent, custom_role: custom_role)
end
it 'returns only conversations assigned to the agent' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/conversations",
headers: agent_with_custom_role.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
# Should only return the conversation assigned to this agent
expect(json_response['payload'].length).to eq 1
expect(json_response['payload'][0]['id']).to eq assigned_conversation.display_id
end
end
context 'with conversation_unassigned_manage permission' do
let(:unassigned_conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact,
contact_inbox: contact_inbox, assignee: nil)
end
let(:assigned_conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact,
contact_inbox: contact_inbox, assignee: agent_with_custom_role)
end
before do
# Create the conversations
unassigned_conversation
assigned_conversation
create(:conversation, account: account, inbox: inbox, contact: contact,
contact_inbox: contact_inbox, assignee: create(:user, account: account, role: :agent))
# Set up permissions
custom_role.update!(permissions: %w[conversation_unassigned_manage])
# Associate the custom role with the agent
account_user = AccountUser.find_by(user: agent_with_custom_role, account: account)
account_user.update!(role: :agent, custom_role: custom_role)
end
it 'returns unassigned conversations AND conversations assigned to the agent' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/conversations",
headers: agent_with_custom_role.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
# Should return both unassigned and assigned to this agent conversations
expect(json_response['payload'].length).to eq 2
conversation_ids = json_response['payload'].pluck('id')
expect(conversation_ids).to include(unassigned_conversation.display_id)
expect(conversation_ids).to include(assigned_conversation.display_id)
end
end
context 'with conversation_manage permission' do
before do
# Create multiple conversations
3.times do
create(:conversation, account: account, inbox: inbox, contact: contact,
contact_inbox: contact_inbox)
end
# Set up permissions
custom_role.update!(permissions: %w[conversation_manage])
# Associate the custom role with the agent
account_user = AccountUser.find_by(user: agent_with_custom_role, account: account)
account_user.update!(role: :agent, custom_role: custom_role)
end
it 'returns all conversations' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/conversations",
headers: agent_with_custom_role.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
# Should return all conversations in this inbox
expect(json_response['payload'].length).to eq 3
end
end
end
end
end

View File

@@ -0,0 +1,197 @@
require 'rails_helper'
RSpec.describe Enterprise::Conversations::PermissionFilterService do
let(:account) { create(:account) }
# Create conversations with different states
let!(:assigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
let!(:unassigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil) }
let!(:another_assigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: create(:user, account: account)) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:inbox) { create(:inbox, account: account) }
# This inbox_member is used to establish the agent's access to the inbox
before { create(:inbox_member, user: agent, inbox: inbox) }
describe '#perform' do
context 'when user is an administrator' do
it 'returns all conversations' do
result = Conversations::PermissionFilterService.new(
account.conversations,
admin,
account
).perform
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(another_assigned_conversation)
expect(result.count).to eq(3)
end
end
context 'when user is a regular agent' do
it 'returns all conversations in assigned inboxes' do
inbox_ids = agent.inboxes.where(account_id: account.id).pluck(:id)
result = Conversations::PermissionFilterService.new(
account.conversations.where(inbox_id: inbox_ids),
agent,
account
).perform
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(another_assigned_conversation)
expect(result.count).to eq(3)
end
end
context 'when user has conversation_manage permission' do
# Test with a new clean state for each test case
it 'returns all conversations' do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox)
# Create custom role with conversation_manage permission
test_custom_role = create(:custom_role, account: test_account, permissions: ['conversation_manage'])
account_user = AccountUser.find_by(user: test_agent, account: test_account)
account_user.update(role: :agent, custom_role: test_custom_role)
# Create some conversations
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
# Run the test
result = Conversations::PermissionFilterService.new(
test_account.conversations,
test_agent,
test_account
).perform
# Should have access to all conversations
expect(result.count).to eq(3)
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(other_assigned_conversation)
end
end
context 'when user has conversation_participating_manage permission' do
it 'returns only conversations assigned to the agent' do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox)
# Create a custom role with only the conversation_participating_manage permission
test_custom_role = create(:custom_role, account: test_account, permissions: %w[conversation_participating_manage])
account_user = AccountUser.find_by(user: test_agent, account: test_account)
account_user.update(role: :agent, custom_role: test_custom_role)
# Create some conversations
other_conversation = create(:conversation, account: test_account, inbox: test_inbox)
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
# Run the test
result = Conversations::PermissionFilterService.new(
test_account.conversations,
test_agent,
test_account
).perform
# Should only see conversations assigned to this agent
expect(result.count).to eq(1)
expect(result.first.assignee).to eq(test_agent)
expect(result).to include(assigned_conversation)
expect(result).not_to include(other_conversation)
end
end
context 'when user has conversation_unassigned_manage permission' do
it 'returns unassigned conversations AND mine' do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox)
# Create a custom role with only the conversation_unassigned_manage permission
test_custom_role = create(:custom_role, account: test_account, permissions: %w[conversation_unassigned_manage])
account_user = AccountUser.find_by(user: test_agent, account: test_account)
account_user.update(role: :agent, custom_role: test_custom_role)
# Create some conversations
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
# Run the test
result = Conversations::PermissionFilterService.new(
test_account.conversations,
test_agent,
test_account
).perform
# Should see unassigned conversations AND conversations assigned to this agent
expect(result.count).to eq(2)
expect(result).to include(unassigned_conversation)
expect(result).to include(assigned_conversation)
# Should NOT include conversations assigned to others
expect(result).not_to include(other_assigned_conversation)
end
end
context 'when user has both participating and unassigned permissions (hierarchical test)' do
it 'gives higher priority to unassigned_manage over participating_manage' do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox)
# Create a custom role with both participating and unassigned permissions
permissions = %w[conversation_participating_manage conversation_unassigned_manage]
test_custom_role = create(:custom_role, account: test_account, permissions: permissions)
account_user = AccountUser.find_by(user: test_agent, account: test_account)
account_user.update(role: :agent, custom_role: test_custom_role)
# Create some conversations
assigned_to_agent = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
# Run the test
result = Conversations::PermissionFilterService.new(
test_account.conversations,
test_agent,
test_account
).perform
# Should behave the same as conversation_unassigned_manage test
# - Show both unassigned and assigned to this agent
# - Do not show conversations assigned to others
expect(result.count).to eq(2)
expect(result).to include(unassigned_conversation)
expect(result).to include(assigned_to_agent)
expect(result).not_to include(other_assigned_conversation)
end
end
end
end

View File

@@ -0,0 +1,254 @@
## This spec is to ensure alignment between frontend and backend filters
# ref: https://github.com/chatwoot/chatwoot/pull/11111
require 'rails_helper'
describe Conversations::FilterService do
describe 'Frontend alignment tests' do
let!(:account) { create(:account) }
let!(:user_1) { create(:user, account: account, role: :administrator) }
let!(:inbox) { create(:inbox, account: account) }
let!(:params) { { payload: [], page: 1 } }
before do
account.conversations.destroy_all
# Create inbox membership
create(:inbox_member, user: user_1, inbox: inbox)
# Create custom attribute definition for conversation_type
create(:custom_attribute_definition,
attribute_key: 'conversation_type',
account: account,
attribute_model: 'conversation_attribute',
attribute_display_type: 'list',
attribute_values: %w[platinum silver gold regular])
end
context 'with A AND B OR C filter chain' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['urgent'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['12345'],
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' }
)
end
it 'matches when all conditions are true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
it 'matches when first condition is false but third is true' do
conversation.update!(status: 'resolved', priority: 'urgent', display_id: '12345')
params[:payload] = filter_payload
result = described_class.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
it 'matches when first and second condition is false but third is true' do
conversation.update!(status: 'resolved', priority: 'low', display_id: '12345')
params[:payload] = filter_payload
result = described_class.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
it 'does not match when all conditions are false' do
conversation.update!(status: 'resolved', priority: 'low', display_id: '67890')
params[:payload] = filter_payload
result = described_class.new(params, user_1, account).perform
expect(result[:conversations].length).to be 0
end
end
context 'with A OR B AND C filter chain' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['low'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['67890'],
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' }
)
end
it 'matches when first condition is true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
it 'matches when second and third conditions are true' do
conversation.update!(status: 'resolved', priority: 'low', display_id: '67890')
params[:payload] = filter_payload
result = described_class.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
end
context 'with complex filter chain A AND B OR C AND D' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['urgent'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['67890'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: ['tr'],
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' },
custom_attributes: { conversation_type: 'platinum' }
)
end
it 'matches when first two conditions are true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
it 'matches when last two conditions are true' do
conversation.update!(
status: 'resolved',
priority: 'low',
display_id: '67890',
additional_attributes: { 'browser_language': 'tr' }
)
params[:payload] = filter_payload
result = described_class.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
end
context 'with mixed operators filter chain' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['urgent'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['67890'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'conversation_type',
filter_operator: 'equal_to',
values: ['platinum'],
custom_attribute_type: '',
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' },
custom_attributes: { conversation_type: 'platinum' }
)
end
it 'matches when all conditions in the chain are true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
it 'does not match when the last condition is false' do
conversation.update!(custom_attributes: { conversation_type: 'silver' })
params[:payload] = filter_payload
result = described_class.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
end
end
end

View File

@@ -23,7 +23,6 @@ describe Conversations::FilterService do
before do
create(:inbox_member, user: user_1, inbox: inbox)
create(:inbox_member, user: user_2, inbox: inbox)
Current.account = account
en_conversation_1.update!(custom_attributes: { conversation_additional_information: 'test custom data' })
en_conversation_2.update!(custom_attributes: { conversation_additional_information: 'test custom data', conversation_type: 'platinum' })
@@ -72,7 +71,7 @@ describe Conversations::FilterService do
it 'filter conversations by additional_attributes and status' do
params[:payload] = payload
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
expect(result[:count][:all_count]).to be conversations.count
end
@@ -88,7 +87,7 @@ describe Conversations::FilterService do
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:conversations].length).to eq 1
expect(result[:conversations][0][:id]).to eq conversation.id
end
@@ -107,7 +106,7 @@ describe Conversations::FilterService do
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:conversations].length).to eq 2
expect(result[:conversations].pluck(:id)).to include(high_priority.id, urgent_priority.id)
end
@@ -127,7 +126,7 @@ describe Conversations::FilterService do
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
# Only include conversations with medium and low priority, excluding high and urgent
expect(result[:conversations].length).to eq 2
@@ -137,7 +136,7 @@ describe Conversations::FilterService do
it 'filter conversations by additional_attributes and status with pagination' do
params[:payload] = payload
params[:page] = 2
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
expect(result[:count][:all_count]).to be conversations.count
end
@@ -156,7 +155,7 @@ describe Conversations::FilterService do
create(:conversation, account: account, inbox: inbox, assignee: user_1, campaign_id: campaign_1.id,
status: 'pending', additional_attributes: { 'browser_language': 'tr' })
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:count][:all_count]).to be 2
end
@@ -174,7 +173,7 @@ describe Conversations::FilterService do
create(:conversation, account: account, inbox: inbox, assignee: user_1, campaign_id: campaign_1.id,
status: 'pending', additional_attributes: { 'browser_language': 'tr' })
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:count][:all_count]).to be 1
expect(result[:conversations].first.additional_attributes['browser_language']).to eq 'fr'
@@ -184,7 +183,7 @@ describe Conversations::FilterService do
payload = [{ attribute_key: 'conversation_type', filter_operator: 'not_equal_to', values: 'platinum', query_operator: nil,
custom_attribute_type: 'conversation_attribute' }.with_indifferent_access]
params[:payload] = payload
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
conversations = Conversation.where(
"custom_attributes ->> 'conversation_type' NOT IN (?) OR custom_attributes ->> 'conversation_type' IS NULL", ['platinum']
)
@@ -213,7 +212,7 @@ describe Conversations::FilterService do
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:count][:all_count]).to be 1
end
@@ -237,7 +236,7 @@ describe Conversations::FilterService do
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:count][:all_count]).to be 2
expect(result[:conversations].pluck(:campaign_id).sort).to eq [campaign_2.id, campaign_1.id].sort
@@ -264,7 +263,7 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
expect { filter_service.new(params, user_1).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
expect { filter_service.new(params, user_1, account).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidQueryOperator)
end
end
end
@@ -296,7 +295,7 @@ describe Conversations::FilterService do
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
expect(result[:conversations][0][:id]).to be user_2_assigned_conversation.id
end
@@ -324,7 +323,7 @@ describe Conversations::FilterService do
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
expect(result[:conversations][0][:id]).to be user_2_assigned_conversation.id
end
@@ -346,7 +345,7 @@ describe Conversations::FilterService do
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
@@ -367,7 +366,7 @@ describe Conversations::FilterService do
custom_attribute_type: nil
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
@@ -393,7 +392,7 @@ describe Conversations::FilterService do
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:conversations].length).to be 1
end
end
@@ -413,7 +412,7 @@ describe Conversations::FilterService do
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
expect(result[:conversations].length).to be expected_count
end
@@ -435,7 +434,7 @@ describe Conversations::FilterService do
custom_attribute_type: ''
}.with_indifferent_access
]
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expected_count = Conversation.where("created_at > ? AND custom_attributes->>'conversation_type' = ?", DateTime.parse('2022-01-20'),
'platinum').count
@@ -471,7 +470,7 @@ describe Conversations::FilterService do
expected_count = Conversation.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?", (Time.zone.today - 3.days),
'platinum').count
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:conversations].length).to be expected_count
end
@@ -488,7 +487,7 @@ describe Conversations::FilterService do
expected_count = Conversation.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
result = filter_service.new(params, user_1).perform
result = filter_service.new(params, user_1, account).perform
expect(result[:conversations].length).to be expected_count
end
end
@@ -522,240 +521,35 @@ describe Conversations::FilterService do
end
end
describe 'Frontend alignment tests' do
describe '#base_relation' do
let!(:account) { create(:account) }
let!(:user_1) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account) }
let!(:user_1) { create(:user, account: account, role: :agent) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:inbox_1) { create(:inbox, account: account) }
let!(:inbox_2) { create(:inbox, account: account) }
let!(:params) { { payload: [], page: 1 } }
before do
account.conversations.destroy_all
# Make user_1 a regular agent with access to inbox_1 only
create(:inbox_member, user: user_1, inbox: inbox_1)
# Create conversations in both inboxes
create(:conversation, account: account, inbox: inbox_1)
create(:conversation, account: account, inbox: inbox_2)
end
context 'with A AND B OR C filter chain' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['urgent'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['12345'],
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' }
)
end
it 'matches when all conditions are true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'matches when first condition is false but third is true' do
conversation.update!(status: 'resolved', priority: 'urgent', display_id: '12345')
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'matches when first and second condition is false but third is true' do
conversation.update!(status: 'resolved', priority: 'low', display_id: '12345')
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'does not match when all conditions are false' do
conversation.update!(status: 'resolved', priority: 'low', display_id: '67890')
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 0
end
it 'returns all conversations for administrators, even for inboxes they are not members of' do
service = filter_service.new(params, admin, account)
result = service.perform
expect(result[:conversations].count).to eq 2
end
context 'with A OR B AND C filter chain' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['low'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['67890'],
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' }
)
end
it 'matches when first condition is true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'matches when second and third conditions are true' do
conversation.update!(status: 'resolved', priority: 'low', display_id: '67890')
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
end
context 'with complex filter chain A AND B OR C AND D' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['urgent'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['67890'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: ['tr'],
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' },
custom_attributes: { conversation_type: 'platinum' }
)
end
it 'matches when first two conditions are true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'matches when last two conditions are true' do
conversation.update!(
status: 'resolved',
priority: 'low',
display_id: '67890',
additional_attributes: { 'browser_language': 'tr' }
)
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
end
context 'with mixed operators filter chain' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1) }
let(:filter_payload) do
[
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'priority',
filter_operator: 'equal_to',
values: ['urgent'],
query_operator: 'OR'
}.with_indifferent_access,
{
attribute_key: 'display_id',
filter_operator: 'equal_to',
values: ['67890'],
query_operator: 'AND'
}.with_indifferent_access,
{
attribute_key: 'conversation_type',
filter_operator: 'equal_to',
values: ['platinum'],
custom_attribute_type: '',
query_operator: nil
}.with_indifferent_access
]
end
before do
conversation.update!(
status: 'open',
priority: 'urgent',
display_id: '12345',
additional_attributes: { 'browser_language': 'en' },
custom_attributes: { conversation_type: 'platinum' }
)
end
it 'matches when all conditions in the chain are true' do
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'does not match when the last condition is false' do
conversation.update!(custom_attributes: { conversation_type: 'silver' })
params[:payload] = filter_payload
result = described_class.new(params, user_1).perform
expect(result[:conversations].length).to be 1
end
it 'filters conversations by inbox membership for non-administrators' do
service = filter_service.new(params, user_1, account)
result = service.perform
expect(result[:conversations].count).to eq 1
end
end
end

View File

@@ -0,0 +1,47 @@
require 'rails_helper'
RSpec.describe Conversations::PermissionFilterService do
let(:account) { create(:account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox) }
let!(:another_conversation) { create(:conversation, account: account, inbox: inbox) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:inbox) { create(:inbox, account: account) }
# This inbox_member is used to establish the agent's access to the inbox
before { create(:inbox_member, user: agent, inbox: inbox) }
describe '#perform' do
context 'when user is an administrator' do
it 'returns all conversations' do
result = described_class.new(
account.conversations,
admin,
account
).perform
expect(result).to include(conversation)
expect(result).to include(another_conversation)
expect(result.count).to eq(2)
end
end
context 'when user is an agent' do
it 'returns all conversations with no further filtering' do
inbox_ids = agent.inboxes.where(account_id: account.id).pluck(:id)
# The base implementation returns all conversations
# expecting the caller to filter by assigned inboxes
result = described_class.new(
account.conversations.where(inbox_id: inbox_ids),
agent,
account
).perform
expect(result).to include(conversation)
expect(result).to include(another_conversation)
expect(result.count).to eq(2)
end
end
end
end