feat: add automation condition to filter private notes (#12102)

## Summary

Adds a new automation condition to filter private notes.

This allows automation rules to explicitly include or exclude private
notes instead of relying on implicit behavior.

Fixes: #11208 

## Preview



https://github.com/user-attachments/assets/c40f6910-7bbf-4e59-aae5-ad408602927a
This commit is contained in:
Sojan Jose
2026-04-13 10:40:46 +05:30
committed by GitHub
parent de0bd8e71b
commit 45b6ea6b3f
14 changed files with 153 additions and 4 deletions

View File

@@ -8,6 +8,7 @@ import {
agents,
teams,
labels,
booleanFilterOptions,
statusFilterOptions,
messageTypeOptions,
priorityOptions,
@@ -73,6 +74,8 @@ describe('useAutomation', () => {
return countries;
case 'message_type':
return messageTypeOptions;
case 'private_note':
return booleanFilterOptions;
case 'priority':
return priorityOptions;
default:
@@ -226,6 +229,9 @@ describe('useAutomation', () => {
expect(getConditionDropdownValues('message_type')).toEqual(
messageTypeOptions
);
expect(getConditionDropdownValues('private_note')).toEqual(
booleanFilterOptions
);
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
});

View File

@@ -0,0 +1,54 @@
import { useEditableAutomation } from '../useEditableAutomation';
import useAutomationValues from '../useAutomationValues';
vi.mock('../useAutomationValues');
describe('useEditableAutomation', () => {
beforeEach(() => {
useAutomationValues.mockReturnValue({
getConditionDropdownValues: vi.fn(attributeKey => {
if (attributeKey === 'private_note') {
return [
{ id: true, name: 'True' },
{ id: false, name: 'False' },
];
}
return [];
}),
getActionDropdownValues: vi.fn(),
});
});
it('rehydrates boolean conditions as a single selected option', () => {
const automation = {
event_name: 'message_created',
conditions: [
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: [false],
query_operator: null,
},
],
actions: [],
};
const automationTypes = {
message_created: {
conditions: [{ key: 'private_note', inputType: 'search_select' }],
},
};
const { formatAutomation } = useEditableAutomation();
const result = formatAutomation(automation, [], automationTypes, []);
expect(result.conditions).toEqual([
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: { id: false, name: 'False' },
query_operator: 'and',
},
]);
});
});

View File

@@ -46,11 +46,26 @@ export function useEditableAutomation() {
if (inputType === 'comma_separated_plain_text') {
return { ...condition, values: condition.values.join(',') };
}
const dropdownValues = getConditionDropdownValues(
condition.attribute_key
);
const hasBooleanOptions =
inputType === 'search_select' &&
dropdownValues.length &&
dropdownValues.every(item => typeof item.id === 'boolean');
if (hasBooleanOptions) {
return {
...condition,
query_operator: condition.query_operator || 'and',
values: dropdownValues.find(item => item.id === condition.values[0]),
};
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
item => [...condition.values].includes(item.id)
values: [...dropdownValues].filter(item =>
[...condition.values].includes(item.id)
),
};
});

View File

@@ -150,6 +150,7 @@ export const getConditionOptions = ({
conversation_language: languages,
country_code: countries,
message_type: messageTypeOptions,
private_note: booleanFilterOptions,
priority: priorityOptions,
labels: generateConditionOptions(labels, 'title'),
};

View File

@@ -178,6 +178,21 @@ describe('getConditionOptions', () => {
})
).toEqual(testOptions);
});
it('returns boolean options for private_note', () => {
const booleanOptions = [
{ id: true, name: 'True' },
{ id: false, name: 'False' },
];
expect(
helpers.getConditionOptions({
booleanFilterOptions: booleanOptions,
customAttributes,
type: 'private_note',
})
).toEqual(booleanOptions);
});
});
describe('getFileName', () => {

View File

@@ -169,6 +169,7 @@
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"PRIVATE_NOTE": "Private Note",
"MESSAGE_CONTAINS": "Message Contains",
"EMAIL": "Email",
"INBOX": "Inbox",

View File

@@ -14,6 +14,12 @@ export const AUTOMATIONS = {
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'private_note',
name: 'PRIVATE_NOTE',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'content',
name: 'MESSAGE_CONTAINS',

View File

@@ -36,7 +36,7 @@ class AutomationRule < ApplicationRecord
def conditions_attributes
%w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
mail_subject phone_number priority conversation_language labels]
mail_subject phone_number priority conversation_language labels private_note]
end
def actions_attributes

View File

@@ -113,6 +113,7 @@ class AutomationRules::ConditionsFilterService < FilterService
query_operator = query_hash['query_operator']
attribute_key = 'processed_message_content' if attribute_key == 'content'
attribute_key = 'private' if attribute_key == 'private_note'
filter_operator_value = filter_operation(query_hash, current_index)

View File

@@ -214,6 +214,12 @@ messages:
filter_operators:
- "equal_to"
- "not_equal_to"
private_note:
attribute_type: "standard"
data_type: "boolean"
filter_operators:
- "equal_to"
- "not_equal_to"
content:
attribute_type: "standard"
data_type: "text"

View File

@@ -220,6 +220,15 @@ describe AutomationRuleListener do
expect(AutomationRules::ActionService).not_to have_received(:new)
end
it 'calls AutomationRules::ActionService if message is a private note' do
message.update!(private: true)
allow(condition_match).to receive(:present?).and_return(true)
listener.message_created(event)
expect(AutomationRules::ActionService).to have_received(:new).with(automation_rule, account, conversation)
end
it 'does not call AutomationRules::ActionService if conditions do not match based on content' do
message.update!(processed_message_content: 'hi', content: "hi\n\nhello")
allow(condition_match).to receive(:present?).and_return(false)

View File

@@ -86,6 +86,19 @@ RSpec.describe AutomationRule do
rule = FactoryBot.build(:automation_rule, params)
expect(rule.valid?).to be true
end
it 'allows private_note as a valid condition attribute' do
params[:conditions] = [
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: [true],
query_operator: nil
}
]
rule = FactoryBot.build(:automation_rule, params)
expect(rule.valid?).to be true
end
end
describe 'reauthorizable' do

View File

@@ -10,7 +10,8 @@ RSpec.describe AutomationRules::ConditionValidationService do
rule.conditions = [
{ 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' },
{ 'values': ['+918484'], 'attribute_key': 'phone_number', 'query_operator': 'OR', 'filter_operator': 'contains' },
{ 'values': ['test'], 'attribute_key': 'email', 'query_operator': nil, 'filter_operator': 'contains' }
{ 'values': ['test'], 'attribute_key': 'email', 'query_operator': 'OR', 'filter_operator': 'contains' },
{ 'values': [true], 'attribute_key': 'private_note', 'query_operator': nil, 'filter_operator': 'equal_to' }
]
rule.save
end

View File

@@ -83,6 +83,27 @@ RSpec.describe AutomationRules::ConditionsFilterService do
end
end
context 'when filtering private notes' do
before do
rule.conditions = [
{ 'values': [true], 'attribute_key': 'private_note', 'query_operator': nil, 'filter_operator': 'equal_to' }
]
rule.save
end
it 'will return true when the message is a private note' do
message.update!(private: true)
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(true)
end
it 'will return false when the message is not a private note' do
message.update!(private: false)
expect(described_class.new(rule, conversation, { message: message, changed_attributes: {} }).perform).to be(false)
end
end
context 'when filter_operator is on processed_message_content' do
before do
rule.conditions = [