feat(captain): Add audience and schedule controls for assistants (#14902)
Captain assistants now support **audience** and **schedule** controls, so you can decide *who* an assistant replies to and *when* it's on duty. By default nothing changes, an assistant still responds to every conversation in its connected inboxes but you can now narrow that down. - **Audience**: build a condition tree (contact attributes, conversation attributes, and custom attributes) with and/or groups, mirroring the contact-segment filter semantics. Only conversations whose contact matches the audience get a Captain reply. - **Schedule**: choose when Captain replies — *Anytime*, *During business hours*, or *Outside business hours* (based on each inbox's configured working hours; inboxes without business hours are always covered). When an assistant opts out of a conversation (contact outside the audience, or off-schedule), the conversation is routed to the human queue instead of being parked pending on a silent bot — both on initial creation and on reopen. Fixes https://linear.app/chatwoot/issue/CW-7414/audience-and-availability-controls |Audience|Availability| |--|--| | <img width="1132" height="627" alt="Screenshot 2026-06-30 at 5 52 09 PM" src="https://github.com/user-attachments/assets/866910e0-e1d7-4248-8630-d91afc758688" /> | <img width="1131" height="539" alt="Screenshot 2026-06-30 at 5 52 13 PM" src="https://github.com/user-attachments/assets/aad0d6f7-ceb7-4546-a049-095c5b46b483" /> | ## How to test 1. Open **Captain → Assistants → (an assistant) → Settings**. 2. Under **Audience**, add a condition or condition group (e.g. `Contact language equal_to en`) and save. Start a conversation from a contact that does *not* match — Captain should stay silent and the conversation should land in the human (open) queue instead of pending. 3. With a matching contact, Captain should respond as before. 4. Under **Schedule**, pick **During business hours** (or **Outside business hours**) on an inbox that has working hours configured, and confirm Captain only engages within/outside that window. An empty/`Anytime` schedule always responds. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Co-authored-by: aakashb95 <aakashbakhle@gmail.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
@@ -127,7 +127,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
assistant_config_attributes = [
|
||||
:product_name, :feature_faq, :feature_memory, :feature_citation,
|
||||
:feature_contact_attributes, :welcome_message, :handoff_message,
|
||||
:resolution_message, :instructions, :temperature, :auto_resolve_mode
|
||||
:resolution_message, :instructions, :temperature, :auto_resolve_mode,
|
||||
:response_window
|
||||
]
|
||||
|
||||
permitted = params.require(:assistant).permit(:name, :description,
|
||||
@@ -138,9 +139,21 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
|
||||
permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant].key?(:guardrails)
|
||||
|
||||
permit_audience_config(permitted)
|
||||
|
||||
permitted
|
||||
end
|
||||
|
||||
# The audience is a recursive condition tree that strong params can't whitelist by shape;
|
||||
# pass it through raw and let Captain::AudienceValidator enforce validity.
|
||||
def permit_audience_config(permitted)
|
||||
config = params[:assistant][:config]
|
||||
return unless config.try(:key?, :audience)
|
||||
|
||||
audience = config[:audience]
|
||||
permitted[:config][:audience] = audience.respond_to?(:permit!) ? audience.permit!.to_h : audience
|
||||
end
|
||||
|
||||
def playground_params
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
|
||||
end
|
||||
|
||||
@@ -20,6 +20,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
DESCRIPTION_LENGTH_LIMIT = 500
|
||||
CITATION_SOURCES_STATE_KEY = :captain_v2_citation_sources
|
||||
AUTO_RESOLVE_MODES = %w[disabled legacy evaluated].freeze
|
||||
RESPONSE_WINDOWS = %w[always business_hours outside_business_hours].freeze
|
||||
|
||||
include Avatarable
|
||||
include Concerns::CaptainToolsHelpers
|
||||
@@ -44,13 +45,15 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :conversation_outcomes, dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name,
|
||||
:auto_resolve_mode
|
||||
:auto_resolve_mode, :response_window
|
||||
|
||||
before_validation :set_default_auto_resolve_mode, on: :create
|
||||
|
||||
validates :name, presence: true
|
||||
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
|
||||
validates :account_id, presence: true
|
||||
validates_with Captain::AudienceValidator
|
||||
validate :validate_response_window
|
||||
validates :auto_resolve_mode, inclusion: { in: AUTO_RESOLVE_MODES }
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
@@ -61,6 +64,26 @@ class Captain::Assistant < ApplicationRecord
|
||||
name
|
||||
end
|
||||
|
||||
def engages?(contact, conversation)
|
||||
responds_to_audience?(contact, conversation) && available_now?(conversation)
|
||||
end
|
||||
|
||||
def responds_to_audience?(contact, conversation)
|
||||
return true if config['audience'].blank?
|
||||
|
||||
Captain::AudienceMatcher.new(config['audience']).matches?(contact, conversation)
|
||||
end
|
||||
|
||||
def available_now?(conversation)
|
||||
response_window = config['response_window']
|
||||
return true if response_window.blank? || response_window == 'always'
|
||||
|
||||
inbox = conversation.inbox
|
||||
return true unless inbox.working_hours_enabled?
|
||||
|
||||
response_window == 'business_hours' ? !inbox.out_of_office? : inbox.out_of_office?
|
||||
end
|
||||
|
||||
def auto_resolve_mode
|
||||
config.fetch('auto_resolve_mode') { account&.captain_auto_resolve_mode || 'evaluated' }
|
||||
end
|
||||
@@ -129,6 +152,13 @@ class Captain::Assistant < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def validate_response_window
|
||||
response_window = config['response_window']
|
||||
return if response_window.blank?
|
||||
|
||||
errors.add(:config, 'invalid response_window') unless RESPONSE_WINDOWS.include?(response_window)
|
||||
end
|
||||
|
||||
def set_default_auto_resolve_mode
|
||||
return if config.key?('auto_resolve_mode')
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ module Enterprise::Conversation
|
||||
|
||||
private
|
||||
|
||||
def determine_conversation_status
|
||||
super
|
||||
return unless pending?
|
||||
return if inbox.external_bot_active?
|
||||
|
||||
assistant = inbox.captain_assistant
|
||||
self.status = :open if assistant.present? && !assistant.engages?(contact, self)
|
||||
end
|
||||
|
||||
def handle_resolved_status_change
|
||||
super
|
||||
update_applied_sla_completion
|
||||
|
||||
@@ -29,6 +29,15 @@ module Enterprise::Message
|
||||
|
||||
private
|
||||
|
||||
def reopen_resolved_conversation
|
||||
assistant = conversation.inbox.captain_assistant
|
||||
|
||||
return super if assistant.blank? || conversation.inbox.external_bot_active?
|
||||
return conversation.open! unless assistant.engages?(conversation.contact, conversation)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def mark_pending_conversation_as_open_for_human_response
|
||||
return unless captain_pending_conversation?
|
||||
return unless human_response?
|
||||
|
||||
180
enterprise/app/services/captain/audience_matcher.rb
Normal file
180
enterprise/app/services/captain/audience_matcher.rb
Normal file
@@ -0,0 +1,180 @@
|
||||
# Evaluates an assistant's audience condition tree in-memory against the conversation's contact.
|
||||
# A node is either a group ({ operator:, conditions: [...] }) or a leaf
|
||||
# ({ attribute_key:, filter_operator:, values: [...] }). Operator semantics mirror
|
||||
# Contacts::FilterService so audiences match the same contacts as segments.
|
||||
class Captain::AudienceMatcher
|
||||
CONTACT_STANDARD = %w[name email phone_number identifier blocked created_at last_activity_at].freeze
|
||||
CONTACT_ADDITIONAL = %w[country_code city company_name].freeze
|
||||
CONVERSATION_ADDITIONAL = %w[browser_language].freeze
|
||||
OPERATORS = %w[equal_to not_equal_to contains does_not_contain is_present is_not_present starts_with
|
||||
is_greater_than is_less_than days_before].freeze
|
||||
# Root group -> sub-group -> leaves.
|
||||
MAX_DEPTH = 3
|
||||
|
||||
def initialize(audience)
|
||||
@root = audience
|
||||
end
|
||||
|
||||
def matches?(contact, conversation)
|
||||
return true if @root.blank?
|
||||
|
||||
@contact = contact
|
||||
@conversation = conversation
|
||||
matches_node?(@root)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def matches_node?(node)
|
||||
node = node.with_indifferent_access
|
||||
node.key?(:conditions) ? matches_group?(node) : matches_leaf?(node)
|
||||
end
|
||||
|
||||
def matches_group?(group)
|
||||
conditions = Array(group[:conditions])
|
||||
if group[:operator].to_s.casecmp?('or')
|
||||
conditions.any? { |child| matches_node?(child) }
|
||||
else
|
||||
conditions.all? { |child| matches_node?(child) }
|
||||
end
|
||||
end
|
||||
|
||||
def matches_leaf?(leaf)
|
||||
key = leaf[:attribute_key]
|
||||
actual = attribute_value(key)
|
||||
values = Array(leaf[:values])
|
||||
|
||||
case leaf[:filter_operator]
|
||||
when 'is_present' then actual.present?
|
||||
when 'is_not_present' then actual.blank?
|
||||
when 'equal_to' then values.any? { |expected| value_equal?(key, actual, expected) }
|
||||
when 'not_equal_to' then negative_equality_match?(key, actual, values)
|
||||
else matches_text_or_range?(leaf[:filter_operator], actual, values.first)
|
||||
end
|
||||
end
|
||||
|
||||
def attribute_value(key)
|
||||
case key
|
||||
when *CONTACT_STANDARD then @contact[key]
|
||||
when *CONTACT_ADDITIONAL then @contact.additional_attributes[key]
|
||||
when 'labels' then @contact.label_list
|
||||
when *CONVERSATION_ADDITIONAL then @conversation.additional_attributes[key]
|
||||
when 'hmac_verified' then hmac_verified?
|
||||
else @contact.custom_attributes[key]
|
||||
end
|
||||
end
|
||||
|
||||
def hmac_verified?
|
||||
@conversation.contact_inbox&.hmac_verified || false
|
||||
end
|
||||
|
||||
# labels is a has-tag check, booleans cast the expected string, phone numbers
|
||||
# ignore the "+" prefix, and text compares case-insensitively.
|
||||
def value_equal?(key, actual, expected)
|
||||
return Array(actual).include?(expected) if key == 'labels'
|
||||
return ActiveModel::Type::Boolean.new.cast(expected) == (actual == true) if boolean_condition?(key, actual, expected)
|
||||
return numeric_equal?(actual, expected) if actual.is_a?(Numeric) || numeric_attribute?(key)
|
||||
|
||||
normalize(key, actual) == normalize(key, expected)
|
||||
end
|
||||
|
||||
def numeric_equal?(actual, expected)
|
||||
BigDecimal(actual.to_s) == BigDecimal(expected.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
false
|
||||
end
|
||||
|
||||
def negative_equality_match?(key, actual, values)
|
||||
if actual.nil?
|
||||
custom_attribute = custom_attribute?(key)
|
||||
return custom_attribute unless custom_attribute && checkbox_attribute?(key)
|
||||
end
|
||||
|
||||
values.none? { |expected| value_equal?(key, actual, expected) }
|
||||
end
|
||||
|
||||
def custom_attribute?(key)
|
||||
CONTACT_STANDARD.exclude?(key) && CONTACT_ADDITIONAL.exclude?(key) && CONVERSATION_ADDITIONAL.exclude?(key) &&
|
||||
%w[labels hmac_verified].exclude?(key)
|
||||
end
|
||||
|
||||
# An unset checkbox attribute counts as false.
|
||||
def boolean_condition?(key, actual, expected)
|
||||
[true, false].include?(actual) ||
|
||||
(actual.nil? && %w[true false].include?(expected.to_s) && checkbox_attribute?(key))
|
||||
end
|
||||
|
||||
def checkbox_attribute?(key)
|
||||
custom_attribute_types[key] == 'checkbox'
|
||||
end
|
||||
|
||||
def numeric_attribute?(key)
|
||||
%w[number currency percent].include?(custom_attribute_types[key])
|
||||
end
|
||||
|
||||
def custom_attribute_types
|
||||
@custom_attribute_types ||= @contact.account.custom_attribute_definitions.contact_attribute.each_with_object({}) do |definition, types|
|
||||
types[definition.attribute_key] = definition.attribute_display_type
|
||||
end
|
||||
end
|
||||
|
||||
def normalize(key, value)
|
||||
return value if value.nil?
|
||||
return "+#{value.to_s.delete('+')}" if key == 'phone_number'
|
||||
|
||||
value.is_a?(String) ? value.downcase : value
|
||||
end
|
||||
|
||||
def matches_text_or_range?(operator, actual, expected)
|
||||
case operator
|
||||
when 'contains' then actual.to_s.downcase.include?(expected.to_s.downcase)
|
||||
when 'does_not_contain' then excludes_text?(actual, expected)
|
||||
when 'starts_with' then actual.to_s.downcase.start_with?(expected.to_s.downcase)
|
||||
when 'is_greater_than' then compare(actual, expected) == 1
|
||||
when 'is_less_than' then compare(actual, expected) == -1
|
||||
when 'days_before' then older_than_days?(actual, expected)
|
||||
else false
|
||||
end
|
||||
end
|
||||
|
||||
def excludes_text?(actual, expected)
|
||||
!actual.nil? && actual.to_s.downcase.exclude?(expected.to_s.downcase)
|
||||
end
|
||||
|
||||
# -1/0/1 like <=>, or nil when blank or unparseable (never matches).
|
||||
def compare(actual, expected)
|
||||
return nil if actual.blank?
|
||||
|
||||
actual = Time.zone.parse(actual) if iso_date_string?(actual)
|
||||
|
||||
if actual.is_a?(Date) || actual.acts_like?(:time)
|
||||
expected_date = to_date(expected)
|
||||
actual.to_date <=> expected_date if expected_date
|
||||
else
|
||||
BigDecimal(actual.to_s) <=> BigDecimal(expected.to_s)
|
||||
end
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
# Custom date attributes store ISO strings in jsonb; treat them as dates the way
|
||||
# Contacts::FilterService does (it casts them in SQL).
|
||||
def iso_date_string?(value)
|
||||
value.is_a?(String) && Date.iso8601(value).present?
|
||||
rescue ArgumentError
|
||||
false
|
||||
end
|
||||
|
||||
def older_than_days?(actual, days)
|
||||
date = to_date(actual)
|
||||
date.present? && date < Time.zone.today - days.to_i.days
|
||||
end
|
||||
|
||||
def to_date(value)
|
||||
return value.to_date if value.respond_to?(:to_date)
|
||||
|
||||
Date.parse(value.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
class Captain::Conversation::ResponseSchedulerService
|
||||
MAX_ATTACHMENT_WAIT_SECONDS = 4
|
||||
|
||||
def initialize(message:)
|
||||
@message = message
|
||||
@conversation = message.conversation
|
||||
@assistant = message.inbox.captain_assistant
|
||||
end
|
||||
|
||||
def perform
|
||||
track_captain_engagement
|
||||
|
||||
wait_time = attachment_wait_time
|
||||
return Captain::Conversation::ResponseBuilderJob.perform_later(*job_args) if wait_time.zero?
|
||||
|
||||
Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def job_args
|
||||
args = [@conversation, @assistant]
|
||||
args << @message.id if captain_v2_enabled?
|
||||
args
|
||||
end
|
||||
|
||||
def track_captain_engagement
|
||||
return unless captain_v2_enabled?
|
||||
|
||||
Captain::ConversationEvents.engaged(
|
||||
conversation: @conversation,
|
||||
assistant: @assistant,
|
||||
at: @message.created_at
|
||||
)
|
||||
end
|
||||
|
||||
def captain_v2_enabled?
|
||||
@conversation.account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
|
||||
def attachment_wait_time
|
||||
attachment_count = captain_v2_enabled? ? recent_attachment_count : @message.attachments.size
|
||||
return 0.seconds if attachment_count.zero?
|
||||
|
||||
base_wait = 1.second
|
||||
additional_wait = [attachment_count, MAX_ATTACHMENT_WAIT_SECONDS].min.seconds
|
||||
base_wait + additional_wait
|
||||
end
|
||||
|
||||
def recent_attachment_count
|
||||
maximum_wait = (MAX_ATTACHMENT_WAIT_SECONDS + 1).seconds
|
||||
|
||||
@conversation.messages.incoming
|
||||
.joins(:attachments)
|
||||
.where(attachments: { created_at: maximum_wait.ago.. })
|
||||
.count
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,10 @@
|
||||
module Enterprise::MessageTemplates::HookExecutionService
|
||||
MAX_ATTACHMENT_WAIT_SECONDS = 4
|
||||
|
||||
def trigger_templates
|
||||
super
|
||||
return unless should_process_captain_response?
|
||||
return perform_handoff unless inbox.captain_active?
|
||||
|
||||
track_captain_engagement
|
||||
schedule_captain_response
|
||||
Captain::Conversation::ResponseSchedulerService.new(message: message).perform
|
||||
end
|
||||
|
||||
def should_send_greeting?
|
||||
@@ -30,59 +27,14 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
|
||||
private
|
||||
|
||||
def track_captain_engagement
|
||||
return unless captain_v2_enabled?
|
||||
|
||||
Captain::ConversationEvents.engaged(
|
||||
conversation: conversation,
|
||||
assistant: inbox.captain_assistant,
|
||||
at: message.created_at
|
||||
)
|
||||
end
|
||||
|
||||
def captain_v2_enabled?
|
||||
conversation.account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
|
||||
def schedule_captain_response
|
||||
job_args = [conversation, conversation.inbox.captain_assistant]
|
||||
captain_v2_enabled = conversation.account.feature_enabled?('captain_integration_v2')
|
||||
job_args << message.id if captain_v2_enabled
|
||||
wait_time = attachment_wait_time(captain_v2_enabled)
|
||||
|
||||
if wait_time.zero?
|
||||
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
|
||||
else
|
||||
Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
|
||||
end
|
||||
end
|
||||
|
||||
def attachment_wait_time(captain_v2_enabled)
|
||||
attachment_count = captain_v2_enabled ? recent_attachment_count : message.attachments.size
|
||||
return 0.seconds if attachment_count.zero?
|
||||
|
||||
calculate_attachment_wait_time(attachment_count)
|
||||
end
|
||||
|
||||
def recent_attachment_count
|
||||
maximum_wait = (MAX_ATTACHMENT_WAIT_SECONDS + 1).seconds
|
||||
|
||||
conversation.messages.incoming
|
||||
.joins(:attachments)
|
||||
.where(attachments: { created_at: maximum_wait.ago.. })
|
||||
.count
|
||||
end
|
||||
|
||||
def calculate_attachment_wait_time(attachment_count)
|
||||
base_wait = 1.second
|
||||
|
||||
# Wait longer for more attachments or larger files
|
||||
additional_wait = [attachment_count * 1, MAX_ATTACHMENT_WAIT_SECONDS].min.seconds
|
||||
base_wait + additional_wait
|
||||
end
|
||||
|
||||
def should_process_captain_response?
|
||||
conversation.pending? && message.captain_response_triggering? && inbox.captain_assistant.present? && !inbox.external_bot_active?
|
||||
# Audience and schedule are decided when Captain first takes or reopens a conversation.
|
||||
# Do not re-evaluate an existing pending conversation for each new message.
|
||||
conversation.pending? && message.captain_response_triggering? && captain_assistant_configured? && !inbox.external_bot_active?
|
||||
end
|
||||
|
||||
def perform_handoff
|
||||
@@ -117,6 +69,10 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
end
|
||||
|
||||
def captain_handling_conversation?
|
||||
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
|
||||
conversation.pending? && captain_assistant_configured?
|
||||
end
|
||||
|
||||
def captain_assistant_configured?
|
||||
inbox.captain_assistant.present?
|
||||
end
|
||||
end
|
||||
|
||||
77
enterprise/app/validators/captain/audience_validator.rb
Normal file
77
enterprise/app/validators/captain/audience_validator.rb
Normal file
@@ -0,0 +1,77 @@
|
||||
class Captain::AudienceValidator < ActiveModel::Validator
|
||||
GROUP_OPERATORS = %w[and or].freeze
|
||||
VALUELESS_OPERATORS = %w[is_present is_not_present].freeze
|
||||
EQUALITY_OPERATORS = %w[equal_to not_equal_to].freeze
|
||||
CONTAINMENT_OPERATORS = %w[equal_to not_equal_to contains does_not_contain].freeze
|
||||
DATE_OPERATORS = %w[is_greater_than is_less_than days_before].freeze
|
||||
COMPARISON_OPERATORS = %w[equal_to not_equal_to is_present is_not_present is_greater_than is_less_than].freeze
|
||||
|
||||
STANDARD_ATTRIBUTE_OPERATORS = {
|
||||
'name' => EQUALITY_OPERATORS,
|
||||
'email' => CONTAINMENT_OPERATORS,
|
||||
'phone_number' => CONTAINMENT_OPERATORS,
|
||||
'identifier' => EQUALITY_OPERATORS,
|
||||
'blocked' => EQUALITY_OPERATORS,
|
||||
'created_at' => DATE_OPERATORS,
|
||||
'last_activity_at' => DATE_OPERATORS,
|
||||
'country_code' => EQUALITY_OPERATORS,
|
||||
'city' => CONTAINMENT_OPERATORS,
|
||||
'company_name' => CONTAINMENT_OPERATORS,
|
||||
'labels' => EQUALITY_OPERATORS,
|
||||
'hmac_verified' => EQUALITY_OPERATORS,
|
||||
'browser_language' => EQUALITY_OPERATORS
|
||||
}.freeze
|
||||
|
||||
CUSTOM_ATTRIBUTE_OPERATORS = {
|
||||
'text' => CONTAINMENT_OPERATORS,
|
||||
'number' => EQUALITY_OPERATORS,
|
||||
'currency' => EQUALITY_OPERATORS,
|
||||
'percent' => EQUALITY_OPERATORS,
|
||||
'link' => EQUALITY_OPERATORS,
|
||||
'date' => COMPARISON_OPERATORS,
|
||||
'list' => EQUALITY_OPERATORS,
|
||||
'checkbox' => EQUALITY_OPERATORS
|
||||
}.freeze
|
||||
|
||||
def validate(record)
|
||||
audience = record.config['audience']
|
||||
return if audience.blank?
|
||||
|
||||
custom_attribute_types = record.account.custom_attribute_definitions.contact_attribute.each_with_object({}) do |definition, types|
|
||||
types[definition.attribute_key] = definition.attribute_display_type
|
||||
end
|
||||
|
||||
record.errors.add(:config, 'audience must be a valid condition tree') unless valid_node?(audience, 1, custom_attribute_types)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_node?(node, depth, custom_attribute_types)
|
||||
return false unless node.is_a?(Hash) && depth <= Captain::AudienceMatcher::MAX_DEPTH
|
||||
|
||||
node = node.with_indifferent_access
|
||||
return valid_group?(node, depth, custom_attribute_types) if node.key?(:conditions)
|
||||
|
||||
valid_leaf?(node, custom_attribute_types)
|
||||
end
|
||||
|
||||
def valid_group?(node, depth, custom_attribute_types)
|
||||
GROUP_OPERATORS.include?(node[:operator].to_s) &&
|
||||
node[:conditions].is_a?(Array) &&
|
||||
node[:conditions].present? &&
|
||||
node[:conditions].all? { |child| valid_node?(child, depth + 1, custom_attribute_types) }
|
||||
end
|
||||
|
||||
def valid_leaf?(node, custom_attribute_types)
|
||||
operator = node[:filter_operator].to_s
|
||||
allowed_operators = allowed_operators(node[:attribute_key].to_s, custom_attribute_types)
|
||||
return false unless allowed_operators&.include?(operator)
|
||||
return true if VALUELESS_OPERATORS.include?(operator)
|
||||
|
||||
node[:values].is_a?(Array) && node[:values].present? && node[:values].all? { |value| value.to_s.present? }
|
||||
end
|
||||
|
||||
def allowed_operators(attribute_key, custom_attribute_types)
|
||||
STANDARD_ATTRIBUTE_OPERATORS[attribute_key] || CUSTOM_ATTRIBUTE_OPERATORS[custom_attribute_types[attribute_key]]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user