From 11826e2a21e6f4490f63d28b2a4d627ef90a0a8b Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:23:44 +0530 Subject: [PATCH 01/23] perf: reduce presence update frequency and fix background tab throttling (#13726) ## Description Reduces the frequency of update_presence WebSocket calls from the live chat widget and fixes agents appearing offline when the dashboard is in a background tab. ## Fixes # (issue) https://github.com/chatwoot/chatwoot/issues/13720 ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## 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 --- .../shared/helpers/BaseActionCableConnector.js | 9 +++++++-- app/javascript/widget/helpers/actionCable.js | 4 +++- app/services/internal/remove_stale_redis_keys_service.rb | 2 +- lib/online_status_tracker.rb | 7 +++++-- spec/lib/online_status_tracker_spec.rb | 2 +- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/javascript/shared/helpers/BaseActionCableConnector.js b/app/javascript/shared/helpers/BaseActionCableConnector.js index 3eb61a80a..06f529dde 100644 --- a/app/javascript/shared/helpers/BaseActionCableConnector.js +++ b/app/javascript/shared/helpers/BaseActionCableConnector.js @@ -6,7 +6,12 @@ const RECONNECT_INTERVAL = 1000; class BaseActionCableConnector { static isDisconnected = false; - constructor(app, pubsubToken, websocketHost = '') { + constructor( + app, + pubsubToken, + websocketHost = '', + presenceInterval = PRESENCE_INTERVAL + ) { const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined; this.consumer = createConsumer(websocketURL); @@ -37,7 +42,7 @@ class BaseActionCableConnector { setTimeout(() => { this.subscription.updatePresence(); this.triggerPresenceInterval(); - }, PRESENCE_INTERVAL); + }, presenceInterval); }; this.triggerPresenceInterval(); } diff --git a/app/javascript/widget/helpers/actionCable.js b/app/javascript/widget/helpers/actionCable.js index 4e18d0c70..60c379ed8 100644 --- a/app/javascript/widget/helpers/actionCable.js +++ b/app/javascript/widget/helpers/actionCable.js @@ -13,9 +13,11 @@ const isMessageInActiveConversation = (getters, message) => { return activeConversationId && conversationId !== activeConversationId; }; +const WIDGET_PRESENCE_INTERVAL = 60000; + class ActionCableConnector extends BaseActionCableConnector { constructor(app, pubsubToken) { - super(app, pubsubToken); + super(app, pubsubToken, '', WIDGET_PRESENCE_INTERVAL); this.events = { 'message.created': this.onMessageCreated, 'message.updated': this.onMessageUpdated, diff --git a/app/services/internal/remove_stale_redis_keys_service.rb b/app/services/internal/remove_stale_redis_keys_service.rb index 553cc6c6a..609fcb4a6 100644 --- a/app/services/internal/remove_stale_redis_keys_service.rb +++ b/app/services/internal/remove_stale_redis_keys_service.rb @@ -3,7 +3,7 @@ class Internal::RemoveStaleRedisKeysService def perform Rails.logger.info "Removing redis stale keys for account #{@account_id}" - range_start = (Time.zone.now - OnlineStatusTracker::PRESENCE_DURATION).to_i + range_start = (Time.zone.now - OnlineStatusTracker::CONTACT_PRESENCE_DURATION).to_i # exclusive minimum score is specified by prefixing ( # we are clearing old records because this could clogg up the sorted set ::Redis::Alfred.zremrangebyscore( diff --git a/lib/online_status_tracker.rb b/lib/online_status_tracker.rb index bc2ed1dbc..20b379c02 100644 --- a/lib/online_status_tracker.rb +++ b/lib/online_status_tracker.rb @@ -1,6 +1,8 @@ class OnlineStatusTracker # NOTE: You can customise the environment variable to keep your agents/contacts as online for longer PRESENCE_DURATION = ENV.fetch('PRESENCE_DURATION', 20).to_i.seconds + # Widget pings every 60s, so contacts need a longer presence window + CONTACT_PRESENCE_DURATION = ENV.fetch('CONTACT_PRESENCE_DURATION', 90).to_i.seconds # presence : sorted set with timestamp as the score & object id as value @@ -11,7 +13,8 @@ class OnlineStatusTracker def self.get_presence(account_id, obj_type, obj_id) connected_time = ::Redis::Alfred.zscore(presence_key(account_id, obj_type), obj_id) - connected_time && connected_time > (Time.zone.now - PRESENCE_DURATION).to_i + duration = obj_type == 'Contact' ? CONTACT_PRESENCE_DURATION : PRESENCE_DURATION + connected_time && connected_time > (Time.zone.now - duration).to_i end def self.presence_key(account_id, type) @@ -39,7 +42,7 @@ class OnlineStatusTracker end def self.get_available_contact_ids(account_id) - range_start = (Time.zone.now - PRESENCE_DURATION).to_i + range_start = (Time.zone.now - CONTACT_PRESENCE_DURATION).to_i # exclusive minimum score is specified by prefixing ( # we are clearing old records because this could clogg up the sorted set ::Redis::Alfred.zremrangebyscore(presence_key(account_id, 'Contact'), '-inf', "(#{range_start}") diff --git a/spec/lib/online_status_tracker_spec.rb b/spec/lib/online_status_tracker_spec.rb index d88298485..70820d95e 100644 --- a/spec/lib/online_status_tracker_spec.rb +++ b/spec/lib/online_status_tracker_spec.rb @@ -42,7 +42,7 @@ describe OnlineStatusTracker do described_class.update_presence(account.id, 'Contact', online_contact.id) # creating a stale record for offline contact presence Redis::Alfred.zadd(format(Redis::Alfred::ONLINE_PRESENCE_CONTACTS, account_id: account.id), - (Time.zone.now - (OnlineStatusTracker::PRESENCE_DURATION + 20)).to_i, offline_contact.id) + (Time.zone.now - (OnlineStatusTracker::CONTACT_PRESENCE_DURATION + 20)).to_i, offline_contact.id) end it 'returns only the online contact ids with presence' do From 4576e75a6792f9d45790841923516f80c5a65208 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Mon, 9 Mar 2026 20:03:01 +0530 Subject: [PATCH 02/23] fix: bump redis-client to 0.26.4 to fix Sentinel resolution (#13689) Description: ## Summary - `redis-client` 0.22.2 uses `.call()` during Sentinel master resolution, but `redis-rb` 5.x undefines `.call()` (only `.call_v()` exists), causing Sentinel connections to fail. - Bumps `redis-client` from 0.22.2 to 0.26.4 which includes the upstream fix (redis-rb/redis-client#283). - Also bumps transitive dependency `connection_pool` from 2.5.3 to 2.5.5. Fixes #11665 https://github.com/chatwoot/chatwoot/issues/8368 ## Test - `bundle exec rspec spec/lib/redis/config_spec.rb` passes - Full CI suite passes --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7a7316e3c..c32c2e5a7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -191,7 +191,7 @@ GEM coderay (1.1.3) commonmarker (0.23.10) concurrent-ruby (1.3.5) - connection_pool (2.5.3) + connection_pool (2.5.5) crack (1.0.0) bigdecimal rexml @@ -736,7 +736,7 @@ GEM ffi (~> 1.0) redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.22.2) + redis-client (0.26.4) connection_pool redis-namespace (1.10.0) redis (>= 4) From 9e40431d3a40ff4bada01588a1cb1050721fcf13 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 9 Mar 2026 08:04:36 -0700 Subject: [PATCH 03/23] feat: show MFA status on Super Admin user page (#13724) This PR adds an MFA row to the individual Super Admin user page and shows the current state as Enabled or Disabled with a compact status badge. Fixes #13723 ## Screens image image --- app/views/super_admin/users/show.html.erb | 8 +++++++ .../super_admin/users_controller_spec.rb | 23 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/views/super_admin/users/show.html.erb b/app/views/super_admin/users/show.html.erb index d8b2c6102..f7ac71480 100644 --- a/app/views/super_admin/users/show.html.erb +++ b/app/views/super_admin/users/show.html.erb @@ -53,6 +53,14 @@ as well as a link to its edit page. <% end %> <% end %> + +
MFA
+
+ <% mfa_enabled = page.resource.mfa_enabled? %> + + <%= mfa_enabled ? 'Enabled' : 'Disabled' %> + +
diff --git a/spec/controllers/super_admin/users_controller_spec.rb b/spec/controllers/super_admin/users_controller_spec.rb index 894c9d425..e1461f554 100644 --- a/spec/controllers/super_admin/users_controller_spec.rb +++ b/spec/controllers/super_admin/users_controller_spec.rb @@ -12,7 +12,7 @@ RSpec.describe 'Super Admin Users API', type: :request do end context 'when it is an authenticated super admin' do - let!(:user) { create(:user) } + let!(:user) { create(:user, name: 'Disabled User') } let!(:params) do { user: { name: 'admin@example.com', @@ -27,9 +27,13 @@ RSpec.describe 'Super Admin Users API', type: :request do it 'shows the list of users' do sign_in(super_admin, scope: :super_admin) get '/super_admin/users' + doc = Nokogiri::HTML(response.body) + header_texts = doc.css('table thead th').map { |header| header.text.squish } + expect(response).to have_http_status(:success) expect(response.body).to include('New user') expect(response.body).to include(CGI.escapeHTML(user.name)) + expect(header_texts).not_to include('MFA') end it 'creates the new super_admin record' do @@ -100,4 +104,21 @@ RSpec.describe 'Super Admin Users API', type: :request do expect(mail_jobs.count).to be >= 1 end end + + describe 'GET /super_admin/users/:id' do + let!(:user) { create(:user, name: 'MFA Enabled User', otp_required_for_login: true) } + + it 'shows the MFA status on the user detail page' do + sign_in(super_admin, scope: :super_admin) + + get "/super_admin/users/#{user.id}" + doc = Nokogiri::HTML(response.body) + labels = doc.css('dt.attribute-label').map { |label| label.text.squish } + + expect(response).to have_http_status(:success) + expect(labels).to include('MFA') + expect(response.body).to include('Enabled') + expect(response.body).to include(CGI.escapeHTML(user.name)) + end + end end From 432462f967373fe9a9a2722c374840115f9a6aee Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 9 Mar 2026 21:17:05 +0530 Subject: [PATCH 04/23] feat: harden filter service --- VERSION_CW | 2 +- app/helpers/filters/filter_helper.rb | 14 ++- app/models/custom_attribute_definition.rb | 9 +- app/services/filter_service.rb | 60 +++++++--- config/app.yml | 2 +- config/locales/en.yml | 1 + package.json | 2 +- .../custom_attribute_definition_spec.rb | 61 ++++++++++ spec/services/contacts/filter_service_spec.rb | 106 +++++++++++++++++- .../conversations/filter_service_spec.rb | 35 ++++++ 10 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 spec/models/custom_attribute_definition_spec.rb diff --git a/VERSION_CW b/VERSION_CW index d782fca8f..4f89fb960 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.11.1 +4.11.2 diff --git a/app/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb index fe03dae28..4f345676e 100644 --- a/app/helpers/filters/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -47,11 +47,15 @@ module Filters::FilterHelper def handle_additional_attributes(query_hash, filter_operator_value, data_type) if data_type == 'text_case_insensitive' - "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \ - "#{filter_operator_value} #{query_hash[:query_operator]}" + ActiveRecord::Base.sanitize_sql_array( + ["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}", + query_hash[:attribute_key]] + ) else - "#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \ - "#{filter_operator_value} #{query_hash[:query_operator]} " + ActiveRecord::Base.sanitize_sql_array( + ["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ", + query_hash[:attribute_key]] + ) end end @@ -70,7 +74,7 @@ module Filters::FilterHelper def date_filter(current_filter, query_hash, filter_operator_value) "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \ - "#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}" + "#{filter_operator_value} #{query_hash[:query_operator]}" end def text_case_insensitive_filter(query_hash, filter_operator_value) diff --git a/app/models/custom_attribute_definition.rb b/app/models/custom_attribute_definition.rb index 70956c108..a2775ebb7 100644 --- a/app/models/custom_attribute_definition.rb +++ b/app/models/custom_attribute_definition.rb @@ -30,10 +30,12 @@ class CustomAttributeDefinition < ApplicationRecord scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) } validates :attribute_display_name, presence: true + before_validation :normalize_attribute_fields validates :attribute_key, presence: true, - uniqueness: { scope: [:account_id, :attribute_model] } + uniqueness: { scope: [:account_id, :attribute_model] }, + format: { with: /\A[\p{L}\p{N}_.\-]+\z/, message: I18n.t('errors.custom_attribute_definition.attribute_key_format') } validates :attribute_display_type, presence: true validates :attribute_model, presence: true @@ -48,6 +50,11 @@ class CustomAttributeDefinition < ApplicationRecord private + def normalize_attribute_fields + self.attribute_key = attribute_key.strip if attribute_key.present? + self.attribute_display_name = attribute_display_name.strip if attribute_display_name.present? + end + def sync_widget_pre_chat_custom_fields ::Inboxes::SyncWidgetPreChatCustomFieldsJob.perform_later(account, attribute_key) end diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb index e4cef7941..33e4092c8 100644 --- a/app/services/filter_service.rb +++ b/app/services/filter_service.rb @@ -33,9 +33,9 @@ class FilterService when 'is_not_present' @filter_values["value_#{current_index}"] = 'IS NULL' when 'is_greater_than', 'is_less_than' - @filter_values["value_#{current_index}"] = lt_gt_filter_values(query_hash) + lt_gt_filter_query(query_hash, current_index) when 'days_before' - @filter_values["value_#{current_index}"] = days_before_filter_values(query_hash) + days_before_filter_query(query_hash, current_index) else @filter_values["value_#{current_index}"] = filter_values(query_hash).to_s "= :value_#{current_index}" @@ -81,21 +81,29 @@ class FilterService query_hash['values'].downcase end - def lt_gt_filter_values(query_hash) + def lt_gt_filter_query(query_hash, current_index) attribute_key = query_hash[:attribute_key] attribute_model = query_hash['custom_attribute_type'].presence || self.class::ATTRIBUTE_MODEL attribute_type = custom_attribute(attribute_key, @account, attribute_model).try(:attribute_display_type) - attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] - value = query_hash['values'][0] + attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] || standard_attribute_data_type(attribute_key) + + @filter_values["value_#{current_index}"] = coerce_lt_gt_value( + query_hash['values'][0], + attribute_data_type, + attribute_key + ) operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>' - "#{operator} '#{value}'::#{attribute_data_type}" + "#{operator} :value_#{current_index}" end - def days_before_filter_values(query_hash) + def days_before_filter_query(query_hash, current_index) date = Time.zone.today - query_hash['values'][0].to_i.days - query_hash['values'] = [date.strftime] - query_hash['filter_operator'] = 'is_less_than' - lt_gt_filter_values(query_hash) + updated_query_hash = query_hash.with_indifferent_access.merge( + values: [date.strftime], + filter_operator: 'is_less_than' + ) + + lt_gt_filter_query(updated_query_hash, current_index) end def set_count_for_all_conversations @@ -149,15 +157,39 @@ class FilterService @attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] end + def standard_attribute_data_type(attribute_key) + @filters.each_value do |section| + return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key) + end + nil + end + + def coerce_lt_gt_value(raw_value, attribute_data_type, attribute_key) + case attribute_data_type + when 'date' + Date.iso8601(raw_value.to_s) + when 'numeric' + BigDecimal(raw_value.to_s) + else + raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key) + end + rescue Date::Error, ArgumentError, FloatDomainError, TypeError + raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key) + end + def build_custom_attr_query(query_hash, current_index) filter_operator_value = filter_operation(query_hash, current_index) query_operator = query_hash[:query_operator] table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts' query = if attribute_data_type == 'text' - "LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " + ActiveRecord::Base.sanitize_sql_array( + ["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key] + ) else - "(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " + ActiveRecord::Base.sanitize_sql_array( + ["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key] + ) end query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type) @@ -174,7 +206,9 @@ class FilterService def not_in_custom_attr_query(table_name, query_hash, attribute_data_type) return '' unless query_hash[:filter_operator] == 'not_equal_to' - " OR (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} IS NULL " + ActiveRecord::Base.sanitize_sql_array( + [" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key] + ) end def equals_to_filter_string(filter_operator, current_index) diff --git a/config/app.yml b/config/app.yml index 4a1b004ac..e4293f20f 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.11.1' + version: '4.11.2' development: <<: *shared diff --git a/config/locales/en.yml b/config/locales/en.yml index 07d9b0e2f..0db53492e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -117,6 +117,7 @@ en: invalid_query_operator: Query operator must be either "AND" or "OR". invalid_value: Invalid value. The values provided for %{attribute_name} are invalid custom_attribute_definition: + attribute_key_format: must only contain letters, numbers, underscores, hyphens, and dots key_conflict: The provided key is not allowed as it might conflict with default attributes. mfa: already_enabled: MFA is already enabled diff --git a/package.json b/package.json index b51ad5ffa..3191894c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.11.1", + "version": "4.11.2", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", diff --git a/spec/models/custom_attribute_definition_spec.rb b/spec/models/custom_attribute_definition_spec.rb new file mode 100644 index 000000000..648296b69 --- /dev/null +++ b/spec/models/custom_attribute_definition_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CustomAttributeDefinition do + let(:account) { create(:account) } + + describe 'validations' do + describe 'attribute_key format' do + it 'allows alphanumeric keys with underscores' do + cad = build(:custom_attribute_definition, account: account, attribute_key: 'order_date_1') + expect(cad).to be_valid + end + + it 'allows hyphens and dots' do + cad = build(:custom_attribute_definition, account: account, attribute_key: 'order-date.v2') + expect(cad).to be_valid + end + + it 'allows Unicode letters' do + cad = build(:custom_attribute_definition, account: account, attribute_key: '客户类型') + expect(cad).to be_valid + end + + it 'rejects keys with single quotes' do + cad = build(:custom_attribute_definition, account: account, attribute_key: "x'||(SELECT 1)||'") + expect(cad).not_to be_valid + expect(cad.errors[:attribute_key]).to be_present + end + + it 'rejects keys with spaces' do + cad = build(:custom_attribute_definition, account: account, attribute_key: 'order date') + expect(cad).not_to be_valid + end + + it 'rejects keys with semicolons' do + cad = build(:custom_attribute_definition, account: account, attribute_key: 'key; DROP TABLE users--') + expect(cad).not_to be_valid + end + + it 'rejects keys with parentheses' do + cad = build(:custom_attribute_definition, account: account, attribute_key: 'key()') + expect(cad).not_to be_valid + end + end + end + + describe 'callbacks' do + describe '#strip_attribute_key' do + it 'strips leading and trailing whitespace from attribute_key' do + cad = create(:custom_attribute_definition, account: account, attribute_key: ' order_date ') + expect(cad.attribute_key).to eq('order_date') + end + + it 'strips leading and trailing whitespace from attribute_display_name' do + cad = create(:custom_attribute_definition, account: account, attribute_display_name: ' Order Date ') + expect(cad.attribute_display_name).to eq('Order Date') + end + end + end +end diff --git a/spec/services/contacts/filter_service_spec.rb b/spec/services/contacts/filter_service_spec.rb index 540f1dfd8..77a543011 100644 --- a/spec/services/contacts/filter_service_spec.rb +++ b/spec/services/contacts/filter_service_spec.rb @@ -49,6 +49,11 @@ describe Contacts::FilterService do account: account, attribute_model: 'contact_attribute', attribute_display_type: 'date') + create(:custom_attribute_definition, + attribute_key: 'lifetime_value', + account: account, + attribute_model: 'contact_attribute', + attribute_display_type: 'number') end describe '#perform' do @@ -60,7 +65,7 @@ describe Contacts::FilterService do en_contact.update!(custom_attributes: { contact_additional_information: 'test custom data' }) el_contact.update!(custom_attributes: { contact_additional_information: 'test custom data', customer_type: 'platinum' }) - cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' }) + cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19', lifetime_value: '120.50' }) end context 'with standard attributes - name' do @@ -272,6 +277,39 @@ describe Contacts::FilterService do expect(result[:contacts].pluck(:id)).to include(cs_contact.id) expect(result[:contacts].pluck(:id)).not_to include(en_contact.id) end + + it 'binds last_activity_at comparison values as dates' do + date_value = '2024-01-01' + params[:payload] = [ + { + attribute_key: 'last_activity_at', + filter_operator: 'is_greater_than', + values: [date_value], + query_operator: nil + }.with_indifferent_access + ] + + service = filter_service.new(account, first_user, params) + filters = service.instance_variable_get(:@filters)['contacts'] + condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0) + + expect(condition_query).to include('(contacts.last_activity_at)::date > :value_0') + expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value)) + end + + it 'rejects invalid last_activity_at comparison values' do + malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --" + params[:payload] = [ + { + attribute_key: 'last_activity_at', + filter_operator: 'is_greater_than', + values: [malicious_value], + query_operator: nil + }.with_indifferent_access + ] + + expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue) + end end context 'with additional attributes' do @@ -369,6 +407,72 @@ describe Contacts::FilterService do expect(result[:contacts].length).to be expected_count expect(result[:contacts].pluck(:id)).to include(el_contact.id) end + + it 'binds custom date comparison values as dates' do + date_value = '2024-01-01' + params[:payload] = [ + { + attribute_key: 'signed_in_at', + filter_operator: 'is_less_than', + values: [date_value], + query_operator: nil + }.with_indifferent_access + ] + + service = filter_service.new(account, first_user, params) + filters = service.instance_variable_get(:@filters)['contacts'] + condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0) + + expect(condition_query).to include("(contacts.custom_attributes ->> 'signed_in_at')::date < :value_0") + expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value)) + end + + it 'binds custom numeric comparison values as decimals' do + params[:payload] = [ + { + attribute_key: 'lifetime_value', + filter_operator: 'is_greater_than', + values: ['100.25'], + query_operator: nil + }.with_indifferent_access + ] + + service = filter_service.new(account, first_user, params) + filters = service.instance_variable_get(:@filters)['contacts'] + condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0) + + expect(condition_query).to include("(contacts.custom_attributes ->> 'lifetime_value')::numeric > :value_0") + expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(BigDecimal('100.25')) + end + + it 'filters by custom numeric attributes' do + params[:payload] = [ + { + attribute_key: 'lifetime_value', + filter_operator: 'is_greater_than', + values: ['100.25'], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(account, first_user, params).perform + + expect(result[:contacts].pluck(:id)).to eq([cs_contact.id]) + end + + it 'rejects invalid custom date comparison values' do + malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --" + params[:payload] = [ + { + attribute_key: 'signed_in_at', + filter_operator: 'is_less_than', + values: [malicious_value], + query_operator: nil + }.with_indifferent_access + ] + + expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue) + end end end end diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb index 7bfa5875d..1bf5c219d 100644 --- a/spec/services/conversations/filter_service_spec.rb +++ b/spec/services/conversations/filter_service_spec.rb @@ -417,6 +417,41 @@ describe Conversations::FilterService do expect(result[:conversations].length).to be expected_count end + it 'binds created_at comparison values as dates' do + date_value = '2024-01-01' + params[:payload] = [ + { + attribute_key: 'created_at', + filter_operator: 'is_greater_than', + values: [date_value], + query_operator: nil, + custom_attribute_type: '' + }.with_indifferent_access + ] + + service = filter_service.new(params, user_1, account) + filters = service.instance_variable_get(:@filters)['conversations'] + condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0) + + expect(condition_query).to include('(conversations.created_at)::date > :value_0') + expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value)) + end + + it 'rejects invalid created_at comparison values' do + malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --" + params[:payload] = [ + { + attribute_key: 'created_at', + filter_operator: 'is_greater_than', + values: [malicious_value], + query_operator: nil, + custom_attribute_type: '' + }.with_indifferent_access + ] + + expect { filter_service.new(params, user_1, account).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue) + end + it 'filter by created_at and conversation_type' do params[:payload] = [ { From 52cd70dfa3ecf6a3875083c45005f1fdb7916c35 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 9 Mar 2026 23:44:58 -0700 Subject: [PATCH 05/23] fix(super-admin): prefill confirmed_at in new user form (#13662) On self-hosted instances without email configured, users created from Super Admin can get stuck in an unconfirmed state. This PR implements the default at the Super Admin frontend form layer, not in backend creation logic. What changed: - Added a custom `ConfirmedAtField` for Super Admin user forms. - Prefills `confirmed_at` with current time on the **New User** form (`GET /super_admin/users/new`). - Kept backend create behavior unchanged (`resource_class.new(resource_params)`), so API/manual payloads still behave normally. Behavior: - In Super Admin UI, `confirmed_at` is prefilled by default. - If someone wants an unconfirmed user, they can clear the `confirmed_at` field before saving. - If `confirmed_at` is omitted from payload entirely, the created user remains unconfirmed. Scope note: external signup flows are intentionally unchanged in this PR (`/api/v1/accounts`, `/api/v2/accounts`, and social/omniauth signup behavior are not modified). ## Demo https://github.com/user-attachments/assets/436abbb0-d4cf-49a6-a1b8-4b6aa85aa09f --- app/dashboards/user_dashboard.rb | 2 +- app/fields/confirmed_at_field.rb | 4 ++ .../fields/confirmed_at_field/_form.html.erb | 8 ++++ .../super_admin/users_controller_spec.rb | 47 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 app/fields/confirmed_at_field.rb create mode 100644 app/views/fields/confirmed_at_field/_form.html.erb diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 753b617ef..b499193cc 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard current_sign_in_ip: Field::String, last_sign_in_ip: Field::String, confirmation_token: Field::String, - confirmed_at: Field::DateTime, + confirmed_at: ConfirmedAtField, confirmation_sent_at: Field::DateTime, unconfirmed_email: Field::String, name: Field::String.with_options(searchable: true), diff --git a/app/fields/confirmed_at_field.rb b/app/fields/confirmed_at_field.rb new file mode 100644 index 000000000..67e04c4a0 --- /dev/null +++ b/app/fields/confirmed_at_field.rb @@ -0,0 +1,4 @@ +require 'administrate/field/base' + +class ConfirmedAtField < Administrate::Field::DateTime +end diff --git a/app/views/fields/confirmed_at_field/_form.html.erb b/app/views/fields/confirmed_at_field/_form.html.erb new file mode 100644 index 000000000..9d4c3029c --- /dev/null +++ b/app/views/fields/confirmed_at_field/_form.html.erb @@ -0,0 +1,8 @@ +
+ <%= f.label field.attribute %> +
+
+ <% value = field.data %> + <% value = Time.current if value.blank? && action_name == 'new' %> + <%= f.datetime_local_field field.attribute, step: 1, value: value %> +
diff --git a/spec/controllers/super_admin/users_controller_spec.rb b/spec/controllers/super_admin/users_controller_spec.rb index e1461f554..724e4fa91 100644 --- a/spec/controllers/super_admin/users_controller_spec.rb +++ b/spec/controllers/super_admin/users_controller_spec.rb @@ -23,6 +23,25 @@ RSpec.describe 'Super Admin Users API', type: :request do type: 'SuperAdmin' } } end + let!(:params_without_confirmed_at) do + { user: { + name: 'agent@example.com', + display_name: 'agent@example.com', + email: 'agent@example.com', + password: 'Password1!', + type: 'SuperAdmin' + } } + end + let!(:params_with_blank_confirmed_at) do + { user: { + name: 'agent-2@example.com', + display_name: 'agent-2@example.com', + email: 'agent-2@example.com', + password: 'Password1!', + confirmed_at: '', + type: 'SuperAdmin' + } } + end it 'shows the list of users' do sign_in(super_admin, scope: :super_admin) @@ -36,6 +55,16 @@ RSpec.describe 'Super Admin Users API', type: :request do expect(header_texts).not_to include('MFA') end + it 'prefills confirmed_at on new user form' do + sign_in(super_admin, scope: :super_admin) + get '/super_admin/users/new' + + expect(response).to have_http_status(:success) + expect(response.body).to include('name="user[confirmed_at]"') + confirmed_at_value = response.body[/name="user\[confirmed_at\]".*?value="([^"]+)"/m, 1] + expect(confirmed_at_value).to be_present + end + it 'creates the new super_admin record' do sign_in(super_admin, scope: :super_admin) @@ -47,6 +76,24 @@ RSpec.describe 'Super Admin Users API', type: :request do post '/super_admin/users', params: params expect(response).to redirect_to('http://www.example.com/super_admin/users/new') end + + it 'creates unconfirmed users when confirmed_at is not provided in payload' do + sign_in(super_admin, scope: :super_admin) + + post '/super_admin/users', params: params_without_confirmed_at + + expect(response).to redirect_to("http://www.example.com/super_admin/users/#{User.last.id}") + expect(User.last).not_to be_confirmed + end + + it 'creates unconfirmed users when confirmed_at is explicitly cleared' do + sign_in(super_admin, scope: :super_admin) + + post '/super_admin/users', params: params_with_blank_confirmed_at + + expect(response).to redirect_to("http://www.example.com/super_admin/users/#{User.last.id}") + expect(User.last).not_to be_confirmed + end end end From 28f58b3694dd78f97ba4ddd69f02c59bf2c20040 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 10 Mar 2026 14:11:36 +0530 Subject: [PATCH 06/23] fix: make conversation transcript rate limit configurable (#13740) ## Summary - The conversation transcript endpoint rate limit is hardcoded at 30 requests/hour per account with no way to override it - Self-hosted users with active accounts hit this limit and get 429 errors across all channels - Add `RATE_LIMIT_CONVERSATION_TRANSCRIPT` env var (default: `1000`) to make it configurable, consistent with other throttles like `RATE_LIMIT_CONTACT_SEARCH` and `RATE_LIMIT_REPORTS_API_ACCOUNT_LEVEL` --- config/initializers/rack_attack.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index b193c2e14..db7be0e43 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -185,7 +185,8 @@ class Rack::Attack ###-----------------------------------------------### ## Prevent Abuse of Converstion Transcript APIs ### - throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript', limit: 30, period: 1.hour) do |req| + throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript', + limit: ENV.fetch('RATE_LIMIT_CONVERSATION_TRANSCRIPT', '1000').to_i, period: 1.hour) do |req| match_data = %r{/api/v1/accounts/(?\d+)/conversations/(?\d+)/transcript}.match(req.path) match_data[:account_id] if match_data.present? end From 8ea93ec73dd46942934349556e9d916d98197aec Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 10 Mar 2026 01:45:10 -0700 Subject: [PATCH 07/23] chore(docs): Update documentation for messages API (#13744) Update the documentation for messages API Co-authored-by: Muhsin Keloth --- .../resource/conversation_meta.yml | 5 ++ .../definitions/resource/message_detailed.yml | 34 +++++++- .../conversation/messages/index.yml | 11 +++ swagger/swagger.json | 81 +++++++++++++++++++ swagger/tag_groups/application_swagger.json | 81 +++++++++++++++++++ swagger/tag_groups/client_swagger.json | 63 +++++++++++++++ swagger/tag_groups/other_swagger.json | 63 +++++++++++++++ swagger/tag_groups/platform_swagger.json | 63 +++++++++++++++ 8 files changed, 400 insertions(+), 1 deletion(-) diff --git a/swagger/definitions/resource/conversation_meta.yml b/swagger/definitions/resource/conversation_meta.yml index 7cffc0fab..02ac2390f 100644 --- a/swagger/definitions/resource/conversation_meta.yml +++ b/swagger/definitions/resource/conversation_meta.yml @@ -45,6 +45,11 @@ properties: contact: $ref: '#/components/schemas/contact_detail' description: Contact details + assignee: + allOf: + - $ref: '#/components/schemas/agent' + description: The agent assigned to the conversation + nullable: true agent_last_seen_at: type: string description: Timestamp when the agent last saw the conversation diff --git a/swagger/definitions/resource/message_detailed.yml b/swagger/definitions/resource/message_detailed.yml index 49ff7aa09..8c7e9c3d1 100644 --- a/swagger/definitions/resource/message_detailed.yml +++ b/swagger/definitions/resource/message_detailed.yml @@ -32,6 +32,10 @@ properties: type: string description: ID of the message this is replying to nullable: true + echo_id: + type: string + description: The echo ID of the message, used for deduplication + nullable: true created_at: type: integer description: The timestamp when message was created @@ -44,4 +48,32 @@ properties: nullable: true sender: $ref: '#/components/schemas/contact_detail' - description: The sender of the message (only for incoming messages) \ No newline at end of file + description: The sender of the message (only for incoming messages) + attachments: + type: array + description: The list of attachments associated with the message + items: + type: object + properties: + id: + type: number + description: The ID of the attachment + message_id: + type: number + description: The ID of the message + file_type: + type: string + enum: ["image", "video", "audio", "file", "location", "fallback", "share", "story_mention", "contact", "ig_reel"] + description: The type of the attached file + account_id: + type: number + description: The ID of the account + data_url: + type: string + description: The URL of the attached file + thumb_url: + type: string + description: The thumbnail URL of the attached file + file_size: + type: number + description: The size of the attached file in bytes \ No newline at end of file diff --git a/swagger/paths/application/conversation/messages/index.yml b/swagger/paths/application/conversation/messages/index.yml index 02693a244..68aa120fc 100644 --- a/swagger/paths/application/conversation/messages/index.yml +++ b/swagger/paths/application/conversation/messages/index.yml @@ -5,6 +5,17 @@ summary: Get messages security: - userApiKey: [] description: List all messages of a conversation +parameters: + - name: after + in: query + schema: + type: integer + description: Fetch messages after the message with this ID. Returns up to 100 messages in ascending order. + - name: before + in: query + schema: + type: integer + description: Fetch messages before the message with this ID. Returns up to 20 messages in ascending order. responses: '200': description: Success diff --git a/swagger/swagger.json b/swagger/swagger.json index d721affa4..e11376bcf 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -6119,6 +6119,24 @@ } ], "description": "List all messages of a conversation", + "parameters": [ + { + "name": "after", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Fetch messages after the message with this ID. Returns up to 100 messages in ascending order." + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Fetch messages before the message with this ID. Returns up to 20 messages in ascending order." + } + ], "responses": { "200": { "description": "Success", @@ -12722,6 +12740,11 @@ } } }, + "echo_id": { + "type": "string", + "description": "The echo ID of the message, used for deduplication", + "nullable": true + }, "created_at": { "type": "integer", "description": "The timestamp when message was created" @@ -12737,6 +12760,55 @@ }, "sender": { "$ref": "#/components/schemas/contact_detail" + }, + "attachments": { + "type": "array", + "description": "The list of attachments associated with the message", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the attachment" + }, + "message_id": { + "type": "number", + "description": "The ID of the message" + }, + "file_type": { + "type": "string", + "enum": [ + "image", + "video", + "audio", + "file", + "location", + "fallback", + "share", + "story_mention", + "contact", + "ig_reel" + ], + "description": "The type of the attached file" + }, + "account_id": { + "type": "number", + "description": "The ID of the account" + }, + "data_url": { + "type": "string", + "description": "The URL of the attached file" + }, + "thumb_url": { + "type": "string", + "description": "The thumbnail URL of the attached file" + }, + "file_size": { + "type": "number", + "description": "The size of the attached file in bytes" + } + } + } } } }, @@ -12805,6 +12877,15 @@ "contact": { "$ref": "#/components/schemas/contact_detail" }, + "assignee": { + "allOf": [ + { + "$ref": "#/components/schemas/agent" + } + ], + "description": "The agent assigned to the conversation", + "nullable": true + }, "agent_last_seen_at": { "type": "string", "description": "Timestamp when the agent last saw the conversation", diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index 844be0350..ba482c9d7 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -4662,6 +4662,24 @@ } ], "description": "List all messages of a conversation", + "parameters": [ + { + "name": "after", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Fetch messages after the message with this ID. Returns up to 100 messages in ascending order." + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "integer" + }, + "description": "Fetch messages before the message with this ID. Returns up to 20 messages in ascending order." + } + ], "responses": { "200": { "description": "Success", @@ -11229,6 +11247,11 @@ } } }, + "echo_id": { + "type": "string", + "description": "The echo ID of the message, used for deduplication", + "nullable": true + }, "created_at": { "type": "integer", "description": "The timestamp when message was created" @@ -11244,6 +11267,55 @@ }, "sender": { "$ref": "#/components/schemas/contact_detail" + }, + "attachments": { + "type": "array", + "description": "The list of attachments associated with the message", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the attachment" + }, + "message_id": { + "type": "number", + "description": "The ID of the message" + }, + "file_type": { + "type": "string", + "enum": [ + "image", + "video", + "audio", + "file", + "location", + "fallback", + "share", + "story_mention", + "contact", + "ig_reel" + ], + "description": "The type of the attached file" + }, + "account_id": { + "type": "number", + "description": "The ID of the account" + }, + "data_url": { + "type": "string", + "description": "The URL of the attached file" + }, + "thumb_url": { + "type": "string", + "description": "The thumbnail URL of the attached file" + }, + "file_size": { + "type": "number", + "description": "The size of the attached file in bytes" + } + } + } } } }, @@ -11312,6 +11384,15 @@ "contact": { "$ref": "#/components/schemas/contact_detail" }, + "assignee": { + "allOf": [ + { + "$ref": "#/components/schemas/agent" + } + ], + "description": "The agent assigned to the conversation", + "nullable": true + }, "agent_last_seen_at": { "type": "string", "description": "Timestamp when the agent last saw the conversation", diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index ebeb4a9cb..ac6915726 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -4882,6 +4882,11 @@ } } }, + "echo_id": { + "type": "string", + "description": "The echo ID of the message, used for deduplication", + "nullable": true + }, "created_at": { "type": "integer", "description": "The timestamp when message was created" @@ -4897,6 +4902,55 @@ }, "sender": { "$ref": "#/components/schemas/contact_detail" + }, + "attachments": { + "type": "array", + "description": "The list of attachments associated with the message", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the attachment" + }, + "message_id": { + "type": "number", + "description": "The ID of the message" + }, + "file_type": { + "type": "string", + "enum": [ + "image", + "video", + "audio", + "file", + "location", + "fallback", + "share", + "story_mention", + "contact", + "ig_reel" + ], + "description": "The type of the attached file" + }, + "account_id": { + "type": "number", + "description": "The ID of the account" + }, + "data_url": { + "type": "string", + "description": "The URL of the attached file" + }, + "thumb_url": { + "type": "string", + "description": "The thumbnail URL of the attached file" + }, + "file_size": { + "type": "number", + "description": "The size of the attached file in bytes" + } + } + } } } }, @@ -4965,6 +5019,15 @@ "contact": { "$ref": "#/components/schemas/contact_detail" }, + "assignee": { + "allOf": [ + { + "$ref": "#/components/schemas/agent" + } + ], + "description": "The agent assigned to the conversation", + "nullable": true + }, "agent_last_seen_at": { "type": "string", "description": "Timestamp when the agent last saw the conversation", diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index 9aa9f5a7a..9fb01fa98 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -4297,6 +4297,11 @@ } } }, + "echo_id": { + "type": "string", + "description": "The echo ID of the message, used for deduplication", + "nullable": true + }, "created_at": { "type": "integer", "description": "The timestamp when message was created" @@ -4312,6 +4317,55 @@ }, "sender": { "$ref": "#/components/schemas/contact_detail" + }, + "attachments": { + "type": "array", + "description": "The list of attachments associated with the message", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the attachment" + }, + "message_id": { + "type": "number", + "description": "The ID of the message" + }, + "file_type": { + "type": "string", + "enum": [ + "image", + "video", + "audio", + "file", + "location", + "fallback", + "share", + "story_mention", + "contact", + "ig_reel" + ], + "description": "The type of the attached file" + }, + "account_id": { + "type": "number", + "description": "The ID of the account" + }, + "data_url": { + "type": "string", + "description": "The URL of the attached file" + }, + "thumb_url": { + "type": "string", + "description": "The thumbnail URL of the attached file" + }, + "file_size": { + "type": "number", + "description": "The size of the attached file in bytes" + } + } + } } } }, @@ -4380,6 +4434,15 @@ "contact": { "$ref": "#/components/schemas/contact_detail" }, + "assignee": { + "allOf": [ + { + "$ref": "#/components/schemas/agent" + } + ], + "description": "The agent assigned to the conversation", + "nullable": true + }, "agent_last_seen_at": { "type": "string", "description": "Timestamp when the agent last saw the conversation", diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index a830a8d56..ee6452319 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -5058,6 +5058,11 @@ } } }, + "echo_id": { + "type": "string", + "description": "The echo ID of the message, used for deduplication", + "nullable": true + }, "created_at": { "type": "integer", "description": "The timestamp when message was created" @@ -5073,6 +5078,55 @@ }, "sender": { "$ref": "#/components/schemas/contact_detail" + }, + "attachments": { + "type": "array", + "description": "The list of attachments associated with the message", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the attachment" + }, + "message_id": { + "type": "number", + "description": "The ID of the message" + }, + "file_type": { + "type": "string", + "enum": [ + "image", + "video", + "audio", + "file", + "location", + "fallback", + "share", + "story_mention", + "contact", + "ig_reel" + ], + "description": "The type of the attached file" + }, + "account_id": { + "type": "number", + "description": "The ID of the account" + }, + "data_url": { + "type": "string", + "description": "The URL of the attached file" + }, + "thumb_url": { + "type": "string", + "description": "The thumbnail URL of the attached file" + }, + "file_size": { + "type": "number", + "description": "The size of the attached file in bytes" + } + } + } } } }, @@ -5141,6 +5195,15 @@ "contact": { "$ref": "#/components/schemas/contact_detail" }, + "assignee": { + "allOf": [ + { + "$ref": "#/components/schemas/agent" + } + ], + "description": "The agent assigned to the conversation", + "nullable": true + }, "agent_last_seen_at": { "type": "string", "description": "Timestamp when the agent last saw the conversation", From 824164852cd0f547e210e62482237702b06af4b4 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 10 Mar 2026 14:15:52 +0530 Subject: [PATCH 08/23] refactor: extract custom attribute methods from FilterService (#13743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extracted 6 custom attribute methods (`custom_attribute_query`, `attribute_model`, `attribute_data_type`, `build_custom_attr_query`, `custom_attribute`, `not_in_custom_attr_query`) into a new `Filters::CustomAttributeFilterHelper` module. - Added an inline `rubocop:disable` for the intentional `Lint/ShadowedException` in `coerce_lt_gt_value` — `Date::Error` is a subclass of `ArgumentError`, but both are listed explicitly for clarity. ## Why `app/services/filters/` The existing `Filters::FilterHelper` lives in `app/helpers/filters/`, but that location triggers `Rails/HelperInstanceVariable` for any module that uses instance variables. The extracted methods share state with `FilterService` via instance variables (`@attribute_key`, `@account`, `@custom_attribute`, etc.), so placing them in `app/helpers/` would require a cop disable. `app/services/filters/` is a better fit because: - The module is a service mixin, not a view helper — it's only included by `FilterService` and its subclasses (`Conversations::FilterService`, `Contacts::FilterService`, `AutomationRules::ConditionsFilterService`). - It sits alongside the services that use it. - No cop disables needed. --------- Co-authored-by: Vishnu Narayanan --- app/services/filter_service.rb | 55 +------------------ .../filters/custom_attribute_filter_helper.rb | 55 +++++++++++++++++++ .../fields/confirmed_at_field/_show.html.erb | 3 + 3 files changed, 60 insertions(+), 53 deletions(-) create mode 100644 app/services/filters/custom_attribute_filter_helper.rb create mode 100644 app/views/fields/confirmed_at_field/_show.html.erb diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb index 33e4092c8..25f118d48 100644 --- a/app/services/filter_service.rb +++ b/app/services/filter_service.rb @@ -2,6 +2,7 @@ require 'json' class FilterService include Filters::FilterHelper + include Filters::CustomAttributeFilterHelper include CustomExceptions::CustomFilter ATTRIBUTE_MODEL = 'conversation_attribute'.freeze @@ -137,26 +138,8 @@ class FilterService end end - def custom_attribute_query(query_hash, custom_attribute_type, current_index) - @attribute_key = query_hash[:attribute_key] - @custom_attribute_type = custom_attribute_type - attribute_data_type - return '' if @custom_attribute.blank? - - build_custom_attr_query(query_hash, current_index) - end - private - def attribute_model - @attribute_model = @custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL - end - - def attribute_data_type - attribute_type = custom_attribute(@attribute_key, @account, attribute_model).try(:attribute_display_type) - @attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] - end - def standard_attribute_data_type(attribute_key) @filters.each_value do |section| return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key) @@ -173,44 +156,10 @@ class FilterService else raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key) end - rescue Date::Error, ArgumentError, FloatDomainError, TypeError + rescue ArgumentError, FloatDomainError, TypeError raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key) end - def build_custom_attr_query(query_hash, current_index) - filter_operator_value = filter_operation(query_hash, current_index) - query_operator = query_hash[:query_operator] - table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts' - - query = if attribute_data_type == 'text' - ActiveRecord::Base.sanitize_sql_array( - ["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key] - ) - else - ActiveRecord::Base.sanitize_sql_array( - ["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key] - ) - end - - query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type) - end - - def custom_attribute(attribute_key, account, custom_attribute_type) - current_account = account || Current.account - attribute_model = custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL - @custom_attribute = current_account.custom_attribute_definitions.where( - attribute_model: attribute_model - ).find_by(attribute_key: attribute_key) - end - - def not_in_custom_attr_query(table_name, query_hash, attribute_data_type) - return '' unless query_hash[:filter_operator] == 'not_equal_to' - - ActiveRecord::Base.sanitize_sql_array( - [" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key] - ) - end - def equals_to_filter_string(filter_operator, current_index) return "IN (:value_#{current_index})" if filter_operator == 'equal_to' diff --git a/app/services/filters/custom_attribute_filter_helper.rb b/app/services/filters/custom_attribute_filter_helper.rb new file mode 100644 index 000000000..f0715c611 --- /dev/null +++ b/app/services/filters/custom_attribute_filter_helper.rb @@ -0,0 +1,55 @@ +module Filters::CustomAttributeFilterHelper + def custom_attribute_query(query_hash, custom_attribute_type, current_index) + @attribute_key = query_hash[:attribute_key] + @custom_attribute_type = custom_attribute_type + attribute_data_type + return '' if @custom_attribute.blank? + + build_custom_attr_query(query_hash, current_index) + end + + private + + def attribute_model + @attribute_model = @custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL + end + + def attribute_data_type + attribute_type = custom_attribute(@attribute_key, @account, attribute_model).try(:attribute_display_type) + @attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] + end + + def build_custom_attr_query(query_hash, current_index) + filter_operator_value = filter_operation(query_hash, current_index) + query_operator = query_hash[:query_operator] + table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts' + + query = if attribute_data_type == 'text' + ActiveRecord::Base.sanitize_sql_array( + ["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key] + ) + else + ActiveRecord::Base.sanitize_sql_array( + ["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key] + ) + end + + query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type) + end + + def custom_attribute(attribute_key, account, custom_attribute_type) + current_account = account || Current.account + attribute_model = custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL + @custom_attribute = current_account.custom_attribute_definitions.where( + attribute_model: attribute_model + ).find_by(attribute_key: attribute_key) + end + + def not_in_custom_attr_query(table_name, query_hash, attribute_data_type) + return '' unless query_hash[:filter_operator] == 'not_equal_to' + + ActiveRecord::Base.sanitize_sql_array( + [" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key] + ) + end +end diff --git a/app/views/fields/confirmed_at_field/_show.html.erb b/app/views/fields/confirmed_at_field/_show.html.erb new file mode 100644 index 000000000..7f06246f7 --- /dev/null +++ b/app/views/fields/confirmed_at_field/_show.html.erb @@ -0,0 +1,3 @@ +<% if field.data %> + <%= field.datetime %> +<% end %> From 9f376c43b5b782425a486ef62bf8884ea896a0ef Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 10 Mar 2026 16:35:09 +0530 Subject: [PATCH 09/23] fix(signup): normalize account signup config checks (#13745) This makes account signup enforcement consistent when signup is disabled at the installation level. Email signup and Google signup now stay blocked regardless of whether the config value is stored as a string or a boolean. This effectively covers the config-loader path, where `YAML.safe_load` reads `value: false` from `installation_config.yml` as a native boolean and persists it that way. - Normalized the account signup check so disabled signup is handled consistently across config value types. - Reused the same check across API signup and Google signup entry points. - Added regression coverage for the disabled-signup cases in the existing controller specs. --------- Co-authored-by: Vishnu Narayanan --- app/controllers/api/v1/accounts_controller.rb | 2 +- app/controllers/api/v2/accounts_controller.rb | 2 +- .../omniauth_callbacks_controller.rb | 3 +-- lib/global_config_service.rb | 4 ++++ .../api/v1/accounts_controller_spec.rb | 23 +++++++++++++++++++ .../api/v2/accounts_controller_spec.rb | 23 +++++++++++++++++++ .../omniauth_callbacks_controller_spec.rb | 20 ++++++++++++++++ 7 files changed, 73 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index bcbf80355..3e513a4b2 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController end def check_signup_enabled - raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' + raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? end def validate_captcha diff --git a/app/controllers/api/v2/accounts_controller.rb b/app/controllers/api/v2/accounts_controller.rb index bed0a212a..5a19ddeed 100644 --- a/app/controllers/api/v2/accounts_controller.rb +++ b/app/controllers/api/v2/accounts_controller.rb @@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController end def check_signup_enabled - raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' + raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? end def validate_captcha diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index 900125670..af759af54 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -51,8 +51,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def account_signup_allowed? - # set it to true by default, this is the behaviour across the app - GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false' + GlobalConfigService.account_signup_enabled? end def resource_class(_mapping = nil) diff --git a/lib/global_config_service.rb b/lib/global_config_service.rb index 0649c24af..31612a240 100644 --- a/lib/global_config_service.rb +++ b/lib/global_config_service.rb @@ -14,4 +14,8 @@ class GlobalConfigService GlobalConfig.clear_cache i.value end + + def self.account_signup_enabled? + load('ENABLE_ACCOUNT_SIGNUP', 'false').to_s != 'false' + end end diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index ec49ecd39..d773cafa7 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -81,6 +81,29 @@ RSpec.describe 'Accounts API', type: :request do end end + context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do + before do + GlobalConfig.clear_cache + InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all + InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false) + end + + after do + InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all + GlobalConfig.clear_cache + end + + it 'responds 404 on requests' do + params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' } + + post api_v1_accounts_url, + params: params, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do it 'does not respond 404 on requests' do params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' } diff --git a/spec/controllers/api/v2/accounts_controller_spec.rb b/spec/controllers/api/v2/accounts_controller_spec.rb index 182ebadac..a39e37a91 100644 --- a/spec/controllers/api/v2/accounts_controller_spec.rb +++ b/spec/controllers/api/v2/accounts_controller_spec.rb @@ -94,6 +94,29 @@ RSpec.describe 'Accounts API', type: :request do end end + context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do + before do + GlobalConfig.clear_cache + InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all + InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false) + end + + after do + InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all + GlobalConfig.clear_cache + end + + it 'responds 404 on requests' do + params = { email: email, password: 'Password1!' } + + post api_v2_accounts_url, + params: params, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do let(:account_builder) { double } let(:account) { create(:account) } diff --git a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb index 1a775f88f..603458a01 100644 --- a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb @@ -106,6 +106,26 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do end end + it 'blocks signup if config is stored as boolean false' do + GlobalConfig.clear_cache + InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all + InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false) + + with_modified_env FRONTEND_URL: 'http://www.example.com' do + set_omniauth_config('does-not-exist-for-sure@example.com') + allow(email_validation_service).to receive(:perform).and_return(true) + + get '/omniauth/google_oauth2/callback' + + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + follow_redirect! + expect(response).to redirect_to(%r{/app/login\?error=no-account-found$}) + end + ensure + InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all + GlobalConfig.clear_cache + end + it 'allows login' do with_modified_env FRONTEND_URL: 'http://www.example.com' do create(:user, email: 'test@example.com') From 8d9dd99012fc1220c0ffa0b0a24066b0b7788b60 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 10 Mar 2026 18:32:44 +0530 Subject: [PATCH 10/23] fix: scenario label (#13746) --- .../routes/dashboard/captain/assistants/scenarios/Index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/scenarios/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/scenarios/Index.vue index 27deb4070..c4914f354 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/assistants/scenarios/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/scenarios/Index.vue @@ -191,7 +191,7 @@ onMounted(() => {