diff --git a/.circleci/config.yml b/.circleci/config.yml
index 59702c139..c533e1402 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -77,7 +77,8 @@ jobs:
- node/install:
node-version: '24.13'
- - node/install-pnpm
+ - node/install-pnpm:
+ version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -118,7 +119,8 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- - node/install-pnpm
+ - node/install-pnpm:
+ version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -149,7 +151,8 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- - node/install-pnpm
+ - node/install-pnpm:
+ version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
diff --git a/Gemfile.lock b/Gemfile.lock
index bd41474a3..68674155e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1030,7 +1030,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
- websocket-driver (0.7.7)
+ websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
diff --git a/app/controllers/api/v1/accounts/assignable_agents_controller.rb b/app/controllers/api/v1/accounts/assignable_agents_controller.rb
index a712342dd..29dcb62b2 100644
--- a/app/controllers/api/v1/accounts/assignable_agents_controller.rb
+++ b/app/controllers/api/v1/accounts/assignable_agents_controller.rb
@@ -2,6 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
+ # TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
+ @include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -10,6 +12,7 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
+ @agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end
private
diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb
index 90cdf2418..08c0ffe43 100644
--- a/app/controllers/api/v1/accounts/callbacks_controller.rb
+++ b/app/controllers/api/v1/accounts/callbacks_controller.rb
@@ -6,6 +6,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token]
page_id = params[:page_id]
inbox_name = params[:inbox_name]
+
ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token,
@@ -15,6 +16,8 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id)
end
+ rescue CustomExceptions::Inbox::LimitExceeded => e
+ render_error_response(e)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}"
diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
index 04eeff92b..482b001d6 100644
--- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb
+++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
@@ -66,6 +66,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
+ default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
@@ -74,4 +75,10 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
)
end
end
+
+ def default_model_for(feature_key)
+ return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
+
+ Llm::Models.default_model_for(feature_key)
+ end
end
diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
index f3b14d49f..1691b5489 100644
--- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
+++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb
@@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create
process_create
+ rescue CustomExceptions::Inbox::LimitExceeded => e
+ render_error_response(e)
rescue StandardError => e
render_could_not_create_error(e.message)
end
diff --git a/app/controllers/api/v1/accounts/conversations/participants_controller.rb b/app/controllers/api/v1/accounts/conversations/participants_controller.rb
index ebd02380f..7569142b2 100644
--- a/app/controllers/api/v1/accounts/conversations/participants_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations/participants_controller.rb
@@ -1,27 +1,40 @@
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
+ include Events::Types
+
def show
@participants = @conversation.conversation_participants
end
def create
+ participant_ids_to_add = participants_to_be_added_ids
+
ActiveRecord::Base.transaction do
- @participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
+ @participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
end
+ notify_unread_count_change if participant_ids_to_add.any?
end
def update
+ participant_ids_to_add = participants_to_be_added_ids
+ participant_ids_to_remove = participants_to_be_removed_ids
+ changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
+
ActiveRecord::Base.transaction do
- participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
- participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
+ participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
+ participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
+ notify_unread_count_change if changed_participant_ids.any?
@participants = @conversation.conversation_participants
render action: 'show'
end
def destroy
+ participant_ids_to_remove = current_participant_ids & params[:user_ids]
+
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
end
+ notify_unread_count_change if participant_ids_to_remove.any?
head :ok
end
@@ -38,4 +51,11 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
def current_participant_ids
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
end
+
+ def notify_unread_count_change
+ return unless Current.account.feature_enabled?('conversation_unread_counts')
+ return unless Current.account.feature_enabled?('unread_count_for_filters')
+
+ Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
+ end
end
diff --git a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb
index d9f15613b..0b2335475 100644
--- a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb
@@ -2,12 +2,28 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
before_action :ensure_unread_counts_enabled
def index
- counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
+ counts = if filtered_unread_counts_enabled?
+ instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
+ else
+ unread_counts
+ end
render json: { payload: counts }
end
private
+ def unread_counts
+ ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
+ end
+
+ def filtered_unread_counts_enabled?
+ Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
+ end
+
+ def instrumentation
+ ::Conversations::UnreadCounts::FilteredCountInstrumentation
+ end
+
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb
index 2e53fa7c9..38e8d94aa 100644
--- a/app/controllers/api/v1/accounts/conversations_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations_controller.rb
@@ -164,6 +164,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
+ ::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
end
def should_update_last_seen?
diff --git a/app/controllers/api/v1/accounts/data_imports_controller.rb b/app/controllers/api/v1/accounts/data_imports_controller.rb
new file mode 100644
index 000000000..7f0d28e81
--- /dev/null
+++ b/app/controllers/api/v1/accounts/data_imports_controller.rb
@@ -0,0 +1,159 @@
+require 'csv'
+
+class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
+ DATA_IMPORT_FEATURE = 'data_import'.freeze
+
+ before_action :ensure_data_import_feature_enabled
+ before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs]
+ before_action :check_authorization
+
+ def index
+ @data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
+ data_import_ids = @data_imports.map(&:id)
+ @import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
+ @skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
+ end
+
+ def show
+ render_show
+ end
+
+ def validate_source
+ totals = validate_intercom_source
+ render json: { valid: true, totals: totals }
+ rescue DataImports::Intercom::Client::AuthenticationError
+ render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
+ rescue DataImports::Intercom::Client::Error
+ render_source_validation_error('Intercom could not be reached. Please try again.')
+ rescue ArgumentError => e
+ render_source_validation_error(e.message)
+ end
+
+ def create
+ @data_import = creation_service.perform
+ unless @data_import
+ render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
+ return
+ end
+
+ DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
+ render_show
+ rescue DataImports::Intercom::Client::AuthenticationError
+ render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
+ rescue DataImports::Intercom::Client::Error
+ render_source_validation_error('Intercom could not be reached. Please try again.')
+ rescue ArgumentError => e
+ render_source_validation_error(e.message)
+ end
+
+ def start
+ restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import)
+ restart_result = restart_service.perform
+ @data_import = restart_service.data_import
+ if restart_result == :access_token_missing
+ render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
+ return
+ end
+
+ DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue
+ render_show
+ end
+
+ def abandon
+ @data_import.abandon!
+ render_show
+ end
+
+ def skip_logs
+ send_data(
+ skip_logs_csv,
+ filename: "data-import-#{@data_import.id}-skip-logs.csv",
+ type: 'text/csv'
+ )
+ end
+
+ def error_logs
+ send_data(
+ error_logs_csv,
+ filename: "data-import-#{@data_import.id}-error-logs.csv",
+ type: 'text/csv'
+ )
+ end
+
+ private
+
+ def ensure_data_import_feature_enabled
+ raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
+ end
+
+ def set_data_import
+ @data_import = Current.account.data_imports.find(params[:id])
+ end
+
+ def check_authorization
+ authorize(@data_import || DataImport)
+ end
+
+ def permitted_params
+ params.permit(:name, :source_provider, :access_token, import_types: [])
+ end
+
+ def creation_service
+ DataImports::Intercom::CreationService.new(
+ account: Current.account,
+ initiated_by: Current.user,
+ source_params: permitted_params.to_h
+ )
+ end
+
+ def import_types
+ return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
+
+ Array(permitted_params[:import_types]).compact_blank
+ end
+
+ def validate_intercom_source
+ raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom'
+
+ DataImports::Intercom::CredentialsValidator.new(
+ access_token: permitted_params[:access_token],
+ import_types: import_types
+ ).perform
+ end
+
+ def render_source_validation_error(message)
+ render json: { valid: false, message: message }, status: :unprocessable_entity
+ end
+
+ def render_show
+ @import_errors_finder = DataImportErrorFinder.new(@data_import)
+ @skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
+ render :show
+ end
+
+ def skip_logs_csv
+ logs_csv(@data_import.import_errors.skip_logs)
+ end
+
+ def error_logs_csv
+ logs_csv(@data_import.import_errors.non_skip_logs)
+ end
+
+ def logs_csv(logs)
+ CSV.generate(headers: true) do |csv|
+ csv << %w[created_at kind source_object_type source_object_id error_code message details]
+
+ logs.order(:created_at).find_each do |log|
+ csv << [
+ log.created_at.iso8601,
+ log.details['kind'],
+ log.source_object_type,
+ log.source_object_id,
+ log.error_code,
+ log.message,
+ log.details.to_json
+ ]
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 757af9b62..bb9ca2a70 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -2,7 +2,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
- before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
@@ -125,8 +124,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def reauthorize_and_update_channel(channel_attributes)
- @inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
+ @inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
end
def update_channel_feature_flags
diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb
index c65a3031d..07decfde0 100644
--- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb
@@ -6,7 +6,10 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
- state: state
+ state: state,
+ # Force the Microsoft account picker so an already-signed-in account does not
+ # silently authorize and re-bind to an existing inbox in the new-inbox flow.
+ prompt: 'select_account'
}
)
if redirect_url
diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
index d52f396fc..db94113d9 100644
--- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
+++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb
@@ -8,8 +8,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
- rescue StandardError => e
+ rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
+ rescue StandardError => e
+ render_embedded_signup_error(e)
end
private
@@ -55,7 +57,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
render json: response
end
- def render_error_response(error)
+ def render_embedded_signup_error(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb
index 5b87e2d1a..3912e5b6e 100644
--- a/app/controllers/api/v1/widget/base_controller.rb
+++ b/app/controllers/api/v1/widget/base_controller.rb
@@ -59,6 +59,10 @@ class Api::V1::Widget::BaseController < ApplicationController
permitted_params.dig(:contact, :phone_number)
end
+ def contact_custom_attributes
+ permitted_params.dig(:contact, :custom_attributes)&.to_h
+ end
+
def browser_params
{
browser_name: browser.name,
diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb
index 6c595ab59..9a7d5193a 100644
--- a/app/controllers/api/v1/widget/contacts_controller.rb
+++ b/app/controllers/api/v1/widget/contacts_controller.rb
@@ -2,6 +2,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
+ before_action :validate_hmac_for_identified_update, only: [:update]
def show; end
@@ -46,6 +47,16 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
+ # The plain update endpoint is also used for anonymous prechat updates
+ # (name/email/phone/custom_attributes with no identifier), which must keep
+ # working on hmac_mandatory inboxes. Only the identity-binding path, where an
+ # identifier is supplied and the contact can be rebound, requires HMAC.
+ def validate_hmac_for_identified_update
+ return if params[:identifier].blank?
+
+ validate_hmac
+ end
+
def validate_hmac
return unless should_verify_hmac?
@@ -62,11 +73,15 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def valid_hmac?
- params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
+ expected_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
params[:identifier].to_s
)
+ identifier_hash = params[:identifier_hash].to_s
+ return false unless identifier_hash.bytesize == expected_hash.bytesize
+
+ ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb
index 00e718614..8f5977d54 100644
--- a/app/controllers/api/v1/widget/conversations_controller.rb
+++ b/app/controllers/api/v1/widget/conversations_controller.rb
@@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def process_update_contact
@contact = ContactIdentifyAction.new(
contact: @contact,
- params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
+ params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes },
retain_original_contact_name: true,
discard_invalid_attrs: true
).perform
@@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def permitted_params
- params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
+ params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end
diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb
index ccab0090a..43d6edf1f 100644
--- a/app/controllers/concerns/request_exception_handler.rb
+++ b/app/controllers/concerns/request_exception_handler.rb
@@ -1,8 +1,15 @@
module RequestExceptionHandler
extend ActiveSupport::Concern
+ QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
+ 'ActiveRecord::QueryCanceled',
+ 'PG::QueryCanceled',
+ 'canceling statement due to statement timeout'
+ ].freeze
+
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
+ rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
end
private
@@ -18,6 +25,9 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
+ rescue ActiveRecord::QueryCanceled => e
+ log_handled_error(e)
+ render_could_not_create_error(database_query_canceled_message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
@@ -31,8 +41,8 @@ module RequestExceptionHandler
render json: { error: message }, status: :not_found
end
- def render_could_not_create_error(message)
- render json: { error: message }, status: :unprocessable_entity
+ def render_could_not_create_error(error)
+ render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
end
def render_payment_required(message)
@@ -59,4 +69,19 @@ module RequestExceptionHandler
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
+
+ def sanitized_error_message(message)
+ return database_query_canceled_message if database_query_canceled_message?(message)
+
+ message
+ end
+
+ def database_query_canceled_message?(message)
+ error_message = message.to_s
+ QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
+ end
+
+ def database_query_canceled_message
+ I18n.t('errors.database.query_canceled')
+ end
end
diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb
index cd317363c..e9065119f 100644
--- a/app/controllers/instagram/callbacks_controller.rb
+++ b/app/controllers/instagram/callbacks_controller.rb
@@ -11,6 +11,8 @@ class Instagram::CallbacksController < ApplicationController
end
process_successful_authorization
+ rescue CustomExceptions::Inbox::LimitExceeded => e
+ handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -47,6 +49,14 @@ class Instagram::CallbacksController < ApplicationController
redirect_to_error_page(error_info)
end
+ def handle_limit_error(error)
+ redirect_to_error_page(
+ 'error_type' => error.class.name,
+ 'code' => Rack::Utils.status_code(error.http_status),
+ 'error_message' => error.message
+ )
+ end
+
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
diff --git a/app/controllers/public/api/v1/inboxes/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb
index 835c2596b..764e02a72 100644
--- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb
+++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb
@@ -18,7 +18,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
contact: @contact_inbox.contact,
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
)
- render json: contact_identify_action.perform
+ contact_identify_action.perform
+ @contact_inbox.reload
end
private
@@ -35,11 +36,15 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
- params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
+ expected_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
+ identifier_hash = params[:identifier_hash].to_s
+ return false unless identifier_hash.bytesize == expected_hash.bytesize
+
+ ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb
index 20c0ee9c0..a39fec5ed 100644
--- a/app/controllers/tiktok/callbacks_controller.rb
+++ b/app/controllers/tiktok/callbacks_controller.rb
@@ -6,6 +6,8 @@ class Tiktok::CallbacksController < ApplicationController
return handle_ungranted_scopes_error unless all_scopes_granted?
process_successful_authorization
+ rescue CustomExceptions::Inbox::LimitExceeded => e
+ handle_limit_error(e)
rescue StandardError => e
handle_error(e)
end
@@ -36,6 +38,14 @@ class Tiktok::CallbacksController < ApplicationController
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
end
+ def handle_limit_error(error)
+ redirect_to_error_page(
+ error_type: error.class.name,
+ code: Rack::Utils.status_code(error.http_status),
+ error_message: error.message
+ )
+ end
+
# Handles the case when a user denies permissions or cancels the authorization flow
def handle_authorization_error
redirect_to_error_page(
diff --git a/app/finders/data_import_error_finder.rb b/app/finders/data_import_error_finder.rb
new file mode 100644
index 000000000..69d85b23c
--- /dev/null
+++ b/app/finders/data_import_error_finder.rb
@@ -0,0 +1,11 @@
+class DataImportErrorFinder
+ RESULTS_LIMIT = 5
+
+ def initialize(data_import)
+ @data_import = data_import
+ end
+
+ def import_errors
+ @data_import.import_errors.non_skip_logs.order(created_at: :desc).limit(RESULTS_LIMIT)
+ end
+end
diff --git a/app/finders/data_import_skip_log_finder.rb b/app/finders/data_import_skip_log_finder.rb
new file mode 100644
index 000000000..ee932f1da
--- /dev/null
+++ b/app/finders/data_import_skip_log_finder.rb
@@ -0,0 +1,35 @@
+class DataImportSkipLogFinder
+ RESULTS_LIMIT = 5
+ SOURCE_OBJECT_TYPES = %w[contact conversation message].freeze
+
+ attr_reader :selected_source_object_type
+
+ def initialize(data_import, params = {})
+ @data_import = data_import
+ @selected_source_object_type = valid_source_object_type(params[:skip_logs_type])
+ end
+
+ def skip_logs
+ filtered_scope.order(created_at: :desc).limit(RESULTS_LIMIT)
+ end
+
+ def counts_by_type
+ base_scope.group(:source_object_type).count
+ end
+
+ private
+
+ def base_scope
+ @base_scope ||= @data_import.import_errors.skip_logs
+ end
+
+ def filtered_scope
+ return base_scope if selected_source_object_type.blank?
+
+ base_scope.where(source_object_type: selected_source_object_type)
+ end
+
+ def valid_source_object_type(source_object_type)
+ source_object_type if SOURCE_OBJECT_TYPES.include?(source_object_type)
+ end
+end
diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb
index 8a10fa99c..6c64dd009 100644
--- a/app/helpers/api/v1/inboxes_helper.rb
+++ b/app/helpers/api/v1/inboxes_helper.rb
@@ -114,10 +114,4 @@ module Api::V1::InboxesHelper
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
-
- def validate_limit
- return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
-
- render_payment_required('Account limit exceeded. Upgrade to a higher plan')
- end
end
diff --git a/app/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb
index 4f345676e..d32c9468a 100644
--- a/app/helpers/filters/filter_helper.rb
+++ b/app/helpers/filters/filter_helper.rb
@@ -68,6 +68,8 @@ module Filters::FilterHelper
when 'text_case_insensitive'
text_case_insensitive_filter(query_hash, filter_operator_value)
else
+ return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash)
+
default_filter(query_hash, filter_operator_value)
end
end
@@ -82,10 +84,18 @@ module Filters::FilterHelper
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
+ def text_cast_filter(query_hash, filter_operator_value)
+ "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{filter_operator_value} #{query_hash[:query_operator]}"
+ end
+
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
+ def text_search_on_display_id?(query_hash)
+ query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator])
+ end
+
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
diff --git a/app/javascript/dashboard/api/assignableAgents.js b/app/javascript/dashboard/api/assignableAgents.js
index 5b999facf..febb05ff9 100644
--- a/app/javascript/dashboard/api/assignableAgents.js
+++ b/app/javascript/dashboard/api/assignableAgents.js
@@ -6,9 +6,12 @@ class AssignableAgents extends ApiClient {
super('assignable_agents', { accountScoped: true });
}
- get(inboxIds) {
+ get(inboxIds, { includeAgentBots = false } = {}) {
return axios.get(this.url, {
- params: { inbox_ids: inboxIds },
+ params: {
+ inbox_ids: inboxIds,
+ ...(includeAgentBots ? { include_agent_bots: true } : {}),
+ },
});
}
}
diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js
index 157eba74e..1fc17798d 100644
--- a/app/javascript/dashboard/api/captain/assistant.js
+++ b/app/javascript/dashboard/api/captain/assistant.js
@@ -1,6 +1,10 @@
/* global axios */
import ApiClient from '../ApiClient';
+// Viewer's UTC offset in hours, matching the reports API convention so the
+// backend can anchor calendar ranges to the viewer's day.
+const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
+
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
@@ -21,6 +25,32 @@ class CaptainAssistant extends ApiClient {
message_history: messageHistory,
});
}
+
+ getStats({ assistantId, range }) {
+ return axios.get(`${this.url}/${assistantId}/stats`, {
+ params: { range, timezone_offset: getTimezoneOffset() },
+ });
+ }
+
+ getSummary({ assistantId, range }) {
+ return axios.get(`${this.url}/${assistantId}/summary`, {
+ params: { range, timezone_offset: getTimezoneOffset() },
+ });
+ }
+
+ getDrilldown({ assistantId, metric, range, page, signal }) {
+ const requestConfig = {
+ params: {
+ metric,
+ range,
+ timezone_offset: getTimezoneOffset(),
+ page,
+ },
+ };
+ if (signal) requestConfig.signal = signal;
+
+ return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig);
+ }
}
export default new CaptainAssistant();
diff --git a/app/javascript/dashboard/api/dataImports.js b/app/javascript/dashboard/api/dataImports.js
new file mode 100644
index 000000000..b4c15b98a
--- /dev/null
+++ b/app/javascript/dashboard/api/dataImports.js
@@ -0,0 +1,39 @@
+/* global axios */
+
+import ApiClient from './ApiClient';
+
+class DataImportsAPI extends ApiClient {
+ constructor() {
+ super('data_imports', { accountScoped: true });
+ }
+
+ start(id) {
+ return axios.post(`${this.url}/${id}/start`);
+ }
+
+ abandon(id) {
+ return axios.post(`${this.url}/${id}/abandon`);
+ }
+
+ show(id, params = {}) {
+ return axios.get(`${this.url}/${id}`, { params });
+ }
+
+ validateSource(payload) {
+ return axios.post(`${this.url}/validate_source`, payload);
+ }
+
+ downloadSkipLogs(id) {
+ return axios.get(`${this.url}/${id}/skip_logs.csv`, {
+ responseType: 'blob',
+ });
+ }
+
+ downloadErrorLogs(id) {
+ return axios.get(`${this.url}/${id}/error_logs.csv`, {
+ responseType: 'blob',
+ });
+ }
+}
+
+export default new DataImportsAPI();
diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js
index f94fca452..08820aac9 100644
--- a/app/javascript/dashboard/api/inbox/conversation.js
+++ b/app/javascript/dashboard/api/inbox/conversation.js
@@ -62,9 +62,10 @@ class ConversationApi extends ApiClient {
});
}
- assignAgent({ conversationId, agentId }) {
+ assignAgent({ conversationId, agentId, assigneeType }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
+ assignee_type: assigneeType,
});
}
diff --git a/app/javascript/dashboard/api/specs/assignableAgents.spec.js b/app/javascript/dashboard/api/specs/assignableAgents.spec.js
index d553d55cb..be00cf07f 100644
--- a/app/javascript/dashboard/api/specs/assignableAgents.spec.js
+++ b/app/javascript/dashboard/api/specs/assignableAgents.spec.js
@@ -26,5 +26,15 @@ describe('#AssignableAgentsAPI', () => {
},
});
});
+
+ it('#getAssignableAgents with agent bots', () => {
+ assignableAgentsAPI.get([1], { includeAgentBots: true });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
+ params: {
+ inbox_ids: [1],
+ include_agent_bots: true,
+ },
+ });
+ });
});
});
diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js
index de0d7a7d0..ea0ef3e75 100644
--- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js
+++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js
@@ -90,11 +90,16 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
- conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
+ conversationAPI.assignAgent({
+ conversationId: 12,
+ agentId: 34,
+ assigneeType: 'AgentBot',
+ });
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
+ assignee_type: 'AgentBot',
}
);
});
diff --git a/app/javascript/dashboard/components-next/banner/Banner.vue b/app/javascript/dashboard/components-next/banner/Banner.vue
index c9b86d42b..f44a6ae5e 100644
--- a/app/javascript/dashboard/components-next/banner/Banner.vue
+++ b/app/javascript/dashboard/components-next/banner/Banner.vue
@@ -61,10 +61,10 @@ const triggerAction = () => {
-
+
-
+
+
{
-
+
+
{
{{ displayLink }}
+
+ {{ responsesCountLabel }}
+
() =>
-
+
+
+ >
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/composables/spec/useAgentsList.spec.js b/app/javascript/dashboard/composables/spec/useAgentsList.spec.js
index 7f6d8e757..3a39a6be9 100644
--- a/app/javascript/dashboard/composables/spec/useAgentsList.spec.js
+++ b/app/javascript/dashboard/composables/spec/useAgentsList.spec.js
@@ -26,11 +26,12 @@ const mockNoneAgent = {
};
const mockUseMapGetter = (overrides = {}) => {
+ const getAssignableAgents = vi.fn(() => allAgentsData);
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
- 'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
+ 'inboxAssignableAgents/getAssignableAgents': ref(getAssignableAgents),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -53,6 +54,24 @@ describe('useAgentsList', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
+ expect(
+ useMapGetter('inboxAssignableAgents/getAssignableAgents').value
+ ).toHaveBeenCalledWith(1, { includeAgentBots: false });
+ expect(agentsList.value[0]).toEqual(mockNoneAgent);
+ expect(agentsList.value.length).toBe(
+ formattedAgentsData.slice(1).length + 1
+ );
+ });
+
+ it('requests agent bots when explicitly included', () => {
+ const { agentsList, assignableAgents } = useAgentsList(true, {
+ includeAgentBots: true,
+ });
+
+ expect(assignableAgents.value).toEqual(allAgentsData);
+ expect(
+ useMapGetter('inboxAssignableAgents/getAssignableAgents').value
+ ).toHaveBeenCalledWith(1, { includeAgentBots: true });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
diff --git a/app/javascript/dashboard/composables/useAgentsList.js b/app/javascript/dashboard/composables/useAgentsList.js
index 47e843be6..8e8ee5568 100644
--- a/app/javascript/dashboard/composables/useAgentsList.js
+++ b/app/javascript/dashboard/composables/useAgentsList.js
@@ -10,9 +10,14 @@ import {
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
+ * @param {Object} [options] - Options for the assignable agents list.
+ * @param {boolean} [options.includeAgentBots=false] - Whether to include AgentBot assignees. Only pass this from surfaces that thread `assignee_type` through the assignment request.
* @returns {Object} An object containing the agents list and assignable agents.
*/
-export function useAgentsList(includeNoneAgent = true) {
+export function useAgentsList(
+ includeNoneAgent = true,
+ { includeAgentBots = false } = {}
+) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
@@ -39,7 +44,9 @@ export function useAgentsList(includeNoneAgent = true) {
* @type {import('vue').ComputedRef
}
*/
const assignableAgents = computed(() => {
- return inboxId.value ? assignable.value(inboxId.value) : [];
+ return inboxId.value
+ ? assignable.value(inboxId.value, { includeAgentBots })
+ : [];
});
/**
diff --git a/app/javascript/dashboard/constants/globals.js b/app/javascript/dashboard/constants/globals.js
index 24c0f07b1..e1f6b8cf6 100644
--- a/app/javascript/dashboard/constants/globals.js
+++ b/app/javascript/dashboard/constants/globals.js
@@ -78,3 +78,5 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
+export const META_RESTRICTION_STATUS_URL =
+ 'https://status.chatwoot.com/incident/948346';
diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js
index 00a79763b..058921eea 100644
--- a/app/javascript/dashboard/featureFlags.js
+++ b/app/javascript/dashboard/featureFlags.js
@@ -7,9 +7,11 @@ export const FEATURE_FLAGS = {
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
+ WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
+ DATA_IMPORT: 'data_import',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
@@ -47,6 +49,7 @@ export const FEATURE_FLAGS = {
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
+ UNREAD_COUNT_FOR_FILTERS: 'unread_count_for_filters',
};
export const PREMIUM_FEATURES = [
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js
index e6f2993e4..ef03b1c01 100644
--- a/app/javascript/dashboard/helper/actionCable.js
+++ b/app/javascript/dashboard/helper/actionCable.js
@@ -17,6 +17,13 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
+const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
+const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
+const MENTION_UNREAD_COUNTS_REFETCH_DELAY_MS =
+ UNREAD_COUNTS_REFETCH_THROTTLE_MS;
+const getFilteredUnreadCountsRefreshRetryDelay = () =>
+ FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
+ Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
@@ -25,6 +32,9 @@ class ActionCableConnector extends BaseActionCableConnector {
this.CancelTyping = [];
this.lastUnreadCountsFetchAt = null;
this.unreadCountsFetchTimer = null;
+ this.mentionUnreadCountsFetchTimer = null;
+ this.mentionUnreadCountsRetryTimer = null;
+ this.filteredUnreadCountsRetryTimer = null;
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
@@ -140,7 +150,12 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onConversationUnreadCountChanged = () => {
+ this.refreshConversationUnreadCountsWithFilteredRetry();
+ };
+
+ refreshConversationUnreadCountsWithFilteredRetry = () => {
this.throttledFetchConversationUnreadCounts();
+ this.scheduleFilteredUnreadCountsRetry();
};
throttledFetchConversationUnreadCounts = () => {
@@ -171,6 +186,51 @@ class ActionCableConnector extends BaseActionCableConnector {
this.unreadCountsFetchTimer = null;
};
+ scheduleMentionUnreadCountsFetch = () => {
+ if (!this.isFilteredUnreadCountsEnabled()) return;
+
+ // Mention invalidation runs through the async dispatcher, and stale snapshots
+ // can be served until the filtered-count backend refresh window opens.
+ this.scheduleUnreadCountsFetchAfter(
+ 'mentionUnreadCountsFetchTimer',
+ MENTION_UNREAD_COUNTS_REFETCH_DELAY_MS
+ );
+ this.scheduleUnreadCountsFetchAfter(
+ 'mentionUnreadCountsRetryTimer',
+ getFilteredUnreadCountsRefreshRetryDelay(),
+ { reset: true }
+ );
+ };
+
+ scheduleFilteredUnreadCountsRetry = () => {
+ if (!this.isFilteredUnreadCountsEnabled()) return;
+
+ // Filtered snapshots can intentionally stay stale until the backend
+ // refresh window opens.
+ this.scheduleUnreadCountsFetchAfter(
+ 'filteredUnreadCountsRetryTimer',
+ getFilteredUnreadCountsRefreshRetryDelay(),
+ { reset: true }
+ );
+ };
+
+ scheduleUnreadCountsFetchAfter = (
+ timerName,
+ delay,
+ { reset = false } = {}
+ ) => {
+ if (this[timerName]) {
+ if (!reset) return;
+
+ clearTimeout(this[timerName]);
+ }
+
+ this[timerName] = setTimeout(() => {
+ this[timerName] = null;
+ this.throttledFetchConversationUnreadCounts();
+ }, delay);
+ };
+
fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
@@ -189,6 +249,17 @@ class ActionCableConnector extends BaseActionCableConnector {
);
};
+ isFilteredUnreadCountsEnabled = () => {
+ const accountId = this.app.$store.getters.getCurrentAccountId;
+ const isFeatureEnabled =
+ this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
+
+ return (
+ isFeatureEnabled?.(accountId, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
+ isFeatureEnabled?.(accountId, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS)
+ );
+ };
+
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -212,6 +283,7 @@ class ActionCableConnector extends BaseActionCableConnector {
onConversationMentioned = data => {
this.app.$store.dispatch('addMentions', data);
+ this.scheduleMentionUnreadCountsFetch();
};
clearTimer = conversationId => {
@@ -273,6 +345,12 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
+
+ if (this.isFilteredUnreadCountsEnabled()) {
+ // Inbox/team/label visibility changes can change the accessible set used
+ // by filtered unread counts even when no conversation row changes.
+ this.refreshConversationUnreadCountsWithFilteredRetry();
+ }
};
onVoiceCallIncoming = data => {
diff --git a/app/javascript/dashboard/helper/agentHelper.js b/app/javascript/dashboard/helper/agentHelper.js
index d521e7241..ff1123f66 100644
--- a/app/javascript/dashboard/helper/agentHelper.js
+++ b/app/javascript/dashboard/helper/agentHelper.js
@@ -38,7 +38,7 @@ export const getAgentsByUpdatedPresence = (
currentAccountId
) => {
const agentsWithDynamicPresenceUpdate = agents.map(item =>
- item.id === currentUser.id
+ item.id === currentUser.id && (item.assignee_type || 'User') === 'User'
? {
...item,
availability_status: currentUser.accounts.find(
diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js
index 32f56172a..2d3c75777 100644
--- a/app/javascript/dashboard/helper/editorHelper.js
+++ b/app/javascript/dashboard/helper/editorHelper.js
@@ -9,6 +9,7 @@ import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
+import { InputRule, inputRules } from 'prosemirror-inputrules';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
@@ -428,6 +429,55 @@ export function stripUnsupportedFormatting(content, schema) {
* - emoji
*/
+// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send.
+const LIQUID_SYNTAX = /\{\{|\{%/;
+
+// Value when set (and not itself Liquid), else the {{placeholder}} for the backend.
+export const resolveVariableText = (key, variables) => {
+ const value = String(variables?.[key] ?? '');
+ return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`;
+};
+
+// Name variables normalized like the backend drops (UserDrop/ContactDrop):
+// name split on whitespace, each word Ruby-capitalized (rest downcased).
+const getNameVariables = (prefix, name) => {
+ const names = (name || '')
+ .split(/\s+/)
+ .filter(Boolean)
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
+ return {
+ [`${prefix}.name`]: names.join(' '),
+ [`${prefix}.first_name`]: names[0] || '',
+ [`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '',
+ };
+};
+
+// {{agent.*}} values for the message sender.
+export const getAgentVariables = user => ({
+ ...getNameVariables('agent', user.name),
+ 'agent.email': user.email,
+});
+
+// {{contact.*}} name values.
+export const getContactVariables = contact =>
+ getNameVariables('contact', contact?.name);
+
+// Resolves a manually typed {{variable}} to its value on the closing braces.
+// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note.
+export const createVariableInputRule = ({ isPrivate, getVariables }) => {
+ const rule = new InputRule(
+ /\{\{([^{}]+)\}\}$/,
+ (editorState, match, from, to) => {
+ if (isPrivate()) return null;
+ const [, key] = match;
+ const text = resolveVariableText(key, getVariables());
+ if (text === `{{${key}}}`) return null;
+ return editorState.tr.insertText(text, from, to);
+ }
+ );
+ return inputRules({ rules: [rule] });
+};
+
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
@@ -462,7 +512,7 @@ const createNode = (editorView, nodeType, content) => {
);
}
case 'variable':
- return state.schema.text(`{{${content}}}`);
+ return state.schema.text(content);
case 'emoji':
return state.schema.text(content);
case 'tool': {
@@ -497,8 +547,12 @@ const nodeCreators = {
to,
};
},
- variable: (editorView, content, from, to) => ({
- node: createNode(editorView, 'variable', content),
+ variable: (editorView, content, from, to, variables) => ({
+ node: createNode(
+ editorView,
+ 'variable',
+ resolveVariableText(content, variables)
+ ),
from,
to,
}),
diff --git a/app/javascript/dashboard/helper/featureHelper.js b/app/javascript/dashboard/helper/featureHelper.js
index c90ec15db..a8e5fd2b7 100644
--- a/app/javascript/dashboard/helper/featureHelper.js
+++ b/app/javascript/dashboard/helper/featureHelper.js
@@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
+ captain: 'https://chwt.app/captain-docs',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
diff --git a/app/javascript/dashboard/helper/sidebarSort.js b/app/javascript/dashboard/helper/sidebarSort.js
index 801dd94e2..f1167f70c 100644
--- a/app/javascript/dashboard/helper/sidebarSort.js
+++ b/app/javascript/dashboard/helper/sidebarSort.js
@@ -20,6 +20,8 @@ export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
+ SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
+ SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
diff --git a/app/javascript/dashboard/helper/specs/actionCable.spec.js b/app/javascript/dashboard/helper/specs/actionCable.spec.js
index 8ba411a5f..b288aee83 100644
--- a/app/javascript/dashboard/helper/specs/actionCable.spec.js
+++ b/app/javascript/dashboard/helper/specs/actionCable.spec.js
@@ -1,5 +1,6 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
@@ -17,6 +18,9 @@ global.chatwootConfig = {
websocketURL: 'wss://test.chatwoot.com',
};
+const mockRetryJitter = value =>
+ vi.spyOn(Math, 'random').mockReturnValue(value);
+
describe('ActionCableConnector - Copilot Tests', () => {
let store;
let actionCable;
@@ -39,6 +43,8 @@ describe('ActionCableConnector - Copilot Tests', () => {
});
afterEach(() => {
+ vi.restoreAllMocks();
+ vi.clearAllTimers();
vi.useRealTimers();
});
describe('copilot event handlers', () => {
@@ -81,12 +87,223 @@ describe('ActionCableConnector - Copilot Tests', () => {
});
it('should refetch unread counts when unread count changes', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ mockRetryJitter(0.5);
+
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
+
+ vi.advanceTimersByTime(37499);
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(1);
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ expect(mockDispatch).toHaveBeenLastCalledWith(
+ 'conversationUnreadCounts/get'
+ );
+ });
+
+ it('does not retry unread count changes when filtered counts are disabled', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ store.$store.getters[
+ 'accounts/isFeatureEnabledonAccount'
+ ].mockImplementation(
+ (_, featureFlag) =>
+ featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
+ );
+
+ actionCable.onReceived({
+ event: 'conversation.unread_count_changed',
+ data: { account_id: 1 },
+ });
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(45000);
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ });
+
+ it('delays unread count refetch when a conversation is mentioned', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+
+ const conversation = { id: 1, account_id: 1 };
+
+ actionCable.onReceived({
+ event: 'conversation.mentioned',
+ data: conversation,
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
+ expect(mockDispatch).not.toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get'
+ );
+
+ vi.advanceTimersByTime(4999);
+ expect(mockDispatch).not.toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get'
+ );
+
+ vi.advanceTimersByTime(1);
+ expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
+ });
+
+ it('does not schedule mention unread count fetches when filtered counts are disabled', () => {
+ vi.useFakeTimers();
+ store.$store.getters[
+ 'accounts/isFeatureEnabledonAccount'
+ ].mockImplementation(
+ (_, featureFlag) =>
+ featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
+ );
+
+ const conversation = { id: 1, account_id: 1 };
+
+ actionCable.onReceived({
+ event: 'conversation.mentioned',
+ data: conversation,
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
+
+ vi.advanceTimersByTime(45000);
+ expect(mockDispatch).not.toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get'
+ );
+ });
+
+ it('retries mentioned unread counts after the backend refresh window', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ mockRetryJitter(0.5);
+
+ actionCable.onReceived({
+ event: 'conversation.mentioned',
+ data: { id: 1, account_id: 1 },
+ });
+
+ const unreadCountFetches = () =>
+ mockDispatch.mock.calls.filter(
+ ([action]) => action === 'conversationUnreadCounts/get'
+ );
+
+ vi.advanceTimersByTime(5000);
+ expect(unreadCountFetches()).toHaveLength(1);
+
+ vi.advanceTimersByTime(32499);
+ expect(unreadCountFetches()).toHaveLength(1);
+
+ vi.advanceTimersByTime(1);
+ expect(unreadCountFetches()).toHaveLength(2);
+ });
+
+ it('reschedules mentioned unread count retries for later invalidations', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ mockRetryJitter(0);
+
+ const unreadCountFetches = () =>
+ mockDispatch.mock.calls.filter(
+ ([action]) => action === 'conversationUnreadCounts/get'
+ );
+
+ actionCable.onReceived({
+ event: 'conversation.mentioned',
+ data: { id: 1, account_id: 1 },
+ });
+
+ vi.advanceTimersByTime(5000);
+ expect(unreadCountFetches()).toHaveLength(1);
+
+ vi.advanceTimersByTime(10000);
+ actionCable.onReceived({
+ event: 'conversation.mentioned',
+ data: { id: 1, account_id: 1 },
+ });
+
+ vi.advanceTimersByTime(5000);
+ expect(unreadCountFetches()).toHaveLength(2);
+
+ vi.advanceTimersByTime(10000);
+ expect(unreadCountFetches()).toHaveLength(2);
+
+ vi.advanceTimersByTime(15000);
+ expect(unreadCountFetches()).toHaveLength(3);
+ });
+
+ it('refetches filtered unread counts after account cache invalidation', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ mockRetryJitter(0.5);
+
+ const cacheKeys = {
+ label: 'label-key',
+ inbox: 'inbox-key',
+ team: 'team-key',
+ };
+ const unreadCountFetches = () =>
+ mockDispatch.mock.calls.filter(
+ ([action]) => action === 'conversationUnreadCounts/get'
+ );
+
+ actionCable.onReceived({
+ event: 'account.cache_invalidated',
+ data: { account_id: 1, cache_keys: cacheKeys },
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith('labels/revalidate', {
+ newKey: cacheKeys.label,
+ });
+ expect(mockDispatch).toHaveBeenCalledWith('inboxes/revalidate', {
+ newKey: cacheKeys.inbox,
+ });
+ expect(mockDispatch).toHaveBeenCalledWith('teams/revalidate', {
+ newKey: cacheKeys.team,
+ });
+ expect(unreadCountFetches()).toHaveLength(1);
+
+ vi.advanceTimersByTime(37499);
+ expect(unreadCountFetches()).toHaveLength(1);
+
+ vi.advanceTimersByTime(1);
+ expect(unreadCountFetches()).toHaveLength(2);
+ });
+
+ it('does not refetch unread counts after cache invalidation when filtered counts are disabled', () => {
+ vi.useFakeTimers();
+ store.$store.getters[
+ 'accounts/isFeatureEnabledonAccount'
+ ].mockImplementation(
+ (_, featureFlag) =>
+ featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
+ );
+
+ actionCable.onReceived({
+ event: 'account.cache_invalidated',
+ data: {
+ account_id: 1,
+ cache_keys: {
+ label: 'label-key',
+ inbox: 'inbox-key',
+ team: 'team-key',
+ },
+ },
+ });
+
+ expect(mockDispatch).not.toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get'
+ );
+
+ vi.advanceTimersByTime(45000);
+ expect(mockDispatch).not.toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get'
+ );
});
it('does not refetch unread counts when unread count feature is disabled', () => {
diff --git a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
index 4efb4d1d9..57d8bd533 100644
--- a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
@@ -94,16 +94,56 @@ describe('getContentNode', () => {
});
describe('getVariableNode', () => {
- it('should create a variable node', () => {
- const content = 'name';
- const from = 0;
- const to = 10;
- getContentNode(editorView, 'variable', content, {
- from,
- to,
- });
+ it('should render the resolved value directly when the variable has a value', () => {
+ getContentNode(
+ editorView,
+ 'variable',
+ 'contact.name',
+ { from: 0, to: 10 },
+ { 'contact.name': 'John' }
+ );
- expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
+ expect(editorView.state.schema.text).toHaveBeenCalledWith('John');
+ });
+
+ it('should resolve camelCase custom attributes and non-string values', () => {
+ getContentNode(
+ editorView,
+ 'variable',
+ 'contact.custom_attribute.cloudCustomer',
+ { from: 0, to: 10 },
+ { 'contact.custom_attribute.cloudCustomer': true }
+ );
+
+ expect(editorView.state.schema.text).toHaveBeenCalledWith('true');
+ });
+
+ it('should keep the placeholder when the variable has no value', () => {
+ getContentNode(
+ editorView,
+ 'variable',
+ 'contact.email',
+ { from: 0, to: 10 },
+ {}
+ );
+
+ expect(editorView.state.schema.text).toHaveBeenCalledWith(
+ '{{contact.email}}'
+ );
+ });
+
+ it('should keep the placeholder when the value contains Liquid syntax', () => {
+ getContentNode(
+ editorView,
+ 'variable',
+ 'contact.name',
+ { from: 0, to: 10 },
+ { 'contact.name': '{{agent.email}}' }
+ );
+
+ expect(editorView.state.schema.text).toHaveBeenCalledWith(
+ '{{contact.name}}'
+ );
});
});
diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
index 220b9903e..fafe1bc56 100644
--- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
@@ -11,9 +11,12 @@ import {
calculateMenuPosition,
cleanSignature,
collapseSelection,
+ createVariableInputRule,
extractTextFromMarkdown,
findNodeToInsertImage,
findSignatureInBody,
+ getAgentVariables,
+ getContactVariables,
getContentNode,
getFormattingForEditor,
getMenuAnchor,
@@ -1228,3 +1231,149 @@ describe('Menu positioning helpers', () => {
});
});
});
+
+describe('getAgentVariables', () => {
+ it('builds agent variables from the user', () => {
+ expect(
+ getAgentVariables({ name: 'John Doe', email: 'john@example.com' })
+ ).toEqual({
+ 'agent.name': 'John Doe',
+ 'agent.first_name': 'John',
+ 'agent.last_name': 'Doe',
+ 'agent.email': 'john@example.com',
+ });
+ });
+
+ it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => {
+ const variables = getAgentVariables({ name: 'JANE doE' });
+
+ expect(variables['agent.name']).toBe('Jane Doe');
+ expect(variables['agent.first_name']).toBe('Jane');
+ expect(variables['agent.last_name']).toBe('Doe');
+ });
+
+ it('ignores extra whitespace between words', () => {
+ expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe(
+ 'John Doe'
+ );
+ });
+
+ it('leaves last_name empty for single-word names', () => {
+ const variables = getAgentVariables({ name: 'john' });
+
+ expect(variables['agent.first_name']).toBe('John');
+ expect(variables['agent.last_name']).toBe('');
+ });
+
+ it('handles a missing name', () => {
+ const variables = getAgentVariables({ email: 'john@example.com' });
+
+ expect(variables['agent.name']).toBe('');
+ expect(variables['agent.first_name']).toBe('');
+ expect(variables['agent.last_name']).toBe('');
+ });
+});
+
+describe('getContactVariables', () => {
+ it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => {
+ expect(getContactVariables({ name: 'JANE doE' })).toEqual({
+ 'contact.name': 'Jane Doe',
+ 'contact.first_name': 'Jane',
+ 'contact.last_name': 'Doe',
+ });
+ });
+
+ it('leaves last_name empty for single-word names', () => {
+ const variables = getContactVariables({ name: 'john' });
+
+ expect(variables['contact.first_name']).toBe('John');
+ expect(variables['contact.last_name']).toBe('');
+ });
+
+ it('handles a missing contact', () => {
+ expect(getContactVariables(undefined)['contact.name']).toBe('');
+ });
+});
+
+describe('createVariableInputRule', () => {
+ // Editor holding `{{key}` so we can simulate typing the final `}`.
+ const buildView = (typed, { isPrivate = false, variables = {} } = {}) => {
+ const plugin = createVariableInputRule({
+ isPrivate: () => isPrivate,
+ getVariables: () => variables,
+ });
+ const state = EditorState.create({
+ schema,
+ doc: schema.node('doc', null, [
+ schema.node('paragraph', null, [schema.text(typed)]),
+ ]),
+ plugins: [plugin],
+ });
+ return new EditorView(document.body, { state });
+ };
+
+ // Types the closing `}`; when the rule declines, insert it like the browser would.
+ const typeClosingBrace = view => {
+ const end = view.state.doc.content.size - 1;
+ const handled = view.someProp('handleTextInput', fn =>
+ fn(view, end, end, '}')
+ );
+ if (!handled) {
+ view.dispatch(view.state.tr.insertText('}', end, end));
+ }
+ };
+
+ it('resolves a manually typed {{variable}} to its value on the closing brace', () => {
+ const view = buildView('{{contact.name}', {
+ variables: { 'contact.name': 'John' },
+ });
+
+ typeClosingBrace(view);
+
+ expect(view.state.doc.textContent).toBe('John');
+ view.destroy();
+ });
+
+ it('resolves boolean/non-string values', () => {
+ const view = buildView('{{contact.custom_attribute.cloudCustomer}', {
+ variables: { 'contact.custom_attribute.cloudCustomer': true },
+ });
+
+ typeClosingBrace(view);
+
+ expect(view.state.doc.textContent).toBe('true');
+ view.destroy();
+ });
+
+ it('keeps the placeholder when the variable has no value', () => {
+ const view = buildView('{{contact.email}', { variables: {} });
+
+ typeClosingBrace(view);
+
+ expect(view.state.doc.textContent).toBe('{{contact.email}}');
+ view.destroy();
+ });
+
+ it('keeps the placeholder when the value itself contains Liquid syntax', () => {
+ const view = buildView('{{contact.name}', {
+ variables: { 'contact.name': '{{agent.email}}' },
+ });
+
+ typeClosingBrace(view);
+
+ expect(view.state.doc.textContent).toBe('{{contact.name}}');
+ view.destroy();
+ });
+
+ it('does not resolve inside a private note', () => {
+ const view = buildView('{{contact.name}', {
+ isPrivate: true,
+ variables: { 'contact.name': 'John' },
+ });
+
+ typeClosingBrace(view);
+
+ expect(view.state.doc.textContent).toBe('{{contact.name}}');
+ view.destroy();
+ });
+});
diff --git a/app/javascript/dashboard/helper/specs/sidebarSort.spec.js b/app/javascript/dashboard/helper/specs/sidebarSort.spec.js
index 3919252c2..7b36e0910 100644
--- a/app/javascript/dashboard/helper/specs/sidebarSort.spec.js
+++ b/app/javascript/dashboard/helper/specs/sidebarSort.spec.js
@@ -148,7 +148,7 @@ describe('#normalizeSidebarSortPreferences', () => {
it('falls back to defaults for unsupported preferences', () => {
const preferences = normalizeSidebarSortPreferences({
- [SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
+ [SIDEBAR_SORT_SECTIONS.FOLDERS]: 'unsupported_sort',
});
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
@@ -171,6 +171,15 @@ describe('#getSidebarSortOptions', () => {
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
+ it('keeps folder unread count options when filtered unread counts are enabled', () => {
+ const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.FOLDERS, {
+ hasUnreadCounts: true,
+ });
+
+ expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
+ expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
+ });
+
it('removes unread count options when unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: false,
@@ -180,6 +189,16 @@ describe('#getSidebarSortOptions', () => {
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
+
+ it('removes folder unread count options when filtered unread counts are disabled', () => {
+ const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.FOLDERS, {
+ hasUnreadCounts: false,
+ });
+
+ expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
+ expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
+ expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
+ });
});
describe('#resolveSidebarSort', () => {
@@ -202,4 +221,14 @@ describe('#resolveSidebarSort', () => {
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
+
+ it('falls back to alphabetical sort for folders when filtered unread counts are disabled', () => {
+ const sortBy = resolveSidebarSort(
+ SIDEBAR_SORT_SECTIONS.FOLDERS,
+ SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
+ { hasUnreadCounts: false }
+ );
+
+ expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
+ });
});
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index c34ed44de..fe71b8974 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -44,6 +44,8 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
+ "INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
+ "INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
index 8eefe5fde..50d8ba3f9 100644
--- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
@@ -58,7 +58,10 @@
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
- "DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
+ "DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
+ "RESTRICTED_WARNING": "Instagram inbox creation is temporarily unavailable due to current Instagram platform restrictions. We’ll restore support as soon as possible.",
+ "SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
+ "STATUS_LINK": "View status update"
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
@@ -320,6 +323,8 @@
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow",
+ "RESTRICTED_WARNING": "WhatsApp embedded signup is temporarily unavailable due to current Meta platform restrictions. We’ll restore support as soon as possible.",
+ "STATUS_LINK": "View status update",
"CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings."
},
"API": {
@@ -815,6 +820,15 @@
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
+ "WHATSAPP_MANUAL_TRANSFER_TITLE": "Switch to Manual Setup",
+ "WHATSAPP_MANUAL_TRANSFER_SUBHEADER": "This inbox was connected through WhatsApp embedded signup, which is no longer supported. Enter the WhatsApp Cloud API credentials from your own Meta app to switch this inbox to a manual setup. Configure the webhook in your Meta app using the verification token above before switching.",
+ "WHATSAPP_MANUAL_TRANSFER_PHONE_NUMBER_ID_LABEL": "Phone Number ID",
+ "WHATSAPP_MANUAL_TRANSFER_BUSINESS_ACCOUNT_ID_LABEL": "Business Account ID",
+ "WHATSAPP_MANUAL_TRANSFER_API_KEY_LABEL": "API Key",
+ "WHATSAPP_MANUAL_TRANSFER_API_KEY_PLACEHOLDER": "Enter a permanent access token from your Meta app",
+ "WHATSAPP_MANUAL_TRANSFER_BUTTON": "Switch to Manual Setup",
+ "WHATSAPP_MANUAL_TRANSFER_SUCCESS": "Inbox switched to manual setup successfully.",
+ "WHATSAPP_MANUAL_TRANSFER_ERROR": "Could not switch to manual setup. Please verify the credentials and try again.",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
@@ -832,6 +846,70 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
+ "WHATSAPP_MANUAL_MIGRATION": {
+ "BANNER": {
+ "TITLE": "WhatsApp setup action required",
+ "DESCRIPTION": "Meta restrictions are affecting WhatsApp setup and management features. Reconnect this inbox manually to keep your WhatsApp configuration up to date.",
+ "START": "Start manual migration",
+ "GUIDE": "View guide"
+ },
+ "DIALOG": {
+ "EYEBROW": "WhatsApp manual migration",
+ "TITLE": "Reconnect WhatsApp inbox",
+ "CLOSE": "Close",
+ "ACTION_REQUIRED_TITLE": "Action required for this WhatsApp inbox",
+ "ACTION_REQUIRED_DESCRIPTION": "Meta restrictions are affecting setup and management features. This guided flow updates the WhatsApp API connection without creating a new inbox.",
+ "GUIDE_LINK": "Open the manual setup guide",
+ "PRESERVED_TITLE": "Preserved",
+ "PRESERVED_DESCRIPTION": "Conversations, contacts, collaborators, routing, business hours, and inbox settings.",
+ "UPDATED_TITLE": "Updated",
+ "UPDATED_DESCRIPTION": "WABA ID, phone number ID, access token, and webhook configuration.",
+ "WABA_ID": "WABA ID",
+ "WABA_PLACEHOLDER": "Enter WABA ID",
+ "WABA_HELP": "The WhatsApp Business Account that owns this phone number.",
+ "PHONE_NUMBER_ID": "Phone Number ID",
+ "PHONE_NUMBER_PLACEHOLDER": "Enter Phone Number ID",
+ "PHONE_NUMBER_HELP": "Meta's unique ID for the WhatsApp number connected to this inbox.",
+ "DISPLAY_PHONE_NUMBER": "Display phone number",
+ "DISPLAY_PHONE_NUMBER_PLACEHOLDER": "Enter display phone number",
+ "DISPLAY_PHONE_NUMBER_HELP": "The customer-facing WhatsApp number. This cannot be changed during migration.",
+ "ACCESS_TOKEN": "Permanent access token or system user token",
+ "ACCESS_TOKEN_PLACEHOLDER": "Paste access token",
+ "TOKEN_HELP_PREFIX": "The token must include",
+ "TOKEN_HELP_MIDDLE": "Add",
+ "TOKEN_HELP_SUFFIX": "for template sync and template management.",
+ "MESSAGING_PERMISSION": "whatsapp_business_messaging.",
+ "MANAGEMENT_PERMISSION": "whatsapp_business_management",
+ "REVIEW_TITLE": "Review before reconnecting",
+ "INBOX": "Inbox",
+ "PHONE_NUMBER": "Phone number",
+ "NOT_ENTERED": "Not entered",
+ "VERIFY_NOTICE": "Chatwoot will verify these credentials with Meta before applying any changes. If verification fails, the current configuration is left untouched.",
+ "BACK": "Back",
+ "CANCEL": "Cancel",
+ "CONTINUE": "Continue",
+ "REVIEW_MIGRATION": "Review migration",
+ "RECONNECT": "Reconnect WhatsApp inbox"
+ },
+ "STEPS": {
+ "BEFORE_YOU_START": {
+ "TITLE": "Before you start",
+ "DESCRIPTION": "This reconnects the WhatsApp API details for this inbox. Conversations, collaborators, routing, business hours, CSAT, and bot settings will be preserved."
+ },
+ "BUSINESS_DETAILS": {
+ "TITLE": "Business details",
+ "DESCRIPTION": "Enter the WhatsApp assets from the customer Meta Business account."
+ },
+ "ACCESS_TOKEN": {
+ "TITLE": "Access token",
+ "DESCRIPTION": "Paste a permanent access token or system user token with WhatsApp permissions."
+ },
+ "REVIEW_MIGRATION": {
+ "TITLE": "Review migration",
+ "DESCRIPTION": "Confirm the connection details before reconnecting this inbox. Chatwoot will verify the token, WABA, and phone number with Meta before applying changes."
+ }
+ }
+ },
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json
index dd1110607..8d060e748 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -392,6 +392,82 @@
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
+ "OVERVIEW": {
+ "HEADER": "Overview",
+ "WELCOME": {
+ "LABEL": "Captain summary",
+ "LOADING": "Generating summary…"
+ },
+ "INBOX_BANNER": {
+ "TEXT": "This assistant isn't connected to any inbox yet, so it won't respond to conversations.",
+ "ACTION": "Connect inbox",
+ "DISMISS": "Dismiss"
+ },
+ "COVERAGE_BANNER": {
+ "TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
+ "ACTION": "Review FAQs",
+ "DISMISS": "Dismiss"
+ },
+ "RANGES": {
+ "LAST_DAYS": "Last {count} days",
+ "THIS_MONTH": "This month",
+ "LAST_MONTH": "Last month"
+ },
+ "METRICS": {
+ "HANDLED": {
+ "LABEL": "Conversations handled",
+ "HINT": "Distinct conversations this assistant replied in."
+ },
+ "AUTO_RESOLUTION": {
+ "LABEL": "Auto-resolution rate",
+ "HINT": "Share of handled conversations closed without a human reply."
+ },
+ "HANDOFF": {
+ "LABEL": "Handoff rate",
+ "HINT": "Share of handled conversations escalated to a human agent."
+ },
+ "HOURS_SAVED": {
+ "LABEL": "Time saved",
+ "HINT": "Estimate: Captain replies times ~2 minutes of assumed agent effort per reply. Directional, not measured labor."
+ },
+ "REOPEN": {
+ "LABEL": "Reopen rate",
+ "HINT": "Auto-resolved conversations that were reopened afterwards."
+ },
+ "DEPTH": {
+ "LABEL": "Messages / conversation",
+ "HINT": "Average replies the assistant sends per conversation."
+ }
+ },
+ "DRILLDOWN": {
+ "CLOSE": "Close details",
+ "EMPTY": "No records found for this metric.",
+ "ERROR": "Could not load records. Please try again.",
+ "LOAD_MORE": "Load more",
+ "RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations"
+ },
+ "KNOWLEDGE": {
+ "TITLE": "Knowledge coverage",
+ "COVERAGE": "{pct}% approved",
+ "APPROVED": "Approved FAQs",
+ "PENDING": "Pending FAQs",
+ "DOCUMENTS": "Documents"
+ },
+ "LINKS": {
+ "DOCS": {
+ "TITLE": "Captain docs",
+ "DESCRIPTION": "Guides and how-tos for Captain"
+ },
+ "PLAYGROUND": {
+ "TITLE": "Playground",
+ "DESCRIPTION": "Test this assistant's replies"
+ },
+ "BILLING": {
+ "TITLE": "Billing",
+ "DESCRIPTION": "Manage credits and plan"
+ }
+ }
+ },
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
@@ -784,6 +860,7 @@
"DOCUMENTS": {
"HEADER": "Documents",
"ADD_NEW": "Create a new document",
+ "FAQ_COUNT": "{n} FAQ | {n} FAQs",
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
@@ -843,7 +920,27 @@
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
- "DESCRIPTION": "These FAQs are generated directly from the document."
+ "EMPTY": "No FAQs have been generated from this document yet."
+ },
+ "DETAILS": {
+ "DESCRIPTION": "Review the crawled content and the FAQs generated from this source.",
+ "SOURCE": "Source",
+ "GENERATED_FAQS": "Generated FAQs",
+ "LAST_UPDATED": "Last updated",
+ "NOT_AVAILABLE": "Not available",
+ "CONTENT_TAB": "Crawled content",
+ "PDF_TAB": "PDF details",
+ "CONTENT_TITLE": "Crawled content",
+ "PDF_TITLE": "PDF file",
+ "PDF_DESCRIPTION": "Review the original PDF source.",
+ "CHARACTER_COUNT": "{count} characters extracted",
+ "COPY_CONTENT": "Copy",
+ "COPY_SUCCESS": "Crawled content copied to clipboard",
+ "COPY_ERROR": "Could not copy crawled content",
+ "VIEW_RAW": "View raw",
+ "VIEW_PREVIEW": "View preview",
+ "UNREADABLE_CONTENT": "Readable content could not be extracted from this document. You can view the raw extracted content.",
+ "EMPTY_CONTENT": "No crawled content is available for this document yet."
},
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
"CREATE": {
@@ -884,7 +981,7 @@
},
"OPTIONS": {
- "VIEW_RELATED_RESPONSES": "View Related Responses",
+ "VIEW_DETAILS": "View details",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document"
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index 4caca05fb..eaecd7b80 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -263,7 +263,7 @@
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
"ACCOUNT_SUSPENDED": {
"TITLE": "Account Suspended",
- "MESSAGE": "Your account is suspended. Please reach out to the support team for more information."
+ "MESSAGE": "Your account has been suspended due to activity that may violate our policies. If you believe this is a mistake, please contact our support team."
},
"NO_ACCOUNTS": {
"TITLE": "No account found",
@@ -324,6 +324,7 @@
"ALL_COMPANIES": "All Companies",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
+ "CAPTAIN_OVERVIEW": "Overview",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
@@ -339,6 +340,7 @@
"NOTIFICATIONS": "Notifications",
"CANNED_RESPONSES": "Canned Responses",
"INTEGRATIONS": "Integrations",
+ "DATA": "Data",
"PROFILE_SETTINGS": "Profile Settings",
"ACCOUNT_SETTINGS": "Account Settings",
"APPLICATIONS": "Applications",
@@ -410,6 +412,104 @@
"CAPTAIN_AI": "Captain",
"CONVERSATION_WORKFLOW": "Conversation Workflow"
},
+ "DATA_IMPORTS": {
+ "HEADER": "Data",
+ "DESCRIPTION": "Bring your existing contacts and past conversations into this account from another support tool. Each import runs in the background, so you can keep working while it finishes, track its progress, and review anything that was skipped along the way.",
+ "LOADING": "Fetching imports",
+ "DEFAULT_IMPORT_NAME": "Intercom import",
+ "TABS": {
+ "IMPORT": "Import",
+ "EXPORT": "Export"
+ },
+ "TYPES": {
+ "CONTACTS": "Contacts",
+ "CONVERSATIONS": "Conversations",
+ "MESSAGES": "Messages"
+ },
+ "DRAWER": {
+ "TITLE": "New import",
+ "SOURCE": "Source",
+ "NAME": "Import name",
+ "NAME_PLACEHOLDER": "July Intercom migration",
+ "ACCESS_KEY": "Intercom access key",
+ "ACCESS_KEY_PLACEHOLDER": "Paste your Intercom access key",
+ "DATA_TYPES": "Data to import",
+ "VALIDATING": "Validating access key...",
+ "VALID_KEY": "Access key validated.",
+ "INVALID_KEY": "Could not validate this access key.",
+ "ACTIVE_IMPORT": "Wait for the active import to finish before starting another one.",
+ "CANCEL": "Cancel",
+ "IMPORT": "Import"
+ },
+ "EXPORT": {
+ "TITLE": "Exports are on the way",
+ "DESCRIPTION": "Export your contacts and conversations out of this account. This workflow is coming soon.",
+ "COMING_SOON": "Coming soon"
+ },
+ "TABLE": {
+ "TITLE": "Recent imports",
+ "EMPTY": "No imports yet",
+ "EMPTY_DESCRIPTION": "Start an import to bring your existing customer history into this account.",
+ "NEW_IMPORT": "Import",
+ "COUNT": "{count} imports",
+ "UNNAMED": "Untitled import",
+ "IMPORTED_COUNT": "{count} imported",
+ "VIEW": "View import",
+ "NAME": "Name",
+ "TYPE": "Type",
+ "STATUS": "Status",
+ "IMPORTED": "Imported",
+ "CREATED": "Created",
+ "ABANDON": "Abandon"
+ },
+ "DETAIL": {
+ "BACK": "Back to imports",
+ "ERRORS": "Errors",
+ "SKIP_LOGS": "Skip logs",
+ "SOURCE": "Source",
+ "IMPORT_TYPES": "Import types",
+ "CREATED": "Created",
+ "DURATION": "Duration",
+ "INITIATED_BY": "Started by",
+ "PROGRESS": "Import progress",
+ "PROGRESS_WITH_TOTAL": "{imported} of {total} imported",
+ "PROGRESS_WITHOUT_TOTAL": "{imported} imported",
+ "PROGRESS_OF_TOTAL": "of {total} imported",
+ "PROGRESS_IMPORTED": "imported",
+ "LAST_UPDATED_TOOLTIP": "Last updated {time}",
+ "NO_SKIP_LOGS": "No skipped or failed records recorded.",
+ "DOWNLOAD_SKIP_LOGS": "Download CSV",
+ "DOWNLOAD_ERROR_LOGS": "Download CSV",
+ "ALL_SKIP_LOGS": "All",
+ "KIND": "Kind",
+ "NO_ERRORS": "No errors recorded.",
+ "ERROR_CODE": "Code",
+ "SOURCE_OBJECT": "Source object",
+ "MESSAGE": "Message"
+ },
+ "MONITOR": {
+ "LIVE": "Live updates every {seconds}s",
+ "LAST_UPDATED": "Last updated {time}",
+ "REFRESH": "Refresh",
+ "REFRESHING": "Refreshing",
+ "STAGES": {
+ "unknown": "Waiting for update",
+ "queued": "Queued",
+ "contacts": "Importing contacts",
+ "conversations": "Importing conversations",
+ "finalizing": "Finalizing import",
+ "completed": "Completed",
+ "completed_with_errors": "Completed with errors",
+ "failed": "Failed",
+ "abandoned": "Abandoned"
+ }
+ },
+ "ALERTS": {
+ "IMPORT_STARTED": "Intercom import has started.",
+ "IMPORT_ABANDONED": "Intercom import has been abandoned.",
+ "IMPORT_FAILED": "Could not start the Intercom import."
+ }
+ },
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
@@ -541,7 +641,7 @@
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
- "PLACEHOLDER": "https://your-idp.com/saml/sso"
+ "PLACEHOLDER": "https://sso.example.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
@@ -561,7 +661,7 @@
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
- "PLACEHOLDER": "https://your-idp.com/saml"
+ "PLACEHOLDER": "https://sso.example.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json
index 9d047dd92..69700c43b 100644
--- a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json
+++ b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json
@@ -501,7 +501,7 @@
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "A URL para onde as solicitações de autenticação SAML serão enviadas",
- "PLACEHOLDER": "https://your-idp.com/saml/sso"
+ "PLACEHOLDER": "https://sso.example.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Certificado de assinatura no formato PEM",
@@ -521,7 +521,7 @@
"IDP_ENTITY_ID": {
"LABEL": "ID da Entidade do Provedor de Identidade",
"HELP": "Identificador exclusivo do seu provedor de identidade (geralmente encontrado na configuração do IdP)",
- "PLACEHOLDER": "https://seu-idp.com/saml"
+ "PLACEHOLDER": "https://sso.example.com/saml"
},
"UPDATE_BUTTON": "Atualizar configurações de SAML",
"API": {
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
new file mode 100644
index 000000000..29411e56d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
@@ -0,0 +1,194 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
index 1ab4fa501..8448f32cf 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
@@ -6,6 +6,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
+import AssistantOverviewIndex from './assistants/overview/Index.vue';
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
@@ -36,6 +37,12 @@ const metaV2 = {
};
const assistantRoutes = [
+ {
+ path: frontendURL('accounts/:accountId/captain/:assistantId/overview'),
+ component: AssistantOverviewIndex,
+ name: 'captain_assistants_overview_index',
+ meta,
+ },
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
@@ -129,7 +136,7 @@ export const routes = [
return {
name: 'captain_assistants_index',
params: {
- navigationPath: 'captain_assistants_responses_index',
+ navigationPath: 'captain_assistants_overview_index',
...to.params,
},
};
diff --git a/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue
index 87c04aefe..9c1effd20 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue
@@ -17,7 +17,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Policy from 'dashboard/components/policy.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
-import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
+import DocumentDetails from 'dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue';
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
@@ -51,22 +51,22 @@ const handleDelete = () => {
deleteDocumentDialog.value.dialogRef.open();
};
-const showRelatedResponses = ref(false);
+const showDocumentDetails = ref(false);
const showCreateDialog = ref(false);
const createDocumentDialog = ref(null);
-const relationQuestionDialog = ref(null);
+const documentDetailsDialog = ref(null);
-const handleShowRelatedDocument = () => {
- showRelatedResponses.value = true;
- nextTick(() => relationQuestionDialog.value.dialogRef.open());
+const handleShowDocumentDetails = () => {
+ showDocumentDetails.value = true;
+ nextTick(() => documentDetailsDialog.value.dialogRef.open());
};
const handleCreateDocument = () => {
showCreateDialog.value = true;
nextTick(() => createDocumentDialog.value.dialogRef.open());
};
-const handleRelatedResponseClose = () => {
- showRelatedResponses.value = false;
+const handleDocumentDetailsClose = () => {
+ showDocumentDetails.value = false;
};
const handleCreateDialogClose = () => {
@@ -235,8 +235,8 @@ const handleAction = ({ action, id }) => {
nextTick(() => {
if (action === 'delete') {
handleDelete();
- } else if (action === 'viewRelatedQuestions') {
- handleShowRelatedDocument();
+ } else if (action === 'viewDetails') {
+ handleShowDocumentDetails();
} else if (action === 'sync') {
handleSync(id);
}
@@ -416,6 +416,7 @@ onUnmounted(() => {
:last-sync-error-code="doc.last_sync_error_code"
:sync-in-progress="doc.sync_in_progress"
:sync-stale-after-hours="syncIntervalHours"
+ :responses-count="doc.responses_count"
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
:selectable="canManageDocuments"
:show-selection-control="shouldShowSelectionControl(doc.id)"
@@ -427,11 +428,11 @@ onUnmounted(() => {
-
{
const { navigationPath } = route.params;
const isAValidRoute = [
+ 'captain_assistants_overview_index', // Overview page
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
@@ -64,7 +65,7 @@ const routeToLastActiveAssistant = () => {
const navigateTo = isAValidRoute
? navigationPath
- : 'captain_assistants_responses_index';
+ : 'captain_assistants_overview_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue
index fede6e3cb..1e33a192b 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue
@@ -25,7 +25,7 @@ export default {
},
},
setup() {
- const { agentsList } = useAgentsList();
+ const { agentsList } = useAgentsList(true, { includeAgentBots: true });
return {
agentsList,
};
@@ -81,18 +81,27 @@ export default {
},
assignedAgent: {
get() {
- return this.currentChat.meta.assignee;
+ const assignee = this.currentChat.meta.assignee;
+ return (
+ assignee && {
+ ...assignee,
+ assignee_type: this.currentChat.meta.assignee_type || 'User',
+ }
+ );
},
set(agent) {
const agentId = agent ? agent.id : null;
+ const assigneeType = agent ? agent.assignee_type || 'User' : null;
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
+ assigneeType,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
+ assigneeType,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
@@ -152,7 +161,10 @@ export default {
if (!this.assignedAgent) {
return true;
}
- if (this.assignedAgent.id !== this.currentUser.id) {
+ if (
+ this.assignedAgent.id !== this.currentUser.id ||
+ (this.assignedAgent.assignee_type || 'User') !== 'User'
+ ) {
return true;
}
return false;
@@ -183,7 +195,11 @@ export default {
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
- if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
+ if (
+ this.assignedAgent?.id === selectedItem.id &&
+ (this.assignedAgent?.assignee_type || 'User') ===
+ (selectedItem.assignee_type || 'User')
+ ) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
index ba2d6f0dc..3d19943d3 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
@@ -7,17 +7,20 @@ import { useMapGetter } from 'dashboard/composables/store';
// Mirrors the availability checks in ChannelItem.vue.
export function useChannelConfig() {
const globalConfig = useMapGetter('globalConfig/get');
+ const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const installationConfig = window.chatwootConfig || {};
const CHANNEL_CONFIGURED = {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
+ !isOnChatwootCloud.value &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
- instagram: () => Boolean(installationConfig.instagramAppId),
+ instagram: () =>
+ !isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js
index bd34d5f20..57411c43c 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js
@@ -1,6 +1,7 @@
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
+import { useAccount } from 'dashboard/composables/useAccount';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import googleClient from 'dashboard/api/channel/googleClient';
@@ -23,11 +24,17 @@ export function useChannelConnect() {
const { t } = useI18n();
const store = useStore();
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
+ const { isOnChatwootCloud } = useAccount();
const connectViaOAuth = async provider => {
const client = OAUTH_CLIENTS[provider];
if (!client) return;
+ if (provider === 'instagram' && isOnChatwootCloud.value) {
+ useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING'));
+ return;
+ }
+
try {
const {
data: { url },
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js
index 1134d4494..7bc39adf1 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js
@@ -11,9 +11,20 @@ vi.mock('vue-router');
// resolves the current account is exercised here too. The real ./constants are
// used, so assertions validate against the actual channel identity (label keys,
// channel_type, social ordering) derived from CHANNEL_LIST.
-const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
+const mountComposable = ({
+ brandInfo,
+ inboxes = [],
+ isOnChatwootCloud = false,
+} = {}) => {
const store = createStore({
modules: {
+ globalConfig: {
+ namespaced: true,
+ getters: {
+ get: () => ({}),
+ isOnChatwootCloud: () => isOnChatwootCloud,
+ },
+ },
accounts: {
namespaced: true,
getters: {
@@ -195,6 +206,22 @@ describe('useDetectedChannels', () => {
'line',
]);
});
+
+ it('hides Instagram from onboarding on Chatwoot Cloud', () => {
+ const { displayedChannels } = mountComposable({
+ isOnChatwootCloud: true,
+ brandInfo: {
+ socials: [
+ { type: 'instagram', url: 'https://instagram.com/acme' },
+ { type: 'tiktok', url: 'https://tiktok.com/@acme' },
+ ],
+ },
+ });
+
+ expect(displayedChannels.value.map(channel => channel.type)).toEqual([
+ 'tiktok',
+ ]);
+ });
});
describe('remainingChannels', () => {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue
index 167d13cf5..2fa3bf0b8 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue
@@ -52,9 +52,11 @@ const helpURL = getHelpUrlForFeature(props.featureName);
v-if="title"
class="flex items-center justify-between w-full gap-4 min-h-8 mb-2"
>
-
- {{ title }}
-
+
+
+ {{ title }}
+
+
+import {
+ computed,
+ onActivated,
+ onBeforeUnmount,
+ onDeactivated,
+ ref,
+} from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
+import { useStoreGetters } from 'dashboard/composables/store';
+
+import Button from 'dashboard/components-next/button/Button.vue';
+import Icon from 'dashboard/components-next/icon/Icon.vue';
+import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
+import SettingsLayout from '../SettingsLayout.vue';
+import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
+import DataImportsAPI from 'dashboard/api/dataImports';
+import NewImportDialog from './NewImportDialog.vue';
+import { importSourceFor } from './importSources';
+import {
+ POLL_INTERVAL_MS,
+ formatDate,
+ formatStatus,
+ importedCount,
+ isActiveImport,
+ isActiveIntercomImport,
+ statusDotClass,
+} from './importStatus';
+
+const { t } = useI18n();
+const getters = useStoreGetters();
+const router = useRouter();
+
+const dataImports = ref([]);
+const isLoading = ref(true);
+const isRefreshing = ref(false);
+const isPolling = ref(false);
+const showImportDrawer = ref(false);
+const activeTab = ref('import');
+let pollTimer;
+let isPageActive = false;
+
+const accountId = getters.getCurrentAccountId;
+
+const tabs = computed(() => [
+ { key: 'import', label: t('DATA_IMPORTS.TABS.IMPORT') },
+ { key: 'export', label: t('DATA_IMPORTS.TABS.EXPORT') },
+]);
+
+const activeTabIndex = computed(() =>
+ tabs.value.findIndex(tab => tab.key === activeTab.value)
+);
+
+const hasActiveImport = computed(() => dataImports.value.some(isActiveImport));
+const hasActiveIntercomImport = computed(() =>
+ dataImports.value.some(isActiveIntercomImport)
+);
+
+const dataImportRoute = dataImport => ({
+ name: 'settings_data_import_show',
+ params: { accountId: accountId.value, dataImportId: dataImport.id },
+});
+
+const importTypesFor = dataImport =>
+ dataImport.import_types?.length
+ ? dataImport.import_types
+ : [dataImport.data_type];
+
+const importTypeLabel = dataImport =>
+ importTypesFor(dataImport)
+ .map(type => {
+ if (type === 'contacts') return t('DATA_IMPORTS.TYPES.CONTACTS');
+ if (type === 'conversations') {
+ return t('DATA_IMPORTS.TYPES.CONVERSATIONS');
+ }
+ return type;
+ })
+ .join(', ');
+
+const fetchImports = async () => {
+ const response = await DataImportsAPI.get();
+ dataImports.value = response.data.payload || [];
+};
+
+const stopPolling = () => {
+ if (!pollTimer) return;
+
+ window.clearInterval(pollTimer);
+ pollTimer = null;
+};
+
+const refreshImportsInBackground = async () => {
+ if (
+ !isPageActive ||
+ isPolling.value ||
+ !hasActiveImport.value ||
+ document.hidden
+ ) {
+ return;
+ }
+
+ isPolling.value = true;
+ try {
+ await fetchImports();
+ } finally {
+ isPolling.value = false;
+ if (!hasActiveImport.value) stopPolling();
+ }
+};
+
+const startPolling = () => {
+ stopPolling();
+ if (!isPageActive || !hasActiveImport.value) return;
+
+ pollTimer = window.setInterval(refreshImportsInBackground, POLL_INTERVAL_MS);
+};
+
+const refresh = async ({ showLoader = true } = {}) => {
+ if (showLoader) isLoading.value = true;
+ else isRefreshing.value = true;
+
+ try {
+ await fetchImports();
+ } finally {
+ isLoading.value = false;
+ isRefreshing.value = false;
+ if (isPageActive) {
+ if (hasActiveImport.value && !pollTimer) startPolling();
+ if (!hasActiveImport.value) stopPolling();
+ }
+ }
+};
+
+const openImport = dataImport => {
+ router.push(dataImportRoute(dataImport));
+};
+
+const openImportDrawer = () => {
+ if (!hasActiveIntercomImport.value) showImportDrawer.value = true;
+};
+
+const onImportCreated = dataImportId => {
+ showImportDrawer.value = false;
+ router.push({
+ name: 'settings_data_import_show',
+ params: { accountId: accountId.value, dataImportId },
+ });
+};
+
+const onTabChanged = tab => {
+ activeTab.value = tab.key;
+};
+
+const handleVisibilityChange = () => {
+ if (isPageActive && !document.hidden && hasActiveImport.value) {
+ refreshImportsInBackground();
+ }
+};
+
+onActivated(async () => {
+ isPageActive = true;
+ await refresh();
+ if (!isPageActive) return;
+
+ startPolling();
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+});
+
+onDeactivated(() => {
+ isPageActive = false;
+ stopPolling();
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+});
+
+onBeforeUnmount(() => {
+ isPageActive = false;
+ stopPolling();
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+});
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('DATA_IMPORTS.TABLE.COUNT', { count: dataImports.length }) }}
+
+
+
+
+
+ {{
+ $t('DATA_IMPORTS.MONITOR.LIVE', {
+ seconds: POLL_INTERVAL_MS / 1000,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('DATA_IMPORTS.EXPORT.TITLE') }}
+
+
+ {{ $t('DATA_IMPORTS.EXPORT.DESCRIPTION') }}
+
+
+
+
+ {{ $t('DATA_IMPORTS.EXPORT.COMING_SOON') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('DATA_IMPORTS.TABLE.EMPTY') }}
+
+
+ {{ $t('DATA_IMPORTS.TABLE.EMPTY_DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+ {{ dataImport.name || $t('DATA_IMPORTS.TABLE.UNNAMED') }}
+
+
+
+
+ {{ formatStatus(dataImport.status) }}
+
+
+
+
+
{{ importTypeLabel(dataImport) }}
+
+
+ {{
+ $t('DATA_IMPORTS.TABLE.IMPORTED_COUNT', {
+ count: importedCount(dataImport),
+ })
+ }}
+
+
+
{{ formatDate(dataImport.created_at) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/NewImportDialog.vue b/app/javascript/dashboard/routes/dashboard/settings/data/NewImportDialog.vue
new file mode 100644
index 000000000..56fda08f3
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/NewImportDialog.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/Show.vue b/app/javascript/dashboard/routes/dashboard/settings/data/Show.vue
new file mode 100644
index 000000000..ed89c66a9
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/Show.vue
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue
new file mode 100644
index 000000000..935d63b87
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportDetailHeader.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+ {{ monitorTitle }}
+
+
+
+
+ {{
+ isPolling
+ ? $t('DATA_IMPORTS.MONITOR.REFRESHING')
+ : $t('DATA_IMPORTS.MONITOR.LIVE', {
+ seconds: pollIntervalSeconds,
+ })
+ }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportErrorsSection.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportErrorsSection.vue
new file mode 100644
index 000000000..0cbfd9d2b
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportErrorsSection.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+ {{ error.error_code }}
+
+
+
+
+ {{ sourceObjectLabel(error) }}
+
+
+
+
+ {{ error.message || '-' }}
+
+
+
+
+ {{ formatDate(error.created_at) }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportLogSection.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportLogSection.vue
new file mode 100644
index 000000000..8ccbaf5fe
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportLogSection.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ emptyMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportProgress.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportProgress.vue
new file mode 100644
index 000000000..fb2cd003c
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportProgress.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+ {{ title }}
+
+
+
+
{{ item.label }}
+
+
+ {{ item.importedLabel }}
+
+
+ {{ `${item.percent}%` }}
+
+
+
+
{{ item.caption }}
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSkipLogsSection.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSkipLogsSection.vue
new file mode 100644
index 000000000..2b3d9c467
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSkipLogsSection.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ skipLog.kind || '-' }}
+
+
+
+
+ {{ sourceObjectLabel(skipLog) }}
+
+
+
+
+ {{ skipLog.message || '-' }}
+
+
+
+
+ {{ formatDate(skipLog.created_at) }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSummaryTiles.vue b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSummaryTiles.vue
new file mode 100644
index 000000000..01e239b32
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/components/ImportSummaryTiles.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
-
+
+ {{ item.label }}
+
+ -
+ {{ item.value }}
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js b/app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js
new file mode 100644
index 000000000..1e81d3e9a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/data.routes.js
@@ -0,0 +1,34 @@
+import { FEATURE_FLAGS } from '../../../../featureFlags';
+import { frontendURL } from '../../../../helper/URLHelper';
+import SettingsWrapper from '../SettingsWrapper.vue';
+import Index from './Index.vue';
+import Show from './Show.vue';
+
+export default {
+ routes: [
+ {
+ path: frontendURL('accounts/:accountId/settings/data'),
+ component: SettingsWrapper,
+ children: [
+ {
+ path: '',
+ name: 'settings_data_imports',
+ component: Index,
+ meta: {
+ featureFlag: FEATURE_FLAGS.DATA_IMPORT,
+ permissions: ['administrator'],
+ },
+ },
+ {
+ path: ':dataImportId',
+ name: 'settings_data_import_show',
+ component: Show,
+ meta: {
+ featureFlag: FEATURE_FLAGS.DATA_IMPORT,
+ permissions: ['administrator'],
+ },
+ },
+ ],
+ },
+ ],
+};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/importSources.js b/app/javascript/dashboard/routes/dashboard/settings/data/importSources.js
new file mode 100644
index 000000000..85e832a6c
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/importSources.js
@@ -0,0 +1,17 @@
+export const IMPORT_SOURCES = [
+ {
+ value: 'intercom',
+ label: 'Intercom',
+ icon: '/dashboard/images/integrations/intercom.png',
+ },
+];
+
+const DEFAULT_IMPORT_SOURCE = {
+ value: 'file',
+ label: 'File import',
+ iconClass: 'i-lucide-file-text',
+};
+
+export const importSourceFor = dataImport =>
+ IMPORT_SOURCES.find(source => source.value === dataImport?.source_provider) ||
+ DEFAULT_IMPORT_SOURCE;
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js b/app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js
new file mode 100644
index 000000000..f658ee04c
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/importStatus.js
@@ -0,0 +1,84 @@
+export const POLL_INTERVAL_MS = 5000;
+
+export const ACTIVE_IMPORT_STATUSES = ['pending', 'processing'];
+
+export const isActiveImport = dataImport =>
+ ACTIVE_IMPORT_STATUSES.includes(dataImport?.status);
+
+export const isIntercomImport = dataImport =>
+ dataImport?.data_type === 'intercom' &&
+ dataImport?.source_provider === 'intercom';
+
+export const isActiveIntercomImport = dataImport =>
+ isIntercomImport(dataImport) && isActiveImport(dataImport);
+
+export const isAbandonableImport = dataImport =>
+ isActiveIntercomImport(dataImport);
+
+export const importedCount = dataImport => {
+ if (!isIntercomImport(dataImport)) {
+ return Number(dataImport?.processed_records || 0);
+ }
+
+ return ['contacts', 'conversations', 'messages'].reduce(
+ (total, key) => total + Number(dataImport?.stats?.[key]?.imported || 0),
+ 0
+ );
+};
+
+export const importStageKey = dataImport => {
+ if (!dataImport) return 'unknown';
+
+ if (dataImport.status === 'completed') return 'completed';
+ if (dataImport.status === 'completed_with_errors') {
+ return 'completed_with_errors';
+ }
+ if (dataImport.status === 'failed') return 'failed';
+ if (dataImport.status === 'abandoned') return 'abandoned';
+ if (dataImport.status === 'pending') return 'queued';
+
+ const importTypes = dataImport.import_types?.length
+ ? dataImport.import_types
+ : [dataImport.data_type];
+ const cursor = dataImport.cursor || {};
+
+ if (importTypes.includes('contacts') && !cursor.contacts?.completed) {
+ return 'contacts';
+ }
+
+ if (
+ importTypes.includes('conversations') &&
+ !cursor.conversations?.completed
+ ) {
+ return 'conversations';
+ }
+
+ return 'finalizing';
+};
+
+export const formatStatus = value => value?.replaceAll('_', ' ') || '-';
+
+export const sourceObjectLabel = record =>
+ [record.source_object_type, record.source_object_id]
+ .filter(Boolean)
+ .join(': ') || '-';
+
+export const formatDate = value => {
+ if (!value) return '-';
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(new Date(value));
+};
+
+const STATUS_DOT_CLASS = {
+ pending: 'bg-n-amber-9',
+ processing: 'bg-n-blue-9',
+ completed: 'bg-n-teal-9',
+ completed_with_errors: 'bg-n-amber-9',
+ failed: 'bg-n-ruby-9',
+ abandoned: 'bg-n-slate-9',
+};
+
+export const statusDotClass = status =>
+ STATUS_DOT_CLASS[status] || 'bg-n-slate-9';
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/specs/importStatus.spec.js b/app/javascript/dashboard/routes/dashboard/settings/data/specs/importStatus.spec.js
new file mode 100644
index 000000000..dcfe7d9bd
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/specs/importStatus.spec.js
@@ -0,0 +1,92 @@
+import {
+ formatDate,
+ importedCount,
+ isActiveIntercomImport,
+ statusDotClass,
+} from '../importStatus';
+
+describe('importStatus', () => {
+ describe('isActiveIntercomImport', () => {
+ it('only treats pending or processing Intercom imports as active', () => {
+ expect(
+ isActiveIntercomImport({
+ data_type: 'intercom',
+ source_provider: 'intercom',
+ status: 'processing',
+ })
+ ).toBe(true);
+ expect(
+ isActiveIntercomImport({
+ data_type: 'contacts',
+ source_provider: null,
+ status: 'processing',
+ })
+ ).toBe(false);
+ expect(
+ isActiveIntercomImport({
+ data_type: 'intercom',
+ source_provider: 'intercom',
+ status: 'completed',
+ })
+ ).toBe(false);
+ });
+ });
+
+ describe('importedCount', () => {
+ it('sums Intercom imported stats', () => {
+ expect(
+ importedCount({
+ data_type: 'intercom',
+ source_provider: 'intercom',
+ processed_records: 20,
+ stats: {
+ contacts: { imported: 2 },
+ conversations: { imported: 3 },
+ messages: { imported: 10 },
+ },
+ })
+ ).toBe(15);
+ });
+
+ it('uses processed records for legacy imports', () => {
+ expect(
+ importedCount({
+ data_type: 'contacts',
+ source_provider: null,
+ processed_records: 7,
+ stats: {},
+ })
+ ).toBe(7);
+ });
+ });
+
+ describe('statusDotClass', () => {
+ it('maps each status to its dot color class', () => {
+ expect(statusDotClass('pending')).toBe('bg-n-amber-9');
+ expect(statusDotClass('processing')).toBe('bg-n-blue-9');
+ expect(statusDotClass('completed')).toBe('bg-n-teal-9');
+ expect(statusDotClass('completed_with_errors')).toBe('bg-n-amber-9');
+ expect(statusDotClass('failed')).toBe('bg-n-ruby-9');
+ expect(statusDotClass('abandoned')).toBe('bg-n-slate-9');
+ });
+
+ it('falls back to slate for unknown or missing status', () => {
+ expect(statusDotClass('unknown')).toBe('bg-n-slate-9');
+ expect(statusDotClass(undefined)).toBe('bg-n-slate-9');
+ });
+ });
+
+ describe('formatDate', () => {
+ it('returns a dash for empty values', () => {
+ expect(formatDate(null)).toBe('-');
+ expect(formatDate('')).toBe('-');
+ expect(formatDate(undefined)).toBe('-');
+ });
+
+ it('formats a valid date into a readable string', () => {
+ const formatted = formatDate('2026-07-10T18:09:00Z');
+ expect(formatted).not.toBe('-');
+ expect(formatted).toContain('2026');
+ });
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/data/specs/pollingLifecycle.spec.js b/app/javascript/dashboard/routes/dashboard/settings/data/specs/pollingLifecycle.spec.js
new file mode 100644
index 000000000..7e74bd565
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/data/specs/pollingLifecycle.spec.js
@@ -0,0 +1,121 @@
+import { flushPromises, mount } from '@vue/test-utils';
+import { KeepAlive, defineComponent, h, nextTick, ref } from 'vue';
+import DataImportsAPI from 'dashboard/api/dataImports';
+import Index from '../Index.vue';
+import Show from '../Show.vue';
+
+vi.mock('dashboard/api/dataImports', () => ({
+ default: {
+ get: vi.fn(),
+ show: vi.fn(),
+ },
+}));
+
+vi.mock('dashboard/composables/store', () => ({
+ useStoreGetters: () => ({ getCurrentAccountId: { value: 1 } }),
+}));
+
+vi.mock('dashboard/composables', () => ({
+ useAlert: vi.fn(),
+}));
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({ t: key => key }),
+}));
+
+vi.mock('vue-router', async importOriginal => ({
+ ...(await importOriginal()),
+ useRoute: () => ({ params: { dataImportId: 1 } }),
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+const deferredRequest = () => {
+ let resolve;
+ const promise = new Promise(resolvePromise => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+};
+
+const mountKeptAlive = component => {
+ const Host = defineComponent({
+ setup() {
+ const visible = ref(true);
+ return { visible };
+ },
+ render() {
+ return h(KeepAlive, null, {
+ default: () => (this.visible ? h(component) : null),
+ });
+ },
+ });
+
+ return mount(Host, {
+ global: {
+ stubs: {
+ SettingsLayout: true,
+ BaseSettingsHeader: true,
+ Button: true,
+ Icon: true,
+ TabBar: true,
+ NewImportDialog: true,
+ ImportDetailHeader: true,
+ ImportSummaryTiles: true,
+ ImportProgress: true,
+ ImportErrorsSection: true,
+ ImportSkipLogsSection: true,
+ },
+ mocks: {
+ $t: key => key,
+ },
+ },
+ });
+};
+
+describe('data import polling lifecycle', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ });
+
+ it('does not start list polling after the page deactivates', async () => {
+ const request = deferredRequest();
+ DataImportsAPI.get.mockReturnValue(request.promise);
+ const wrapper = mountKeptAlive(Index);
+ await nextTick();
+
+ wrapper.vm.visible = false;
+ await nextTick();
+ request.resolve({ data: { payload: [{ status: 'processing' }] } });
+ await flushPromises();
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(DataImportsAPI.get).toHaveBeenCalledTimes(1);
+ wrapper.unmount();
+ });
+
+ it('does not start detail polling after the page deactivates', async () => {
+ const request = deferredRequest();
+ DataImportsAPI.show.mockReturnValue(request.promise);
+ const wrapper = mountKeptAlive(Show);
+ await nextTick();
+
+ wrapper.vm.visible = false;
+ await nextTick();
+ request.resolve({
+ data: {
+ status: 'processing',
+ skip_logs_filters: {},
+ },
+ });
+ await flushPromises();
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(DataImportsAPI.show).toHaveBeenCalledTimes(1);
+ wrapper.unmount();
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue
index 884c198c4..0026cd8a9 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue
@@ -1,5 +1,5 @@
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
index 3dda0ad8e..0972e4b95 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
+import Banner from 'next/banner/Banner.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
@@ -17,6 +18,22 @@ const props = defineProps({
type: Boolean,
default: false,
},
+ isDisabled: {
+ type: Boolean,
+ default: false,
+ },
+ showRestrictionAlert: {
+ type: Boolean,
+ default: false,
+ },
+ restrictionStatusUrl: {
+ type: String,
+ default: '',
+ },
+ restrictionWarningText: {
+ type: String,
+ default: '',
+ },
});
const store = useStore();
@@ -81,6 +98,8 @@ const handleSignupSuccess = async inboxData => {
};
const launchEmbeddedSignup = async () => {
+ if (props.isDisabled) return;
+
let credentials;
try {
credentials = await runEmbeddedSignup();
@@ -174,9 +193,33 @@ const launchEmbeddedSignup = async () => {
+
+
+
+
+ {{
+ restrictionWarningText ||
+ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
+ }}
+
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
+
+
+
+
+
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import Banner from 'dashboard/components-next/banner/Banner.vue';
+import Icon from 'dashboard/components-next/icon/Icon.vue';
+
+const emit = defineEmits(['start']);
+const { t } = useI18n();
+
+const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
+ 'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
+
+const copy = computed(() => ({
+ title: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.TITLE'),
+ description: t(
+ 'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.DESCRIPTION'
+ ),
+ start: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.START'),
+ guide: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.GUIDE'),
+}));
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappManualMigrationDialog.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappManualMigrationDialog.vue
new file mode 100644
index 000000000..6e7136a02
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappManualMigrationDialog.vue
@@ -0,0 +1,524 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
index 8beaea489..e1ecdbd3f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
@@ -10,7 +10,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TextArea from 'next/textarea/TextArea.vue';
-import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
export default {
@@ -22,7 +21,6 @@ export default {
SmtpSettings,
NextButton,
TextArea,
- WhatsappReauthorize,
},
mixins: [inboxMixin],
props: {
@@ -39,7 +37,6 @@ export default {
hmacMandatory: false,
allowMobileWebview: false,
whatsAppInboxAPIKey: '',
- isRequestingReauthorization: false,
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
@@ -53,9 +50,6 @@ export default {
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
- whatsappAppId() {
- return window.chatwootConfig?.whatsappAppId;
- },
isForwardingEnabled() {
return !!this.inbox.forwarding_enabled;
},
@@ -166,11 +160,6 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
- async handleReconfigure() {
- if (this.$refs.whatsappReauth) {
- await this.$refs.whatsappReauth.requestAuthorization();
- }
- },
async syncTemplates() {
this.isSyncingTemplates = true;
try {
@@ -362,22 +351,17 @@ export default {
-
-
- {{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
-
-
+
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js
index 7c37cd9cc..8309ed360 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js
@@ -1,7 +1,13 @@
import { computed, ref } from 'vue';
import ReportsAPI from 'dashboard/api/reports';
-export function useReportDrilldown() {
+// `fetcher` is any `({ ...request, page, signal }) => Promise` returning the
+// shared drilldown envelope (`{ data: { meta, payload } }`), so the same paging
+// and abort machinery backs both the reports and Captain assistant drilldowns.
+// The default is wrapped so `ReportsAPI` stays the receiver when invoked.
+export function useReportDrilldown(
+ fetcher = params => ReportsAPI.getDrilldown(params)
+) {
const activeRequest = ref(null);
const records = ref([]);
const meta = ref({});
@@ -20,17 +26,7 @@ export function useReportDrilldown() {
const isCurrentRequest = token =>
token === requestToken && !!activeRequest.value;
- const requestFingerprint = request =>
- JSON.stringify({
- metric: request.metric,
- bucketTimestamp: request.bucketTimestamp,
- from: request.from,
- to: request.to,
- type: request.type,
- id: request.id,
- groupBy: request.groupBy,
- businessHours: request.businessHours,
- });
+ const requestFingerprint = request => JSON.stringify(request);
const abortActiveRequest = () => {
if (!activeRequestController) return;
@@ -55,7 +51,7 @@ export function useReportDrilldown() {
hasError.value = false;
try {
- const response = await ReportsAPI.getDrilldown({
+ const response = await fetcher({
...request,
page,
signal: controller.signal,
diff --git a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js
index e277a0ef4..c37093176 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js
@@ -26,6 +26,7 @@ import profile from './profile/profile.routes';
import security from './security/security.routes';
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
import captain from './captain/captain.routes';
+import data from './data/data.routes';
export default {
routes: [
@@ -57,6 +58,7 @@ export default {
...canned.routes,
...inbox.routes,
...integrations.routes,
+ ...data.routes,
...labels.routes,
...macros.routes,
...reports.routes,
diff --git a/app/javascript/dashboard/routes/dashboard/suspended/Index.vue b/app/javascript/dashboard/routes/dashboard/suspended/Index.vue
index 56a5b5cae..027f75ff5 100644
--- a/app/javascript/dashboard/routes/dashboard/suspended/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/suspended/Index.vue
@@ -1,5 +1,6 @@
@@ -93,16 +101,25 @@ const hasIcon = computed(() => {
+ >
+
+
+
+
+
+
item && option.id === item.id);
+ return this.selectedItems.some(item => {
+ if (!item || option.id !== item.id) return false;
+
+ return (
+ (option.assignee_type || 'User') === (item.assignee_type || 'User')
+ );
+ });
},
},
};
@@ -94,7 +100,10 @@ export default {
-
+
+ >
+
+
+
+
+
+
'';
+ }
+
get formattedMessage() {
return this.formatMessage();
}
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 3350399eb..20d64005a 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -68,6 +68,25 @@ describe('#MessageFormatter', () => {
});
});
+ describe('#disableImageRendering', () => {
+ it('omits nested and reference images with relative URLs', () => {
+ const message = `Before ![nested [alt]](/relative.png)
+
+![reference][logo]
+
+[logo]: /logo.png
+
+After`;
+ const formatter = new MessageFormatter(message);
+
+ formatter.disableImageRendering();
+
+ expect(formatter.formattedMessage).not.toContain('
{
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js
index 713de56f1..b1c76e94f 100755
--- a/app/javascript/widget/api/endPoints.js
+++ b/app/javascript/widget/api/endPoints.js
@@ -11,6 +11,7 @@ const createConversation = params => {
name: params.fullName,
email: params.emailAddress,
phone_number: params.phoneNumber,
+ custom_attributes: params.contactCustomAttributes,
},
message: {
content: params.message,
diff --git a/app/javascript/widget/api/specs/endPoints.spec.js b/app/javascript/widget/api/specs/endPoints.spec.js
index b95b2f659..cf7f2486b 100644
--- a/app/javascript/widget/api/specs/endPoints.spec.js
+++ b/app/javascript/widget/api/specs/endPoints.spec.js
@@ -32,6 +32,50 @@ describe('#sendMessage', () => {
});
});
+describe('#createConversation', () => {
+ it('includes contact custom attributes in the payload', () => {
+ const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
+ toString: () => 'mock date',
+ }));
+ vi.spyOn(window, 'location', 'get').mockReturnValue({
+ ...window.location,
+ search: '?param=1',
+ });
+
+ window.WOOT_WIDGET = {
+ $root: { $i18n: { locale: 'ar' } },
+ };
+
+ const result = endPoints.createConversation({
+ fullName: 'John',
+ emailAddress: 'john@example.com',
+ phoneNumber: '+919745313456',
+ message: 'hey',
+ customAttributes: { order_id: '12345' },
+ contactCustomAttributes: { cpf: '123.456.789-09' },
+ });
+
+ expect(result).toEqual({
+ url: `/api/v1/widget/conversations?param=1&locale=ar`,
+ params: {
+ contact: {
+ name: 'John',
+ email: 'john@example.com',
+ phone_number: '+919745313456',
+ custom_attributes: { cpf: '123.456.789-09' },
+ },
+ message: {
+ content: 'hey',
+ timestamp: 'mock date',
+ referer_url: '',
+ },
+ custom_attributes: { order_id: '12345' },
+ },
+ });
+ spy.mockRestore();
+ });
+});
+
describe('#sendMessage with pending metadata', () => {
it('includes custom_attributes and labels in payload', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
diff --git a/app/javascript/widget/views/PreChatForm.vue b/app/javascript/widget/views/PreChatForm.vue
index 4872edbcd..5bbac0596 100644
--- a/app/javascript/widget/views/PreChatForm.vue
+++ b/app/javascript/widget/views/PreChatForm.vue
@@ -3,7 +3,6 @@ import { mapActions } from 'vuex';
import { useRouter } from 'vue-router';
import PreChatForm from '../components/PreChat/Form.vue';
import configMixin from '../mixins/configMixin';
-import { isEmptyObject } from 'widget/helpers/utils';
import { ON_CONVERSATION_CREATED } from '../constants/widgetBusEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -42,6 +41,10 @@ export default {
contactCustomAttributes,
conversationCustomAttributes,
}) {
+ // Contact custom attributes are sent within the same request that
+ // identifies the contact. A separate update call would race the contact
+ // merge on the server (matching email/phone) and write the values to
+ // the destroyed contact, silently losing them.
if (activeCampaignId) {
emitter.emit('execute-campaign', {
campaignId: activeCampaignId,
@@ -52,6 +55,7 @@ export default {
email: emailAddress,
name: fullName,
phone_number: phoneNumber,
+ custom_attributes: contactCustomAttributes,
},
});
} else {
@@ -63,14 +67,9 @@ export default {
message: message,
phoneNumber: phoneNumber,
customAttributes: conversationCustomAttributes,
+ contactCustomAttributes: contactCustomAttributes,
});
}
- if (!isEmptyObject(contactCustomAttributes)) {
- this.$store.dispatch(
- 'contacts/setCustomAttributes',
- contactCustomAttributes
- );
- }
},
},
};
diff --git a/app/javascript/widget/views/specs/PreChatForm.spec.js b/app/javascript/widget/views/specs/PreChatForm.spec.js
new file mode 100644
index 000000000..25bb16b53
--- /dev/null
+++ b/app/javascript/widget/views/specs/PreChatForm.spec.js
@@ -0,0 +1,89 @@
+import { shallowMount, flushPromises } from '@vue/test-utils';
+import { createStore } from 'vuex';
+import PreChatFormView from '../PreChatForm.vue';
+
+global.chatwootWebChannel = {
+ preChatFormEnabled: true,
+ preChatFormOptions: { pre_chat_fields: [], pre_chat_message: '' },
+};
+
+describe('PreChatForm view', () => {
+ let createConversation;
+ let setCustomAttributes;
+ let updateContact;
+ let store;
+
+ beforeEach(() => {
+ createConversation = vi.fn();
+ setCustomAttributes = vi.fn();
+ updateContact = vi.fn();
+ store = createStore({
+ modules: {
+ conversation: {
+ namespaced: true,
+ actions: { createConversation, clearConversations: vi.fn() },
+ },
+ conversationAttributes: {
+ namespaced: true,
+ actions: { clearConversationAttributes: vi.fn() },
+ },
+ contacts: {
+ namespaced: true,
+ actions: { setCustomAttributes, update: updateContact },
+ },
+ },
+ });
+ });
+
+ const mountView = () =>
+ shallowMount(PreChatFormView, { global: { plugins: [store] } });
+
+ it('sends contact custom attributes with the conversation create request', async () => {
+ const wrapper = mountView();
+ wrapper.vm.onSubmit({
+ fullName: 'John',
+ emailAddress: 'john@example.com',
+ message: 'hey',
+ contactCustomAttributes: { cpf: '123.456.789-09' },
+ conversationCustomAttributes: { order_id: '12345' },
+ });
+ await flushPromises();
+
+ expect(createConversation).toHaveBeenCalledWith(expect.anything(), {
+ fullName: 'John',
+ emailAddress: 'john@example.com',
+ message: 'hey',
+ phoneNumber: undefined,
+ customAttributes: { order_id: '12345' },
+ contactCustomAttributes: { cpf: '123.456.789-09' },
+ });
+ // attributes ride along in the create request itself; a separate call
+ // would race the contact merge on the server and write to a destroyed
+ // contact
+ expect(setCustomAttributes).not.toHaveBeenCalled();
+ });
+
+ it('sends contact custom attributes along with the contact update for campaigns', async () => {
+ const wrapper = mountView();
+ wrapper.vm.onSubmit({
+ fullName: 'John',
+ emailAddress: 'john@example.com',
+ phoneNumber: null,
+ activeCampaignId: 42,
+ contactCustomAttributes: { cpf: '123.456.789-09' },
+ conversationCustomAttributes: {},
+ });
+ await flushPromises();
+
+ expect(updateContact).toHaveBeenCalledWith(expect.anything(), {
+ user: {
+ email: 'john@example.com',
+ name: 'John',
+ phone_number: null,
+ custom_attributes: { cpf: '123.456.789-09' },
+ },
+ });
+ expect(createConversation).not.toHaveBeenCalled();
+ expect(setCustomAttributes).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/jobs/agents/destroy_job.rb b/app/jobs/agents/destroy_job.rb
index 8596ca1cc..fe0276bc6 100644
--- a/app/jobs/agents/destroy_job.rb
+++ b/app/jobs/agents/destroy_job.rb
@@ -31,7 +31,11 @@ class Agents::DestroyJob < ApplicationJob
def unassign_conversations(account, user)
# rubocop:disable Rails/SkipsModelValidations
- user.assigned_conversations.where(account: account).in_batches.update_all(assignee_id: nil)
+ unassigned_count = user.assigned_conversations.where(account: account).in_batches.update_all(assignee_id: nil)
# rubocop:enable Rails/SkipsModelValidations
+
+ return unless unassigned_count.positive?
+
+ ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed!
end
end
diff --git a/app/jobs/data_imports/intercom/base_job.rb b/app/jobs/data_imports/intercom/base_job.rb
new file mode 100644
index 000000000..d4842c061
--- /dev/null
+++ b/app/jobs/data_imports/intercom/base_job.rb
@@ -0,0 +1,43 @@
+class DataImports::Intercom::BaseJob < ApplicationJob
+ queue_as :low
+
+ retry_on DataImports::Intercom::Client::Error, wait: 1.minute, attempts: 3 do |job, error|
+ job.fail_import!(error)
+ end
+
+ retry_on DataImports::Intercom::Client::RateLimitError, wait: 1.minute, attempts: 5 do |job, error|
+ job.fail_import!(error)
+ end
+
+ def fail_import!(error)
+ data_import = arguments.first
+ run_id = arguments.length > 1 ? arguments.last : nil
+ return if data_import.blank? || skip_import?(data_import, run_id)
+
+ DataImports::Intercom::Importer.new(data_import: data_import, run_id: run_id).fail!(error)
+ end
+
+ private
+
+ def skip_import?(data_import, run_id = nil)
+ data_import.reload
+ data_import.abandoned? || data_import.failed? || data_import.completed? ||
+ data_import.completed_with_errors? || stale_import_run?(data_import, run_id)
+ end
+
+ def stale_import_run?(data_import, run_id)
+ active_run_id = data_import.active_intercom_import_run_id
+ active_run_id.present? && active_run_id != run_id
+ end
+
+ def importer_for(data_import, run_id = nil)
+ DataImports::Intercom::Importer.new(data_import: data_import, run_id: run_id)
+ end
+
+ def fail_unexpected_error(importer, error)
+ raise error if error.is_a?(DataImports::Intercom::Client::Error)
+
+ importer&.fail!(error)
+ raise error
+ end
+end
diff --git a/app/jobs/data_imports/intercom/contacts_page_job.rb b/app/jobs/data_imports/intercom/contacts_page_job.rb
new file mode 100644
index 000000000..89f51a5b4
--- /dev/null
+++ b/app/jobs/data_imports/intercom/contacts_page_job.rb
@@ -0,0 +1,26 @@
+class DataImports::Intercom::ContactsPageJob < DataImports::Intercom::BaseJob
+ def perform(data_import, starting_after = nil, run_id = nil)
+ return if skip_import?(data_import, run_id)
+
+ importer = importer_for(data_import, run_id)
+ return enqueue_conversations_or_finish(data_import, importer, run_id) if importer.contacts_completed?
+
+ result = importer.import_contacts_page(starting_after: starting_after)
+ return if skip_import?(data_import, run_id)
+ return self.class.perform_later(data_import, result.next_cursor, run_id) unless result.done?
+
+ enqueue_conversations_or_finish(data_import, importer, run_id)
+ rescue StandardError => e
+ fail_unexpected_error(importer, e)
+ end
+
+ private
+
+ def enqueue_conversations_or_finish(data_import, importer, run_id)
+ if importer.import_conversations? && !importer.conversations_completed?
+ DataImports::Intercom::ConversationsPageJob.perform_later(data_import, importer.cursor_for('conversations'), run_id)
+ else
+ importer.finish!
+ end
+ end
+end
diff --git a/app/jobs/data_imports/intercom/conversations_page_job.rb b/app/jobs/data_imports/intercom/conversations_page_job.rb
new file mode 100644
index 000000000..8b8d92e6d
--- /dev/null
+++ b/app/jobs/data_imports/intercom/conversations_page_job.rb
@@ -0,0 +1,16 @@
+class DataImports::Intercom::ConversationsPageJob < DataImports::Intercom::BaseJob
+ def perform(data_import, starting_after = nil, run_id = nil)
+ return if skip_import?(data_import, run_id)
+
+ importer = importer_for(data_import, run_id)
+ return importer.finish! if importer.conversations_completed?
+
+ result = importer.import_conversations_page(starting_after: starting_after)
+ return if skip_import?(data_import, run_id)
+ return self.class.perform_later(data_import, result.next_cursor, run_id) unless result.done?
+
+ importer.finish!
+ rescue StandardError => e
+ fail_unexpected_error(importer, e)
+ end
+end
diff --git a/app/jobs/data_imports/intercom/import_job.rb b/app/jobs/data_imports/intercom/import_job.rb
new file mode 100644
index 000000000..1b054669c
--- /dev/null
+++ b/app/jobs/data_imports/intercom/import_job.rb
@@ -0,0 +1,24 @@
+class DataImports::Intercom::ImportJob < DataImports::Intercom::BaseJob
+ def perform(data_import, run_id = nil)
+ return if skip_import?(data_import, run_id)
+
+ importer = importer_for(data_import, run_id)
+ return unless importer.start!
+
+ enqueue_next_stage(data_import, importer, run_id)
+ rescue StandardError => e
+ fail_unexpected_error(importer, e)
+ end
+
+ private
+
+ def enqueue_next_stage(data_import, importer, run_id)
+ if importer.import_contacts? && !importer.contacts_completed?
+ DataImports::Intercom::ContactsPageJob.perform_later(data_import, importer.cursor_for('contacts'), run_id)
+ elsif importer.import_conversations? && !importer.conversations_completed?
+ DataImports::Intercom::ConversationsPageJob.perform_later(data_import, importer.cursor_for('conversations'), run_id)
+ else
+ importer.finish!
+ end
+ end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 0fa3e6659..4295b162e 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -7,6 +7,7 @@
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
+# feature_flags_ext_1 :bigint default(0), not null
# internal_attributes :jsonb not null
# limits :jsonb
# locale :integer default("en")
@@ -23,7 +24,7 @@
#
class Account < ApplicationRecord
- # used for single column multi flags
+ # used for multi-flag bitset columns
include FlagShihTzu
include Reportable
include Featurable
diff --git a/app/models/account_user.rb b/app/models/account_user.rb
index bbcb0e010..cdacb9b0e 100644
--- a/app/models/account_user.rb
+++ b/app/models/account_user.rb
@@ -39,6 +39,8 @@ class AccountUser < ApplicationRecord
after_create_commit :notify_creation, :create_notification_setting
after_destroy :notify_deletion, :remove_user_from_account
after_save :update_presence_in_redis, if: :saved_change_to_availability?
+ after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy]
+ after_update_commit :invalidate_filtered_unread_count_visibility_update, if: :filtered_unread_count_visibility_changed?
validates :user_id, uniqueness: { scope: :account_id }
@@ -79,6 +81,22 @@ class AccountUser < ApplicationRecord
def update_presence_in_redis
OnlineStatusTracker.set_status(account.id, user.id, availability)
end
+
+ def filtered_unread_count_visibility_changed?
+ previous_changes.key?('role') || previous_changes.key?('custom_role_id')
+ end
+
+ def invalidate_filtered_unread_count_visibility
+ ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).user_visibility_changed!(user_id: user_id)
+ end
+
+ def invalidate_filtered_unread_count_visibility_update
+ dispatch_account_cache_invalidated if invalidate_filtered_unread_count_visibility
+ end
+
+ def dispatch_account_cache_invalidated
+ Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, account: account, cache_keys: account.cache_keys)
+ end
end
AccountUser.prepend_mod_with('AccountUser')
diff --git a/app/models/assignment_policy.rb b/app/models/assignment_policy.rb
index 69b619581..12c46aa97 100644
--- a/app/models/assignment_policy.rb
+++ b/app/models/assignment_policy.rb
@@ -22,6 +22,8 @@
# index_assignment_policies_on_enabled (enabled)
#
class AssignmentPolicy < ApplicationRecord
+ DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168
+
belongs_to :account
has_many :inbox_assignment_policies, dependent: :destroy
has_many :inboxes, through: :inbox_assignment_policies
diff --git a/app/models/campaign.rb b/app/models/campaign.rb
index 1d4da7712..479b5bdd0 100644
--- a/app/models/campaign.rb
+++ b/app/models/campaign.rb
@@ -53,6 +53,7 @@ class Campaign < ApplicationRecord
before_validation :ensure_correct_campaign_attributes
after_commit :set_display_id, unless: :display_id?
+ after_destroy_commit :invalidate_filtered_unread_count_filters
def trigger!
return unless one_off?
@@ -88,6 +89,15 @@ class Campaign < ApplicationRecord
end
end
+ def invalidate_filtered_unread_count_filters
+ filters_changed = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed!
+ dispatch_account_cache_invalidated if filters_changed
+ end
+
+ def dispatch_account_cache_invalidated
+ Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, account: account, cache_keys: account.cache_keys)
+ end
+
def set_display_id
reload
end
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 2a558cd13..7a109c455 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -33,6 +33,7 @@ class Channel::Whatsapp < ApplicationRecord
validate :validate_provider_config
after_create :sync_templates
+ after_update_commit :log_credentials_transfer, if: :saved_change_to_provider_config?
before_destroy :teardown_webhooks
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
@@ -129,6 +130,15 @@ class Channel::Whatsapp < ApplicationRecord
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
+ # Logs only credential changes, so config-only saves (e.g. calling toggles) stay silent.
+ def log_credentials_transfer
+ before, after = saved_change_to_provider_config
+ keys = %w[api_key phone_number_id business_account_id]
+ return if before.nil? || before.values_at(*keys) == after.values_at(*keys)
+
+ Rails.logger.info("[WHATSAPP_MANUAL_TRANSFER] success account_id=#{account_id} channel_id=#{id}")
+ end
+
def perform_webhook_setup
webhook_setup_service.perform
end
diff --git a/app/models/concerns/account_cache_revalidator.rb b/app/models/concerns/account_cache_revalidator.rb
index b5ff5a473..d2bfaf294 100644
--- a/app/models/concerns/account_cache_revalidator.rb
+++ b/app/models/concerns/account_cache_revalidator.rb
@@ -6,6 +6,8 @@ module AccountCacheRevalidator
end
def update_account_cache
+ return if account.blank?
+
account.update_cache_key(self.class.name.underscore)
end
end
diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb
index daa0b4bf6..914df8449 100644
--- a/app/models/concerns/featurable.rb
+++ b/app/models/concerns/featurable.rb
@@ -1,6 +1,10 @@
module Featurable
extend ActiveSupport::Concern
+ DEFAULT_FEATURE_FLAG_COLUMN = 'feature_flags'.freeze
+ FEATURE_FLAG_COLUMNS = [DEFAULT_FEATURE_FLAG_COLUMN, 'feature_flags_ext_1'].freeze
+ MAX_FEATURES_PER_COLUMN = 63
+
QUERY_MODE = {
flag_query_mode: :bit_operator,
check_for_column: false
@@ -8,15 +12,62 @@ module Featurable
FEATURE_LIST = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
- FEATURES = FEATURE_LIST.each_with_object({}) do |feature, result|
- result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym
+ def self.feature_flag_mappings_for(feature_list)
+ features_by_column = feature_list.group_by { |feature| feature['column'].presence || DEFAULT_FEATURE_FLAG_COLUMN }
+
+ mappings = FEATURE_FLAG_COLUMNS.index_with do |column|
+ features = features_by_column.delete(column) || []
+ validate_feature_count!(column, features)
+
+ features.each_with_index.to_h do |feature, index|
+ [index + 1, "feature_#{feature['name']}".to_sym]
+ end
+ end
+
+ validate_feature_columns!(features_by_column)
+ mappings
end
+ def self.validate_feature_count!(column, features)
+ return if features.size <= MAX_FEATURES_PER_COLUMN
+
+ raise ArgumentError, "Account feature flag column #{column} supports up to #{MAX_FEATURES_PER_COLUMN} features"
+ end
+
+ def self.validate_feature_columns!(features_by_column)
+ return if features_by_column.blank?
+
+ invalid_columns = features_by_column.keys.join(', ')
+ raise ArgumentError, "Unknown account feature flag column: #{invalid_columns}"
+ end
+
+ FEATURES_BY_COLUMN = feature_flag_mappings_for(FEATURE_LIST).freeze
+
included do
include FlagShihTzu
- has_flags FEATURES.merge(column: 'feature_flags').merge(QUERY_MODE)
+
+ FEATURE_FLAG_COLUMNS.each do |column|
+ has_flags FEATURES_BY_COLUMN.fetch(column).merge(column: column).merge(QUERY_MODE)
+ end
before_create :enable_default_features
+
+ define_method :all_feature_flags do
+ FEATURE_FLAG_COLUMNS.flat_map { |column| all_flags(column) }
+ end
+
+ define_method :selected_feature_flags do
+ FEATURE_FLAG_COLUMNS.flat_map { |column| selected_flags(column) }
+ end
+
+ define_method :selected_feature_flags= do |chosen_flags|
+ FEATURE_FLAG_COLUMNS.each { |column| unselect_all_flags(column) }
+ return if chosen_flags.nil?
+
+ chosen_flags.each do |selected_flag|
+ enable_flag(selected_flag.to_sym) if selected_flag.present?
+ end
+ end
end
def enable_features(*names)
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index d9c06c4d8..1e9c38c41 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -62,6 +62,14 @@ class Conversation < ApplicationRecord
include PushDataHelper
include ConversationMuteHelpers
+ CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS = %w[conversation_language].freeze
+ FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS = %w[browser_language conversation_language mail_subject referer].freeze
+ FILTERED_UNREAD_COUNT_UPDATE_KEYS = %w[
+ cached_label_list campaign_id custom_attributes first_reply_created_at label_list last_activity_at priority snoozed_until waiting_since
+ ].freeze
+ private_constant :CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS, :FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS,
+ :FILTERED_UNREAD_COUNT_UPDATE_KEYS
+
validates :account_id, presence: true
validates :inbox_id, presence: true
validates :contact_id, presence: true
@@ -247,6 +255,7 @@ class Conversation < ApplicationRecord
handle_resolved_status_change
notify_status_change
create_activity
+ invalidate_filtered_unread_count_conversation
notify_conversation_updation
end
@@ -313,10 +322,23 @@ class Conversation < ApplicationRecord
end
def allowed_keys?
- (
- previous_changes.keys.intersect?(list_of_keys) ||
- (previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
- )
+ previous_changes.keys.intersect?(list_of_keys) ||
+ additional_attributes_changed?(CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS)
+ end
+
+ def invalidate_filtered_unread_count_conversation
+ return unless filtered_unread_count_update?
+
+ ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed!
+ end
+
+ def filtered_unread_count_update?
+ previous_changes.keys.intersect?(FILTERED_UNREAD_COUNT_UPDATE_KEYS) ||
+ additional_attributes_changed?(FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS)
+ end
+
+ def additional_attributes_changed?(keys)
+ Array(previous_changes['additional_attributes']).compact.any? { |attributes| attributes.keys.intersect?(keys) }
end
def load_attributes_created_by_db_triggers
diff --git a/app/models/conversation_participant.rb b/app/models/conversation_participant.rb
index 830eb7baa..4103deda4 100644
--- a/app/models/conversation_participant.rb
+++ b/app/models/conversation_participant.rb
@@ -28,6 +28,7 @@ class ConversationParticipant < ApplicationRecord
belongs_to :user
before_validation :ensure_account_id
+ after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy]
private
@@ -38,4 +39,8 @@ class ConversationParticipant < ApplicationRecord
def ensure_inbox_access
errors.add(:user, 'must have inbox access') if conversation && conversation.inbox.assignable_agents.exclude?(user)
end
+
+ def invalidate_filtered_unread_count_visibility
+ ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).user_visibility_changed!(user_id: user_id)
+ end
end
diff --git a/app/models/custom_attribute_definition.rb b/app/models/custom_attribute_definition.rb
index 35f822335..8270cc70e 100644
--- a/app/models/custom_attribute_definition.rb
+++ b/app/models/custom_attribute_definition.rb
@@ -48,6 +48,8 @@ class CustomAttributeDefinition < ApplicationRecord
belongs_to :account
after_update :update_widget_pre_chat_custom_fields, unless: :company_attribute?
after_destroy :sync_widget_pre_chat_custom_fields, unless: :company_attribute?
+ after_update_commit :invalidate_filtered_unread_count_filters_update, if: :conversation_attribute_before_or_after?
+ after_destroy_commit :invalidate_filtered_unread_count_filters_destroy, if: :conversation_attribute?
private
@@ -64,6 +66,27 @@ class CustomAttributeDefinition < ApplicationRecord
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
end
+ def invalidate_filtered_unread_count_filters_update
+ invalidate_filtered_unread_count_filters
+ end
+
+ def invalidate_filtered_unread_count_filters_destroy
+ invalidate_filtered_unread_count_filters
+ end
+
+ def invalidate_filtered_unread_count_filters
+ filters_changed = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).custom_attribute_definition_changed!(self)
+ dispatch_account_cache_invalidated if filters_changed
+ end
+
+ def dispatch_account_cache_invalidated
+ Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, account: account, cache_keys: account.cache_keys)
+ end
+
+ def conversation_attribute_before_or_after?
+ conversation_attribute? || attribute_model_previously_was == 'conversation_attribute'
+ end
+
def attribute_must_not_conflict
model_keys = attribute_model.to_s.delete_suffix('_attribute').to_sym
standard_attributes = STANDARD_ATTRIBUTES[model_keys]
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index 6d64c0447..6e2461a74 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -22,10 +22,31 @@ class CustomFilter < ApplicationRecord
enum filter_type: { conversation: 0, contact: 1, report: 2 }
validate :validate_number_of_filters
+ after_create_commit :invalidate_filtered_unread_count_create
+ after_update_commit :invalidate_filtered_unread_count_update
+ after_destroy_commit :invalidate_filtered_unread_count_destroy
def validate_number_of_filters
return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
+
+ private
+
+ def invalidate_filtered_unread_count_create
+ filtered_count_invalidator.custom_filter_created!(self)
+ end
+
+ def invalidate_filtered_unread_count_update
+ filtered_count_invalidator.custom_filter_updated!(self)
+ end
+
+ def invalidate_filtered_unread_count_destroy
+ filtered_count_invalidator.custom_filter_destroyed!(self)
+ end
+
+ def filtered_count_invalidator
+ ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account)
+ end
end
diff --git a/app/models/data_import.rb b/app/models/data_import.rb
index a44650a22..5f0ad4737 100644
--- a/app/models/data_import.rb
+++ b/app/models/data_import.rb
@@ -3,33 +3,115 @@
# Table name: data_imports
#
# id :bigint not null, primary key
+# abandoned_at :datetime
+# access_token :text
+# completed_at :datetime
+# cursor :jsonb not null
# data_type :string not null
+# import_types :jsonb not null
+# last_error_at :datetime
+# name :string
# processed_records :integer
# processing_errors :text
+# source_metadata :jsonb not null
+# source_provider :string
+# source_type :string
+# started_at :datetime
+# stats :jsonb not null
# status :integer default("pending"), not null
# total_records :integer
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
+# initiated_by_id :integer
#
# Indexes
#
-# index_data_imports_on_account_id (account_id)
+# index_data_imports_on_account_id (account_id)
+# index_data_imports_on_initiated_by_id (initiated_by_id)
+# index_data_imports_on_source_provider (source_provider)
#
class DataImport < ApplicationRecord
+ ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY = 'active_intercom_import_run_id'.freeze
+ LEGACY_DATA_TYPES = ['contacts'].freeze
+ INTEGRATION_DATA_TYPES = ['intercom'].freeze
+ IMPORT_TYPES = %w[contacts conversations].freeze
+
belongs_to :account
- validates :data_type, inclusion: { in: ['contacts'], message: I18n.t('errors.data_import.data_type.invalid') }
- enum status: { pending: 0, processing: 1, completed: 2, failed: 3 }
+ belongs_to :initiated_by, class_name: 'User', optional: true
+
+ encrypts :access_token if Chatwoot.encryption_configured?
+
+ has_many :items, class_name: 'DataImportItem', dependent: :destroy_async
+ has_many :mappings, class_name: 'DataImportMapping', dependent: :destroy_async
+ has_many :import_errors, class_name: 'DataImportError', dependent: :destroy_async
+
+ validates :data_type, inclusion: { in: LEGACY_DATA_TYPES + INTEGRATION_DATA_TYPES, message: I18n.t('errors.data_import.data_type.invalid') }
+ validates :access_token, presence: true, on: :create, if: :intercom_import?
+ validate :validate_import_types
+
+ enum status: { pending: 0, processing: 1, completed: 2, failed: 3, completed_with_errors: 6, abandoned: 7 }
+
+ scope :active_intercom, -> { where(data_type: 'intercom', source_provider: 'intercom', status: [:pending, :processing]) }
has_one_attached :import_file
has_one_attached :failed_records
after_create_commit :process_data_import
+ def legacy_contacts_csv_import?
+ data_type == 'contacts' && source_provider.blank?
+ end
+
+ def intercom_import?
+ data_type == 'intercom' && source_provider == 'intercom'
+ end
+
+ def restartable?
+ failed? || abandoned?
+ end
+
+ def abandonable?
+ intercom_import? && (pending? || processing?)
+ end
+
+ def abandon!
+ self.class.transaction do
+ abandonable_import = self.class.lock.find_by(
+ id: id,
+ data_type: 'intercom',
+ source_provider: 'intercom',
+ status: [:pending, :processing]
+ )
+ abandonable_import&.update!(status: :abandoned, abandoned_at: Time.current)
+ end
+ reload
+ end
+
+ def active_intercom_import_run_id
+ source_metadata.to_h[ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY]
+ end
+
+ def assign_active_intercom_import_run_id
+ self.source_metadata = source_metadata.to_h.merge(ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => SecureRandom.uuid)
+ active_intercom_import_run_id
+ end
+
private
def process_data_import
+ return unless legacy_contacts_csv_import?
+
# we wait for the file to be uploaded to the cloud
DataImportJob.set(wait: 1.minute).perform_later(self)
end
+
+ def validate_import_types
+ return if import_types.blank?
+
+ invalid_types = import_types - IMPORT_TYPES
+ return if invalid_types.blank?
+
+ errors.add(:import_types, "contains unsupported values: #{invalid_types.join(', ')}")
+ end
end
diff --git a/app/models/data_import_error.rb b/app/models/data_import_error.rb
new file mode 100644
index 000000000..663050caa
--- /dev/null
+++ b/app/models/data_import_error.rb
@@ -0,0 +1,33 @@
+# == Schema Information
+#
+# Table name: data_import_errors
+#
+# id :bigint not null, primary key
+# details :jsonb not null
+# error_code :string not null
+# message :text
+# source_object_type :string
+# created_at :datetime not null
+# updated_at :datetime not null
+# data_import_id :bigint not null
+# data_import_item_id :bigint
+# source_object_id :string
+#
+# Indexes
+#
+# idx_data_import_errors_on_source (source_object_type,source_object_id)
+# index_data_import_errors_on_data_import_id (data_import_id)
+# index_data_import_errors_on_data_import_item_id (data_import_item_id)
+#
+class DataImportError < ApplicationRecord
+ SKIP_LOG_KINDS = %w[failed skipped].freeze
+
+ belongs_to :data_import
+ belongs_to :data_import_item, optional: true
+
+ validates :error_code, presence: true
+
+ scope :skip_logs, -> { where("details ->> 'kind' IN (:kinds)", kinds: SKIP_LOG_KINDS) }
+ scope :failed, -> { where("details ->> 'kind' = ?", 'failed') }
+ scope :non_skip_logs, -> { where("details ->> 'kind' IS NULL OR details ->> 'kind' NOT IN (:kinds)", kinds: SKIP_LOG_KINDS) }
+end
diff --git a/app/models/data_import_item.rb b/app/models/data_import_item.rb
new file mode 100644
index 000000000..1ac9c2812
--- /dev/null
+++ b/app/models/data_import_item.rb
@@ -0,0 +1,35 @@
+# == Schema Information
+#
+# Table name: data_import_items
+#
+# id :bigint not null, primary key
+# attempt_count :integer default(0), not null
+# chatwoot_record_type :string
+# last_error_code :string
+# last_error_message :text
+# metadata :jsonb not null
+# source_object_type :string not null
+# source_provider :string not null
+# status :integer default("pending"), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# chatwoot_record_id :bigint
+# data_import_id :bigint not null
+# source_object_id :string not null
+#
+# Indexes
+#
+# idx_data_import_items_on_import_and_source (data_import_id,source_object_type,source_object_id) UNIQUE
+# idx_data_import_items_on_record (chatwoot_record_type,chatwoot_record_id)
+# idx_data_import_items_on_source (source_provider,source_object_type,source_object_id)
+# index_data_import_items_on_data_import_id (data_import_id)
+#
+class DataImportItem < ApplicationRecord
+ belongs_to :data_import
+ has_many :import_errors, class_name: 'DataImportError', dependent: :destroy_async
+
+ validates :source_provider, :source_object_type, :source_object_id, presence: true
+ validates :source_object_id, uniqueness: { scope: [:data_import_id, :source_object_type] }
+
+ enum status: { pending: 0, processing: 1, imported: 2, skipped: 3, failed: 4 }
+end
diff --git a/app/models/data_import_mapping.rb b/app/models/data_import_mapping.rb
new file mode 100644
index 000000000..29da663f0
--- /dev/null
+++ b/app/models/data_import_mapping.rb
@@ -0,0 +1,33 @@
+# == Schema Information
+#
+# Table name: data_import_mappings
+#
+# id :bigint not null, primary key
+# chatwoot_record_type :string not null
+# metadata :jsonb not null
+# source_object_type :string not null
+# source_provider :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :integer not null
+# chatwoot_record_id :bigint not null
+# data_import_id :bigint not null
+# source_object_id :string not null
+#
+# Indexes
+#
+# idx_data_import_mappings_on_account_and_source (account_id,source_provider,source_object_type,source_object_id) UNIQUE
+# idx_data_import_mappings_on_record (chatwoot_record_type,chatwoot_record_id)
+# index_data_import_mappings_on_data_import_id (data_import_id)
+#
+class DataImportMapping < ApplicationRecord
+ belongs_to :data_import
+ belongs_to :account
+
+ validates :source_provider, :source_object_type, :source_object_id, :chatwoot_record_type, :chatwoot_record_id, presence: true
+ validates :source_object_id, uniqueness: { scope: [:account_id, :source_provider, :source_object_type] }
+
+ def chatwoot_record
+ chatwoot_record_type.constantize.find_by(id: chatwoot_record_id)
+ end
+end
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index 15bfe77dd..1b0cfc587 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -77,10 +77,12 @@ class Inbox < ApplicationRecord
enum sender_name_type: { friendly: 0, professional: 1 }
+ before_destroy :capture_filtered_unread_count_user_ids, prepend: true
after_destroy :delete_round_robin_agents
after_create_commit :dispatch_create_event
after_update_commit :dispatch_update_event
+ after_destroy_commit :invalidate_filtered_unread_counts_after_destroy
scope :order_by_name, -> { order('lower(name) ASC') }
@@ -261,6 +263,18 @@ class Inbox < ApplicationRecord
::AutoAssignment::InboxRoundRobinService.new(inbox: self).clear_queue
end
+ def capture_filtered_unread_count_user_ids
+ return if account.blank?
+
+ @filtered_unread_count_user_ids = (inbox_members.pluck(:user_id) + account.account_users.administrator.pluck(:user_id)).uniq
+ end
+
+ def invalidate_filtered_unread_counts_after_destroy
+ invalidator = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account)
+ invalidator.conversation_changed!
+ invalidator.users_visibility_changed!(user_ids: @filtered_unread_count_user_ids)
+ end
+
def check_channel_type?
['Channel::Email', 'Channel::Api', 'Channel::WebWidget'].include?(channel_type)
end
diff --git a/app/models/inbox_member.rb b/app/models/inbox_member.rb
index d4a36ccc8..bc4014da9 100644
--- a/app/models/inbox_member.rb
+++ b/app/models/inbox_member.rb
@@ -24,6 +24,7 @@ class InboxMember < ApplicationRecord
after_create :add_agent_to_round_robin
after_destroy :remove_agent_from_round_robin
+ after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy]
private
@@ -34,6 +35,10 @@ class InboxMember < ApplicationRecord
def remove_agent_from_round_robin
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).remove_agent_from_queue(user_id) if inbox.present?
end
+
+ def invalidate_filtered_unread_count_visibility
+ ::Conversations::UnreadCounts::FilteredCountInvalidator.new(inbox&.account).user_visibility_changed!(user_id: user_id)
+ end
end
InboxMember.include_mod_with('Audit::InboxMember')
diff --git a/app/models/message.rb b/app/models/message.rb
index f25d2e112..220bdd549 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -34,7 +34,7 @@
# index_messages_on_conversation_id (conversation_id)
# index_messages_on_created_at (created_at)
# index_messages_on_inbox_id (inbox_id)
-# index_messages_on_sender_type_and_sender_id (sender_type,sender_id)
+# index_messages_on_sender_and_created (sender_type,sender_id,created_at)
# index_messages_on_source_id (source_id)
#
diff --git a/app/models/team.rb b/app/models/team.rb
index 48990b488..b19f215ca 100644
--- a/app/models/team.rb
+++ b/app/models/team.rb
@@ -25,12 +25,15 @@ class Team < ApplicationRecord
has_many :members, through: :team_members, source: :user
has_many :conversations, dependent: :nullify
+ before_destroy :capture_filtered_unread_count_member_ids, prepend: true
+ after_destroy_commit :invalidate_filtered_unread_counts_after_destroy
+
validates :name,
presence: { message: I18n.t('errors.validations.presence') },
uniqueness: { scope: :account_id }
before_validation do
- self.name = name.downcase if attribute_present?('name')
+ self.name = name.gsub(/[[:cntrl:]]/, '').strip.downcase if attribute_present?('name')
end
# Adds multiple members to the team
@@ -69,6 +72,18 @@ class Team < ApplicationRecord
icon_color: icon_color
}
end
+
+ private
+
+ def capture_filtered_unread_count_member_ids
+ @filtered_unread_count_member_ids = team_members.pluck(:user_id)
+ end
+
+ def invalidate_filtered_unread_counts_after_destroy
+ invalidator = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account)
+ invalidator.conversation_changed!
+ invalidator.users_visibility_changed!(user_ids: @filtered_unread_count_member_ids)
+ end
end
Team.include_mod_with('Audit::Team')
diff --git a/app/models/team_member.rb b/app/models/team_member.rb
index f99af264b..0d0e0aa22 100644
--- a/app/models/team_member.rb
+++ b/app/models/team_member.rb
@@ -18,6 +18,14 @@ class TeamMember < ApplicationRecord
belongs_to :user
belongs_to :team
validates :user_id, uniqueness: { scope: :team_id }
+
+ after_commit :invalidate_filtered_unread_count_visibility, on: [:create, :destroy]
+
+ private
+
+ def invalidate_filtered_unread_count_visibility
+ ::Conversations::UnreadCounts::FilteredCountInvalidator.new(team&.account).user_visibility_changed!(user_id: user_id)
+ end
end
TeamMember.include_mod_with('Audit::TeamMember')
diff --git a/app/policies/data_import_policy.rb b/app/policies/data_import_policy.rb
new file mode 100644
index 000000000..3f908ca5d
--- /dev/null
+++ b/app/policies/data_import_policy.rb
@@ -0,0 +1,41 @@
+class DataImportPolicy < ApplicationPolicy
+ def index?
+ @account_user.administrator?
+ end
+
+ def show?
+ @account_user.administrator? && record.account_id == account.id
+ end
+
+ def create?
+ @account_user.administrator?
+ end
+
+ def validate_source?
+ create?
+ end
+
+ def start?
+ show?
+ end
+
+ def abandon?
+ show?
+ end
+
+ def skip_logs?
+ show?
+ end
+
+ def error_logs?
+ show?
+ end
+
+ class Scope < Scope
+ def resolve
+ return scope.where(account_id: account.id) if account_user.administrator?
+
+ scope.none
+ end
+ end
+end
diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb
index c4c494d57..b29f0ca02 100644
--- a/app/services/auto_assignment/assignment_service.rb
+++ b/app/services/auto_assignment/assignment_service.rb
@@ -35,9 +35,9 @@ class AutoAssignment::AssignmentService
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
- # Skip stale backlog with no activity beyond the policy's age threshold (defaults to 7 days)
+ # Skip stale backlog with no activity beyond the age threshold
policy = inbox.assignment_policy
- scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
+ scope = apply_age_exclusions(scope, age_exclusion_hours(policy))
# Apply conversation priority using assignment policy if available
scope = if policy&.longest_waiting?
@@ -49,6 +49,12 @@ class AutoAssignment::AssignmentService
scope.limit(limit)
end
+ def age_exclusion_hours(policy)
+ return policy.exclude_older_than_hours if policy
+
+ AssignmentPolicy::DEFAULT_EXCLUDE_OLDER_THAN_HOURS
+ end
+
def apply_age_exclusions(scope, hours_threshold)
return scope if hours_threshold.blank?
diff --git a/app/services/conversations/unread_counts.rb b/app/services/conversations/unread_counts.rb
index 668395063..e00f8357d 100644
--- a/app/services/conversations/unread_counts.rb
+++ b/app/services/conversations/unread_counts.rb
@@ -1,4 +1,10 @@
module Conversations::UnreadCounts
READY_TTL = 24.hours.to_i
SET_TTL = 25.hours.to_i
+ FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i
+ FILTERED_COUNT_STALE_WINDOW = 1.hour.to_i
+ FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW
+ FILTERED_COUNT_VERSION_TTL = SET_TTL
+ FILTERED_COUNT_MIN_REFRESH_INTERVAL = 5.minutes.to_i
+ MAX_INLINE_FILTER_BUILDS = 3
end
diff --git a/app/services/conversations/unread_counts/counter.rb b/app/services/conversations/unread_counts/counter.rb
index b1ba5ddb0..2a015f3a6 100644
--- a/app/services/conversations/unread_counts/counter.rb
+++ b/app/services/conversations/unread_counts/counter.rb
@@ -14,7 +14,7 @@ class Conversations::UnreadCounts::Counter
end
def perform
- return empty_counts if permission_mode == :none
+ return with_filtered_counts(empty_counts, build: false) if permission_mode == :none
ensure_base_cache!
ensure_assignment_cache! if assignment_mode?
@@ -26,11 +26,17 @@ class Conversations::UnreadCounts::Counter
inboxes: inbox_counts,
labels: unread_label_counts,
teams: unread_team_counts
- }
+ }.then { |counts| with_filtered_counts(counts) }
end
private
+ def with_filtered_counts(counts, build: true)
+ return counts unless account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
+
+ counts.merge(build ? filtered_counter.perform : ::Conversations::UnreadCounts::FilteredCounter.empty_counts)
+ end
+
def ensure_base_cache!
ensure_cache_ready!(
ready: -> { store.base_ready?(account.id) },
@@ -200,4 +206,8 @@ class Conversations::UnreadCounts::Counter
def store
::Conversations::UnreadCounts::Store
end
+
+ def filtered_counter
+ @filtered_counter ||= ::Conversations::UnreadCounts::FilteredCounter.new(account: account, user: user)
+ end
end
diff --git a/app/services/conversations/unread_counts/filter_query_counter.rb b/app/services/conversations/unread_counts/filter_query_counter.rb
new file mode 100644
index 000000000..238bed2a1
--- /dev/null
+++ b/app/services/conversations/unread_counts/filter_query_counter.rb
@@ -0,0 +1,151 @@
+class Conversations::UnreadCounts::FilterQueryCounter < Conversations::FilterService
+ BOOLEAN_VALUES = %w[0 1 false f n no off on t true y yes].freeze
+ DATABASE_CAST_ERROR_CLASS_NAMES = %w[
+ PG::DatetimeFieldOverflow
+ PG::InvalidDatetimeFormat
+ PG::InvalidTextRepresentation
+ PG::NumericValueOutOfRange
+ ].freeze
+ DAYS_BEFORE_FILTER_OPERATOR = 'days_before'.freeze
+ MALFORMED_QUERY_ERRORS = [NoMethodError, TypeError].freeze
+ NUMERIC_ATTRIBUTE_KEYS = %w[assignee_id inbox_id].freeze
+ TEXT_DATA_TYPES = %w[labels link text text_case_insensitive].freeze
+ TEXT_FILTER_OPERATORS = %w[contains does_not_contain].freeze
+ TYPED_DATA_TYPES = %w[boolean date number numeric].freeze
+ VALID_QUERY_OPERATORS = %w[AND OR].freeze
+ VALIDATION_DATA_TYPES = (TEXT_DATA_TYPES + TYPED_DATA_TYPES).freeze
+ VALUELESS_FILTER_OPERATORS = %w[is_present is_not_present].freeze
+
+ def initialize(account:, user:, query:)
+ super(query.with_indifferent_access, user, account)
+ end
+
+ def perform
+ return unless valid_query?
+ return unless valid_typed_values?
+
+ validate_query_operator
+ query_builder(@filters['conversations']).count
+ rescue *MALFORMED_QUERY_ERRORS
+ nil
+ rescue ActiveRecord::StatementInvalid => e
+ raise unless database_cast_error?(e)
+
+ nil
+ end
+
+ def base_relation
+ Conversations::PermissionFilterService.new(unread_conversations, @user, @account).perform
+ end
+
+ private
+
+ def valid_query?
+ @params[:payload].is_a?(Array) && valid_query_operator_positions?
+ end
+
+ def database_cast_error?(error)
+ DATABASE_CAST_ERROR_CLASS_NAMES.include?(error.cause&.class&.name)
+ end
+
+ def valid_query_operator_positions?
+ @params[:payload].each_with_index.all? do |query_hash, index|
+ query_operator_position_valid?(query_hash[:query_operator], last_query?(index))
+ end
+ end
+
+ def query_operator_position_valid?(query_operator, last_query)
+ return query_operator.blank? if last_query
+
+ VALID_QUERY_OPERATORS.include?(query_operator.to_s.upcase)
+ end
+
+ def last_query?(index)
+ index == @params[:payload].length - 1
+ end
+
+ def valid_typed_values?
+ @params[:payload].all? do |query_hash|
+ next true if VALUELESS_FILTER_OPERATORS.include?(query_hash[:filter_operator])
+
+ data_type = validation_data_type(query_hash)
+ next true if data_type.blank?
+ next false if text_filter_operator?(query_hash) && TYPED_DATA_TYPES.include?(data_type)
+
+ valid_typed_values_for?(query_hash[:values], data_type, query_hash[:filter_operator])
+ end
+ end
+
+ def validation_data_type(query_hash)
+ attribute_key = query_hash[:attribute_key]
+ data_type = filter_data_type(query_hash)
+
+ return nil if text_search_on_display_id?(query_hash)
+ return 'number' if NUMERIC_ATTRIBUTE_KEYS.include?(attribute_key)
+ return data_type if VALIDATION_DATA_TYPES.include?(data_type)
+
+ nil
+ end
+
+ def filter_data_type(query_hash)
+ attribute_key = query_hash[:attribute_key]
+ data_type = @filters.dig('conversations', attribute_key, 'data_type')
+ return data_type.to_s.downcase if data_type.present?
+
+ custom_attribute_data_type(query_hash)
+ end
+
+ def custom_attribute_data_type(query_hash)
+ custom_attribute_type = query_hash[:custom_attribute_type].presence || self.class::ATTRIBUTE_MODEL
+ custom_attribute = @account.custom_attribute_definitions.where(
+ attribute_model: custom_attribute_type
+ ).find_by(attribute_key: query_hash[:attribute_key])
+
+ self.class::ATTRIBUTE_TYPES[custom_attribute&.attribute_display_type].to_s
+ end
+
+ def valid_typed_values_for?(values, data_type, filter_operator)
+ Array.wrap(values).all? do |value|
+ valid_typed_value?(value, data_type, filter_operator)
+ end
+ end
+
+ def text_filter_operator?(query_hash)
+ TEXT_FILTER_OPERATORS.include?(query_hash[:filter_operator])
+ end
+
+ def valid_typed_value?(value, data_type, filter_operator)
+ case data_type
+ when 'boolean'
+ BOOLEAN_VALUES.include?(value.to_s.downcase)
+ when 'date'
+ return Integer(value.to_s, exception: false).present? if filter_operator == DAYS_BEFORE_FILTER_OPERATOR
+
+ Date.iso8601(value.to_s).present?
+ when 'numeric'
+ BigDecimal(value.to_s, exception: false).present?
+ when *TEXT_DATA_TYPES
+ value.is_a?(String)
+ else
+ Integer(value.to_s, exception: false).present?
+ end
+ rescue ArgumentError
+ false
+ end
+
+ def unread_conversations
+ @account.conversations
+ .joins(:messages)
+ .merge(Message.incoming.reorder(nil))
+ .where(messages: { account_id: @account.id })
+ .where(unread_since_last_seen_condition)
+ .distinct
+ end
+
+ def unread_since_last_seen_condition
+ conversations = Conversation.arel_table
+ messages = Message.arel_table
+
+ conversations[:agent_last_seen_at].eq(nil).or(messages[:created_at].gt(conversations[:agent_last_seen_at]))
+ end
+end
diff --git a/app/services/conversations/unread_counts/filtered_count_instrumentation.rb b/app/services/conversations/unread_counts/filtered_count_instrumentation.rb
new file mode 100644
index 000000000..707c63f33
--- /dev/null
+++ b/app/services/conversations/unread_counts/filtered_count_instrumentation.rb
@@ -0,0 +1,188 @@
+class Conversations::UnreadCounts::FilteredCountInstrumentation
+ # Centralizes the rollout-critical filtered unread count signals:
+ # API response duration, counter duration, snapshot build duration, snapshot state distribution,
+ # refresh claim rate, build lock acquisition rate, and invalidation/version bump rate.
+ EVENT_NAME = 'FilteredUnreadCounts'.freeze
+ METRIC_PREFIX = 'Custom/Conversations/UnreadCounts/Filtered'.freeze
+ SUMMARY_KEY = :filtered_unread_counts_request_summary
+ AGGREGATED_INCREMENT_OPERATIONS = %i[snapshot_state refresh_claim build_lock].freeze
+ SNAPSHOT_STATUSES = %i[fresh stale missing expired].freeze
+ SNAPSHOT_SCOPES = %i[built_in_filter folder_index filter].freeze
+ SUMMARY_DEFAULTS = begin
+ defaults = {
+ snapshot_total_count: 0,
+ refresh_claimed_count: 0,
+ refresh_skipped_count: 0,
+ build_lock_acquired_count: 0,
+ build_lock_missed_count: 0,
+ snapshot_build_success_count: 0,
+ snapshot_build_error_count: 0
+ }
+
+ SNAPSHOT_STATUSES.each { |status| defaults[:"snapshot_#{status}_count"] = 0 }
+ SNAPSHOT_SCOPES.each do |scope|
+ defaults[:"#{scope}_snapshot_count"] = 0
+ defaults[:"#{scope}_refresh_claimed_count"] = 0
+ defaults[:"#{scope}_refresh_skipped_count"] = 0
+ defaults[:"#{scope}_build_lock_acquired_count"] = 0
+ defaults[:"#{scope}_build_lock_missed_count"] = 0
+ defaults[:"#{scope}_snapshot_build_success_count"] = 0
+ defaults[:"#{scope}_snapshot_build_error_count"] = 0
+ end
+
+ defaults.freeze
+ end
+ private_constant :SUMMARY_KEY, :AGGREGATED_INCREMENT_OPERATIONS, :SNAPSHOT_STATUSES, :SNAPSHOT_SCOPES, :SUMMARY_DEFAULTS
+
+ class << self
+ def summarize_request(account_id:)
+ previous_summary = current_summary
+ summary = request_summary(account_id)
+ Thread.current[SUMMARY_KEY] = summary
+ started_at = monotonic_time
+ status = :success
+
+ yield
+ rescue StandardError => e
+ status = :error
+ summary[:error_class] = e.class.name
+ raise
+ ensure
+ record_request_summary(summary, status, started_at)
+ Thread.current[SUMMARY_KEY] = previous_summary
+ end
+
+ def observe(operation, attributes = {})
+ started_at = monotonic_time
+
+ yield.tap do
+ record_observation(operation, attributes, started_at, status: :success)
+ end
+ rescue StandardError => e
+ record_observation(operation, attributes.merge(error_class: e.class.name), started_at, status: :error)
+ raise
+ end
+
+ def increment(operation, attributes = {})
+ record_increment_summary(operation, attributes) if aggregated_increment?(operation)
+ record_event(operation, attributes) unless aggregated_increment?(operation)
+ record_metric("#{metric_name(operation)}/count", 1)
+ end
+
+ def record_event(operation, attributes = {})
+ agent = new_relic_agent
+ return unless agent.respond_to?(:record_custom_event)
+
+ agent.record_custom_event(EVENT_NAME, sanitized_attributes(attributes.merge(operation: operation)))
+ rescue StandardError
+ nil
+ end
+
+ private
+
+ def record_observation(operation, attributes, started_at, status:)
+ duration_ms = elapsed_ms_since(started_at)
+ record_observation_summary(operation, attributes, status: status)
+ record_metric("#{metric_name(operation)}/duration_ms", duration_ms)
+ end
+
+ def record_request_summary(summary, status, started_at)
+ duration_ms = elapsed_ms_since(started_at)
+ summary[:status] = status
+ summary[:duration_ms] = duration_ms
+ record_metric("#{metric_name(:api_response)}/duration_ms", duration_ms)
+ record_event(:request_summary, summary)
+ end
+
+ def record_metric(name, value)
+ agent = new_relic_agent
+ return unless agent.respond_to?(:record_metric)
+
+ agent.record_metric(name, value)
+ rescue StandardError
+ nil
+ end
+
+ def metric_name(operation)
+ "#{METRIC_PREFIX}/#{operation}"
+ end
+
+ def request_summary(account_id)
+ SUMMARY_DEFAULTS.dup.merge(account_id: account_id)
+ end
+
+ def current_summary
+ Thread.current[SUMMARY_KEY]
+ end
+
+ def aggregated_increment?(operation)
+ operation.in?(AGGREGATED_INCREMENT_OPERATIONS)
+ end
+
+ def record_increment_summary(operation, attributes)
+ case operation
+ when :snapshot_state
+ record_snapshot_state_summary(attributes)
+ when :refresh_claim
+ result = attributes[:claimed] ? :claimed : :skipped
+ increment_summary_count(:"refresh_#{result}_count")
+ increment_scoped_summary_count(attributes[:snapshot_scope], :"refresh_#{result}_count")
+ when :build_lock
+ result = attributes[:acquired] ? :acquired : :missed
+ increment_summary_count(:"build_lock_#{result}_count")
+ increment_scoped_summary_count(attributes[:snapshot_scope], :"build_lock_#{result}_count")
+ end
+ end
+
+ def record_snapshot_state_summary(attributes)
+ increment_summary_count(:snapshot_total_count)
+ increment_summary_count(:"snapshot_#{attributes[:snapshot_status]}_count")
+ increment_scoped_summary_count(attributes[:snapshot_scope], :snapshot_count)
+ end
+
+ def record_observation_summary(operation, attributes, status:)
+ return unless operation == :snapshot_build
+
+ result = status == :success ? :success : :error
+ increment_summary_count(:"snapshot_build_#{result}_count")
+ increment_scoped_summary_count(attributes[:snapshot_scope], :"snapshot_build_#{result}_count")
+ end
+
+ def increment_scoped_summary_count(scope, suffix)
+ return if scope.blank?
+
+ increment_summary_count(:"#{scope}_#{suffix}")
+ end
+
+ def increment_summary_count(key)
+ return if current_summary.blank?
+
+ current_summary[key] = current_summary.fetch(key, 0) + 1
+ end
+
+ def sanitized_attributes(attributes)
+ attributes.compact.transform_values do |value|
+ case value
+ when String, Integer, Float, TrueClass, FalseClass
+ value
+ else
+ value.to_s
+ end
+ end
+ end
+
+ def elapsed_ms_since(started_at)
+ ((monotonic_time - started_at) * 1000).round(2)
+ end
+
+ def monotonic_time
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ end
+
+ def new_relic_agent
+ return unless defined?(::NewRelic::Agent)
+
+ ::NewRelic::Agent
+ end
+ end
+end
diff --git a/app/services/conversations/unread_counts/filtered_count_invalidator.rb b/app/services/conversations/unread_counts/filtered_count_invalidator.rb
new file mode 100644
index 000000000..6546bb58f
--- /dev/null
+++ b/app/services/conversations/unread_counts/filtered_count_invalidator.rb
@@ -0,0 +1,184 @@
+class Conversations::UnreadCounts::FilteredCountInvalidator
+ FEATURE_FLAG = 'unread_count_for_filters'.freeze
+
+ attr_reader :account
+
+ def initialize(account)
+ @account = account
+ end
+
+ def conversation_changed!
+ return false unless enabled?
+
+ version = store.bump_conversation_version!(account.id)
+ record_invalidation(:conversation, reason: :conversation_changed, version: version)
+ true
+ end
+
+ def user_visibility_changed!(user_id:)
+ return false unless enabled? && user_id.present?
+
+ version = store.bump_built_in_filter_version!(account_id: account.id, user_id: user_id)
+ record_invalidation(:built_in_filter, reason: :user_visibility_changed, version: version)
+ true
+ end
+
+ def users_visibility_changed!(user_ids:)
+ return false unless enabled?
+
+ user_ids = Array(user_ids).compact_blank.uniq
+ return false if user_ids.blank?
+
+ bump_built_in_filter_versions(user_ids).each_value do |version|
+ record_invalidation(:built_in_filter, reason: :user_visibility_changed, version: version)
+ end
+ true
+ end
+
+ def custom_filter_created!(custom_filter)
+ return false unless conversation_filter?(custom_filter)
+
+ bump_folder_index_version!(custom_filter, reason: :custom_filter_created)
+ bump_filter_version!(custom_filter, reason: :custom_filter_created)
+ true
+ end
+
+ def custom_filter_updated!(custom_filter)
+ return false unless enabled? && conversation_filter_before_or_after?(custom_filter)
+
+ filter_type_changed = filter_type_changed?(custom_filter)
+ query_changed = query_changed?(custom_filter)
+ return false unless filter_type_changed || query_changed
+
+ bump_folder_index_version!(custom_filter, reason: :custom_filter_updated) if filter_type_changed
+ bump_filter_version!(custom_filter, reason: :custom_filter_updated)
+ store.delete_filter_count!(account_id: account.id, filter_id: custom_filter.id) if moved_out_of_conversation_filters?(custom_filter)
+ true
+ end
+
+ def custom_filter_destroyed!(custom_filter)
+ return false unless conversation_filter?(custom_filter)
+
+ bump_folder_index_version!(custom_filter, reason: :custom_filter_destroyed)
+ store.delete_filter_count!(account_id: account.id, filter_id: custom_filter.id)
+ true
+ end
+
+ def custom_attribute_definition_changed!(custom_attribute_definition)
+ return false unless enabled? && conversation_attribute_before_or_after?(custom_attribute_definition)
+
+ affected_filters = affected_custom_attribute_filters(custom_attribute_definition)
+ return false if affected_filters.blank?
+
+ affected_filters.each { |custom_filter| bump_filter_version!(custom_filter, reason: :custom_attribute_definition_changed) }
+ true
+ end
+
+ private
+
+ def bump_built_in_filter_versions(user_ids)
+ results = Redis::Alfred.pipelined do |pipeline|
+ user_ids.each do |user_id|
+ key = store.built_in_filter_version_key(account.id, user_id)
+ pipeline.incr(key)
+ pipeline.expire(key, Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL)
+ end
+ end
+
+ user_ids.zip(results.each_slice(2).map(&:first)).to_h
+ end
+
+ def enabled?
+ account&.feature_enabled?(FEATURE_FLAG)
+ end
+
+ def conversation_filter?(custom_filter)
+ enabled? && custom_filter.conversation?
+ end
+
+ def conversation_filter_before_or_after?(custom_filter)
+ custom_filter.conversation? || previous_filter_type(custom_filter) == 'conversation'
+ end
+
+ def moved_out_of_conversation_filters?(custom_filter)
+ filter_type_changed?(custom_filter) && previous_filter_type(custom_filter) == 'conversation' && !custom_filter.conversation?
+ end
+
+ def filter_type_changed?(custom_filter)
+ custom_filter.previous_changes.key?('filter_type')
+ end
+
+ def query_changed?(custom_filter)
+ custom_filter.previous_changes.key?('query')
+ end
+
+ def previous_filter_type(custom_filter)
+ raw_filter_type = custom_filter.previous_changes.dig('filter_type', 0)
+ return if raw_filter_type.blank?
+ return raw_filter_type if CustomFilter.filter_types.key?(raw_filter_type)
+ return CustomFilter.filter_types.key(raw_filter_type) if raw_filter_type.is_a?(Integer)
+
+ CustomFilter.filter_types.key(raw_filter_type.to_i) || raw_filter_type.to_s
+ end
+
+ def conversation_attribute_before_or_after?(custom_attribute_definition)
+ custom_attribute_definition.conversation_attribute? || previous_attribute_model(custom_attribute_definition) == 'conversation_attribute'
+ end
+
+ def previous_attribute_model(custom_attribute_definition)
+ raw_attribute_model = custom_attribute_definition.previous_changes.dig('attribute_model', 0)
+ return if raw_attribute_model.blank?
+ return raw_attribute_model if CustomAttributeDefinition.attribute_models.key?(raw_attribute_model)
+ return CustomAttributeDefinition.attribute_models.key(raw_attribute_model) if raw_attribute_model.is_a?(Integer)
+
+ CustomAttributeDefinition.attribute_models.key(raw_attribute_model.to_i) || raw_attribute_model.to_s
+ end
+
+ def affected_custom_attribute_filters(custom_attribute_definition)
+ attribute_keys = custom_attribute_keys(custom_attribute_definition)
+ account.custom_filters.conversation.select do |custom_filter|
+ custom_filter_references_conversation_attribute?(custom_filter, attribute_keys)
+ end
+ end
+
+ def custom_attribute_keys(custom_attribute_definition)
+ [custom_attribute_definition.attribute_key, custom_attribute_definition.previous_changes.dig('attribute_key', 0)].compact_blank.map(&:to_s).uniq
+ end
+
+ def custom_filter_references_conversation_attribute?(custom_filter, attribute_keys)
+ payload = custom_filter.query.with_indifferent_access[:payload]
+ Array(payload).any? do |condition|
+ condition = condition.with_indifferent_access
+ condition[:attribute_key].to_s.in?(attribute_keys) &&
+ (condition[:custom_attribute_type].presence || 'conversation_attribute') == 'conversation_attribute'
+ end
+ end
+
+ def bump_folder_index_version!(custom_filter, reason:)
+ version = store.bump_folder_index_version!(account_id: account.id, user_id: custom_filter.user_id)
+ record_invalidation(:folder_index, reason: reason, version: version)
+ end
+
+ def bump_filter_version!(custom_filter, reason:)
+ version = store.bump_filter_version!(account_id: account.id, filter_id: custom_filter.id)
+ record_invalidation(:filter, reason: reason, version: version)
+ end
+
+ def record_invalidation(scope, reason:, version:)
+ instrumentation.increment(
+ :invalidation,
+ account_id: account.id,
+ invalidation_scope: scope,
+ reason: reason,
+ version: version
+ )
+ end
+
+ def store
+ ::Conversations::UnreadCounts::FilteredCountStore
+ end
+
+ def instrumentation
+ ::Conversations::UnreadCounts::FilteredCountInstrumentation
+ end
+end
diff --git a/app/services/conversations/unread_counts/filtered_count_snapshot_resolver.rb b/app/services/conversations/unread_counts/filtered_count_snapshot_resolver.rb
new file mode 100644
index 000000000..8685aa891
--- /dev/null
+++ b/app/services/conversations/unread_counts/filtered_count_snapshot_resolver.rb
@@ -0,0 +1,67 @@
+class Conversations::UnreadCounts::FilteredCountSnapshotResolver
+ BUILD_LOCK_TTL = 15.minutes.to_i
+
+ attr_reader :account, :now, :store, :lock_manager
+
+ def initialize(account:, now:, store:, lock_manager:)
+ @account = account
+ @now = now
+ @store = store
+ @lock_manager = lock_manager
+ end
+
+ # Version mismatches make a snapshot stale immediately, but refresh_after keeps DB rebuilds throttled.
+ def resolve(scope:, state:, lock_key:, claim_refresh:, &)
+ record_snapshot_state(scope, state)
+ return state.payload if state.fresh?
+
+ stale_payload = state.payload if state.stale?
+ return stale_payload if refresh_not_due?(stale_payload)
+ return stale_payload unless refresh_claimed?(scope, claim_refresh)
+
+ build_with_lock(scope, lock_key, stale_payload, &)
+ end
+
+ private
+
+ def record_snapshot_state(scope, state)
+ instrumentation.increment(
+ :snapshot_state,
+ account_id: account.id,
+ snapshot_scope: scope,
+ snapshot_status: state.status
+ )
+ end
+
+ def refresh_not_due?(stale_payload)
+ stale_payload.present? && !store.refresh_due?(stale_payload, now: now)
+ end
+
+ def refresh_claimed?(scope, claim_refresh)
+ claimed = claim_refresh.call
+ instrumentation.increment(:refresh_claim, account_id: account.id, snapshot_scope: scope, claimed: claimed)
+ claimed
+ end
+
+ def build_with_lock(scope, lock_key, stale_payload, &)
+ built_payload = nil
+ lock_acquired = false
+
+ begin
+ lock_manager.with_lock(lock_key, BUILD_LOCK_TTL) do
+ lock_acquired = true
+ built_payload = instrumentation.observe(:snapshot_build, account_id: account.id, snapshot_scope: scope, &)
+ rescue ActiveRecord::StatementInvalid
+ built_payload = stale_payload
+ end
+ ensure
+ instrumentation.increment(:build_lock, account_id: account.id, snapshot_scope: scope, acquired: lock_acquired)
+ end
+
+ lock_acquired ? built_payload : stale_payload
+ end
+
+ def instrumentation
+ ::Conversations::UnreadCounts::FilteredCountInstrumentation
+ end
+end
diff --git a/app/services/conversations/unread_counts/filtered_count_store.rb b/app/services/conversations/unread_counts/filtered_count_store.rb
new file mode 100644
index 000000000..b88e01627
--- /dev/null
+++ b/app/services/conversations/unread_counts/filtered_count_store.rb
@@ -0,0 +1,214 @@
+class Conversations::UnreadCounts::FilteredCountStore
+ extend Conversations::UnreadCounts::FilteredCountStoreKeys
+
+ SnapshotResult = Struct.new(:status, :payload, keyword_init: true) do
+ def fresh? = status == :fresh
+ def stale? = status == :stale
+ def expired? = status == :expired
+ def missing? = status == :missing
+ end
+
+ VERSION_KEY_METHODS = {
+ conversation: :conversation_version_key,
+ built_in_filter: :built_in_filter_version_key,
+ folder_index: :folder_index_version_key,
+ filter: :filter_version_key
+ }.freeze
+ REFRESH_THROTTLE_KEY_METHODS = {
+ built_in_filter: :built_in_filter_refresh_throttle_key,
+ folder_index: :folder_index_refresh_throttle_key,
+ filter: :filter_refresh_throttle_key
+ }.freeze
+ private_constant :VERSION_KEY_METHODS, :REFRESH_THROTTLE_KEY_METHODS
+
+ class << self
+ def conversation_version(account_id) = current_version_for(:conversation, account_id)
+ def built_in_filter_version(account_id:, user_id:) = current_version_for(:built_in_filter, account_id, user_id)
+ def folder_index_version(account_id:, user_id:) = current_version_for(:folder_index, account_id, user_id)
+ def filter_version(account_id:, filter_id:) = current_version_for(:filter, account_id, filter_id)
+
+ def bump_conversation_version!(account_id) = bump_version_for!(:conversation, account_id)
+ def bump_built_in_filter_version!(account_id:, user_id:) = bump_version_for!(:built_in_filter, account_id, user_id)
+ def bump_folder_index_version!(account_id:, user_id:) = bump_version_for!(:folder_index, account_id, user_id)
+ def bump_filter_version!(account_id:, filter_id:) = bump_version_for!(:filter, account_id, filter_id)
+
+ # Keep version dimensions explicit so callers cannot write a snapshot without the freshness contract it depends on.
+ def write_built_in_filter_counts!(account_id:, user_id:, counts:, account_version:, built_in_filter_version:, built_at: Time.current, meta: {}) # rubocop:disable Metrics/ParameterLists
+ payload = snapshot_payload(built_at).merge(
+ account_version: account_version,
+ built_in_filter_version: built_in_filter_version,
+ user_id: user_id,
+ counts: counts,
+ meta: meta
+ )
+ write_snapshot(built_in_filter_counts_key(account_id, user_id), payload)
+ end
+
+ def built_in_filter_counts(account_id:, user_id:)
+ read_snapshot(built_in_filter_counts_key(account_id, user_id))
+ end
+
+ def built_in_filter_counts_state(account_id:, user_id:, versions: nil, now: Time.current)
+ snapshot_state(
+ built_in_filter_counts(account_id: account_id, user_id: user_id),
+ versions: versions || {
+ account_version: conversation_version(account_id),
+ built_in_filter_version: built_in_filter_version(account_id: account_id, user_id: user_id)
+ },
+ now: now
+ )
+ end
+
+ def write_folder_index!(account_id:, user_id:, filter_ids:, folder_index_version:, built_at: Time.current)
+ payload = snapshot_payload(built_at).merge(
+ folder_index_version: folder_index_version,
+ user_id: user_id,
+ filter_ids: Array(filter_ids).map(&:to_i)
+ )
+ write_snapshot(folder_index_key(account_id, user_id), payload)
+ end
+
+ def folder_index(account_id:, user_id:)
+ read_snapshot(folder_index_key(account_id, user_id))
+ end
+
+ def folder_index_state(account_id:, user_id:, versions: nil, now: Time.current)
+ snapshot_state(
+ folder_index(account_id: account_id, user_id: user_id),
+ versions: versions || { folder_index_version: folder_index_version(account_id: account_id, user_id: user_id) },
+ now: now
+ )
+ end
+
+ # Saved folder snapshots depend on account, filter, and owner visibility versions; keep all three visible at the callsite.
+ def write_filter_count!(account_id:, filter_id:, user_id:, count:, account_version:, filter_version:, owner_built_in_filter_version:, # rubocop:disable Metrics/ParameterLists
+ built_at: Time.current, meta: {})
+ payload = snapshot_payload(built_at).merge(
+ account_version: account_version,
+ filter_version: filter_version,
+ owner_built_in_filter_version: owner_built_in_filter_version,
+ filter_id: filter_id,
+ user_id: user_id,
+ count: count,
+ meta: meta
+ )
+ write_snapshot(filter_count_key(account_id, filter_id), payload)
+ end
+
+ def filter_count(account_id:, filter_id:)
+ read_snapshot(filter_count_key(account_id, filter_id))
+ end
+
+ def filter_count_state(account_id:, filter_id:, owner_user_id: nil, versions: nil, now: Time.current)
+ snapshot = filter_count(account_id: account_id, filter_id: filter_id)
+ return SnapshotResult.new(status: :missing, payload: nil) if snapshot.blank?
+
+ owner_user_id ||= snapshot[:user_id]
+ snapshot_state(
+ snapshot,
+ versions: versions || {
+ account_version: conversation_version(account_id),
+ filter_version: filter_version(account_id: account_id, filter_id: filter_id),
+ owner_built_in_filter_version: built_in_filter_version(account_id: account_id, user_id: owner_user_id)
+ },
+ now: now
+ )
+ end
+
+ def refresh_due?(snapshot, now: Time.current)
+ return true if snapshot.blank?
+
+ refresh_after = parse_time(snapshot[:refresh_after])
+ refresh_after.blank? || now >= refresh_after
+ end
+
+ def claim_built_in_filter_refresh!(account_id:, user_id:) = claim_refresh_for!(:built_in_filter, account_id, user_id)
+ def claim_folder_index_refresh!(account_id:, user_id:) = claim_refresh_for!(:folder_index, account_id, user_id)
+ def claim_filter_refresh!(account_id:, filter_id:) = claim_refresh_for!(:filter, account_id, filter_id)
+
+ def delete_filter_count!(account_id:, filter_id:)
+ Redis::Alfred.delete(filter_count_key(account_id, filter_id))
+ end
+
+ private
+
+ # Keep the public API domain-specific while centralizing direct Redis version/throttle operations.
+ def current_version_for(scope, *key_args)
+ current_version(public_send(VERSION_KEY_METHODS.fetch(scope), *key_args))
+ end
+
+ def bump_version_for!(scope, *key_args)
+ key = public_send(VERSION_KEY_METHODS.fetch(scope), *key_args)
+
+ Redis::Alfred.with do |conn|
+ conn.multi do |transaction|
+ transaction.incr(key)
+ transaction.expire(key, Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL)
+ end.first
+ end
+ end
+
+ def claim_refresh_for!(scope, *key_args)
+ claim_refresh_throttle(public_send(REFRESH_THROTTLE_KEY_METHODS.fetch(scope), *key_args))
+ end
+
+ def current_version(key)
+ Redis::Alfred.get(key).to_i
+ end
+
+ def snapshot_payload(built_at)
+ # expires_at marks the end of the fresh window. Redis keeps the snapshot for the additional stale window.
+ {
+ built_at: built_at.iso8601,
+ refresh_after: (built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL).iso8601,
+ expires_at: (built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL).iso8601
+ }
+ end
+
+ def write_snapshot(key, payload)
+ Redis::Alfred.setex(key, JSON.generate(payload), Conversations::UnreadCounts::FILTERED_COUNT_REDIS_TTL)
+ end
+
+ def read_snapshot(key)
+ value = Redis::Alfred.get(key)
+ return if value.blank?
+
+ JSON.parse(value, symbolize_names: true)
+ end
+
+ def snapshot_state(snapshot, versions:, now:)
+ return SnapshotResult.new(status: :missing, payload: nil) if snapshot.blank?
+ return SnapshotResult.new(status: :expired, payload: snapshot) unless inside_stale_window?(snapshot, now)
+ return SnapshotResult.new(status: :fresh, payload: snapshot) if versions_match?(snapshot, versions) && inside_fresh_window?(snapshot, now)
+
+ SnapshotResult.new(status: :stale, payload: snapshot)
+ end
+
+ def versions_match?(snapshot, versions)
+ versions.all? { |key, value| snapshot[key].to_i == value.to_i }
+ end
+
+ def inside_fresh_window?(snapshot, now)
+ expires_at = parse_time(snapshot[:expires_at])
+ expires_at.present? && now <= expires_at
+ end
+
+ def inside_stale_window?(snapshot, now)
+ expires_at = parse_time(snapshot[:expires_at])
+ expires_at.present? && now <= expires_at + Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW
+ end
+
+ def parse_time(value)
+ return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
+ return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String)
+
+ Time.zone.parse(value.to_s)
+ rescue ArgumentError, TypeError
+ nil
+ end
+
+ def claim_refresh_throttle(key)
+ Redis::Alfred.set(key, Time.current.to_i, nx: true, ex: Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL) ? true : false
+ end
+ end
+end
diff --git a/app/services/conversations/unread_counts/filtered_count_store_keys.rb b/app/services/conversations/unread_counts/filtered_count_store_keys.rb
new file mode 100644
index 000000000..5072503eb
--- /dev/null
+++ b/app/services/conversations/unread_counts/filtered_count_store_keys.rb
@@ -0,0 +1,67 @@
+module Conversations::UnreadCounts::FilteredCountStoreKeys
+ def conversation_version_key(account_id)
+ account_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_CONVERSATION_VERSION, account_id)
+ end
+
+ def built_in_filter_version_key(account_id, user_id)
+ user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_VERSION, account_id, user_id)
+ end
+
+ def built_in_filter_counts_key(account_id, user_id)
+ user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_COUNTS, account_id, user_id)
+ end
+
+ def built_in_filter_build_lock_key(account_id, user_id)
+ user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_BUILD_LOCK, account_id, user_id)
+ end
+
+ def built_in_filter_refresh_throttle_key(account_id, user_id)
+ user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_REFRESH_THROTTLE, account_id, user_id)
+ end
+
+ def folder_index_version_key(account_id, user_id)
+ user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_VERSION, account_id, user_id)
+ end
+
+ def folder_index_key(account_id, user_id)
+ user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX, account_id, user_id)
+ end
+
+ def folder_index_build_lock_key(account_id, user_id)
+ user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_BUILD_LOCK, account_id, user_id)
+ end
+
+ def folder_index_refresh_throttle_key(account_id, user_id)
+ user_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_REFRESH_THROTTLE, account_id, user_id)
+ end
+
+ def filter_version_key(account_id, filter_id)
+ filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_VERSION, account_id, filter_id)
+ end
+
+ def filter_count_key(account_id, filter_id)
+ filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_COUNT, account_id, filter_id)
+ end
+
+ def filter_build_lock_key(account_id, filter_id)
+ filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_BUILD_LOCK, account_id, filter_id)
+ end
+
+ def filter_refresh_throttle_key(account_id, filter_id)
+ filter_key(Redis::Alfred::UNREAD_CONVERSATIONS_V2_FILTER_REFRESH_THROTTLE, account_id, filter_id)
+ end
+
+ private
+
+ def account_key(format_string, account_id)
+ format(format_string, account_id: account_id)
+ end
+
+ def user_key(format_string, account_id, user_id)
+ format(format_string, account_id: account_id, user_id: user_id)
+ end
+
+ def filter_key(format_string, account_id, filter_id)
+ format(format_string, account_id: account_id, filter_id: filter_id)
+ end
+end
diff --git a/app/services/conversations/unread_counts/filtered_count_version_cache.rb b/app/services/conversations/unread_counts/filtered_count_version_cache.rb
new file mode 100644
index 000000000..5aa73269a
--- /dev/null
+++ b/app/services/conversations/unread_counts/filtered_count_version_cache.rb
@@ -0,0 +1,29 @@
+class Conversations::UnreadCounts::FilteredCountVersionCache
+ attr_reader :account, :user, :store
+
+ def initialize(account:, user:, store:)
+ @account = account
+ @user = user
+ @store = store
+ end
+
+ def built_in_filter = { account_version: account_version, built_in_filter_version: built_in_filter_version }
+
+ def folder_index = { folder_index_version: store.folder_index_version(account_id: account.id, user_id: user.id) }
+
+ def filter(filter_id)
+ {
+ account_version: account_version,
+ filter_version: filter_version(filter_id),
+ owner_built_in_filter_version: built_in_filter_version
+ }
+ end
+
+ private
+
+ def account_version = @account_version ||= store.conversation_version(account.id)
+
+ def built_in_filter_version = @built_in_filter_version ||= store.built_in_filter_version(account_id: account.id, user_id: user.id)
+
+ def filter_version(filter_id) = (@filter_versions ||= {})[filter_id] ||= store.filter_version(account_id: account.id, filter_id: filter_id)
+end
diff --git a/app/services/conversations/unread_counts/filtered_counter.rb b/app/services/conversations/unread_counts/filtered_counter.rb
new file mode 100644
index 000000000..1194951b6
--- /dev/null
+++ b/app/services/conversations/unread_counts/filtered_counter.rb
@@ -0,0 +1,216 @@
+class Conversations::UnreadCounts::FilteredCounter
+ FEATURE_FLAG = 'unread_count_for_filters'.freeze
+ EMPTY_COUNTS = {
+ mentions_count: 0,
+ participating_count: 0,
+ unattended_count: 0,
+ folders: {}
+ }.freeze
+
+ attr_reader :account, :user, :now
+
+ def self.empty_counts = EMPTY_COUNTS.deep_dup
+
+ def initialize(account:, user:, now: Time.current)
+ @account = account
+ @user = user
+ @now = now
+ end
+
+ def perform = instrumentation.observe(:counter_perform, account_id: account.id) { built_in_counts.merge(folders: folder_counts) }
+
+ private
+
+ def built_in_counts = counts_from_built_in_snapshot(built_in_counts_snapshot) || self.class.empty_counts.except(:folders)
+
+ def built_in_counts_snapshot
+ versions = version_cache.built_in_filter
+ snapshot_or_build(
+ scope: :built_in_filter,
+ state: store.built_in_filter_counts_state(account_id: account.id, user_id: user.id, versions: versions, now: now),
+ lock_key: store.built_in_filter_build_lock_key(account.id, user.id),
+ claim_refresh: -> { store.claim_built_in_filter_refresh!(account_id: account.id, user_id: user.id) }
+ ) { build_built_in_counts!(versions) }
+ end
+
+ def counts_from_built_in_snapshot(snapshot) = snapshot&.fetch(:counts, nil)&.slice(:mentions_count, :participating_count, :unattended_count)
+
+ def folder_counts
+ folder_index = folder_index_snapshot
+ return {} if folder_index.blank?
+
+ @inline_filter_builds = 0
+ folder_index[:filter_ids].each_with_object({}) do |filter_id, counts|
+ count = filter_count(filter_id)
+ counts[filter_id.to_s] = count if count.to_i.positive?
+ end
+ end
+
+ def folder_index_snapshot
+ versions = version_cache.folder_index
+ snapshot_or_build(
+ scope: :folder_index,
+ state: store.folder_index_state(account_id: account.id, user_id: user.id, versions: versions, now: now),
+ lock_key: store.folder_index_build_lock_key(account.id, user.id),
+ claim_refresh: -> { store.claim_folder_index_refresh!(account_id: account.id, user_id: user.id) }
+ ) { build_folder_index!(versions) }
+ end
+
+ def filter_count(filter_id)
+ versions = version_cache.filter(filter_id)
+ snapshot = snapshot_or_build(
+ scope: :filter,
+ state: store.filter_count_state(account_id: account.id, filter_id: filter_id, owner_user_id: user.id, versions: versions, now: now),
+ lock_key: store.filter_build_lock_key(account.id, filter_id),
+ claim_refresh: -> { filter_build_available? && store.claim_filter_refresh!(account_id: account.id, filter_id: filter_id) }
+ ) do
+ track_filter_build!
+ build_filter_count!(filter_id, versions)
+ end
+
+ snapshot&.fetch(:count, nil)
+ end
+
+ def snapshot_or_build(scope:, state:, lock_key:, claim_refresh:, &)
+ snapshot_resolver.resolve(scope: scope, state: state, lock_key: lock_key, claim_refresh: claim_refresh, &)
+ end
+
+ def filter_build_available? = @inline_filter_builds.to_i < Conversations::UnreadCounts::MAX_INLINE_FILTER_BUILDS
+
+ def track_filter_build! = @inline_filter_builds = @inline_filter_builds.to_i + 1
+
+ def build_built_in_counts!(versions)
+ store.write_built_in_filter_counts!(**built_in_count_snapshot_payload(versions))
+ store.built_in_filter_counts(account_id: account.id, user_id: user.id)
+ end
+
+ def built_in_count_snapshot_payload(versions)
+ {
+ account_id: account.id,
+ user_id: user.id,
+ counts: built_in_counts_from_database,
+ account_version: versions.fetch(:account_version),
+ built_in_filter_version: versions.fetch(:built_in_filter_version),
+ built_at: now
+ }
+ end
+
+ def built_in_counts_from_database
+ {
+ mentions_count: count_relation(mentioned_unread_conversations),
+ participating_count: count_relation(participating_unread_conversations),
+ unattended_count: count_relation(unread_open_accessible_conversations.unattended)
+ }
+ end
+
+ def mentioned_unread_conversations
+ unread_open_accessible_conversations
+ .joins(:mentions)
+ .where(mentions: { account_id: account.id, user_id: user.id })
+ end
+
+ def participating_unread_conversations
+ unread_open_accessible_conversations
+ .joins(:conversation_participants)
+ .where(conversation_participants: { user_id: user.id })
+ end
+
+ def build_folder_index!(versions)
+ store.write_folder_index!(
+ account_id: account.id,
+ user_id: user.id,
+ filter_ids: folder_filter_ids_from_database,
+ folder_index_version: versions.fetch(:folder_index_version),
+ built_at: now
+ )
+ store.folder_index(account_id: account.id, user_id: user.id)
+ end
+
+ def folder_filter_ids_from_database = account.custom_filters.where(user_id: user.id, filter_type: :conversation).pluck(:id)
+
+ def build_filter_count!(filter_id, versions)
+ custom_filter = account.custom_filters.find_by(id: filter_id, user_id: user.id, filter_type: :conversation)
+ return delete_filter_count!(filter_id) if custom_filter.blank?
+
+ count = filter_query_count(custom_filter)
+ return delete_filter_count!(filter_id) if count.nil?
+
+ write_filter_count!(filter_id, count, versions)
+ store.filter_count(account_id: account.id, filter_id: filter_id)
+ rescue CustomExceptions::CustomFilter::InvalidAttribute,
+ CustomExceptions::CustomFilter::InvalidOperator,
+ CustomExceptions::CustomFilter::InvalidQueryOperator,
+ CustomExceptions::CustomFilter::InvalidValue
+ delete_filter_count!(filter_id)
+ end
+
+ def filter_query_count(custom_filter)
+ ::Conversations::UnreadCounts::FilterQueryCounter.new(
+ account: account,
+ user: user,
+ query: custom_filter.query
+ ).perform
+ end
+
+ def write_filter_count!(filter_id, count, versions)
+ store.write_filter_count!(
+ account_id: account.id,
+ filter_id: filter_id,
+ user_id: user.id,
+ count: count,
+ account_version: versions.fetch(:account_version),
+ filter_version: versions.fetch(:filter_version),
+ owner_built_in_filter_version: versions.fetch(:owner_built_in_filter_version),
+ built_at: now
+ )
+ end
+
+ def version_cache = @version_cache ||= ::Conversations::UnreadCounts::FilteredCountVersionCache.new(account: account, user: user, store: store)
+
+ def delete_filter_count!(filter_id) = store.delete_filter_count!(account_id: account.id, filter_id: filter_id).then { nil }
+
+ def unread_open_accessible_conversations
+ @unread_open_accessible_conversations ||= Conversations::PermissionFilterService.new(
+ unread_conversations.open,
+ user,
+ account
+ ).perform
+ end
+
+ def unread_conversations
+ account.conversations
+ .joins(:messages)
+ .merge(Message.incoming.reorder(nil))
+ .where(messages: { account_id: account.id })
+ .where(unread_since_last_seen_condition)
+ .distinct
+ end
+
+ def unread_since_last_seen_condition
+ conversations = Conversation.arel_table
+ messages = Message.arel_table
+
+ conversations[:agent_last_seen_at].eq(nil).or(messages[:created_at].gt(conversations[:agent_last_seen_at]))
+ end
+
+ def count_relation(relation) = relation.unscope(:order).count
+
+ def lock_manager = @lock_manager ||= Redis::LockManager.new
+
+ def snapshot_resolver
+ @snapshot_resolver ||= ::Conversations::UnreadCounts::FilteredCountSnapshotResolver.new(
+ account: account,
+ now: now,
+ store: store,
+ lock_manager: lock_manager
+ )
+ end
+
+ def store
+ ::Conversations::UnreadCounts::FilteredCountStore
+ end
+
+ def instrumentation
+ ::Conversations::UnreadCounts::FilteredCountInstrumentation
+ end
+end
diff --git a/app/services/conversations/unread_counts/listener.rb b/app/services/conversations/unread_counts/listener.rb
index 28792d884..59f15f5bc 100644
--- a/app/services/conversations/unread_counts/listener.rb
+++ b/app/services/conversations/unread_counts/listener.rb
@@ -1,34 +1,59 @@
class Conversations::UnreadCounts::Listener < BaseListener
include Events::Types
+ FILTERED_CONVERSATION_UPDATE_KEYS = %w[
+ additional_attributes cached_label_list campaign_id custom_attributes first_reply_created_at label_list last_activity_at priority snoozed_until
+ waiting_since
+ ].freeze
+ private_constant :FILTERED_CONVERSATION_UPDATE_KEYS
+
def message_created(event)
message, = extract_message_and_account(event)
- return unless message.incoming?
- return unless message.account.feature_enabled?('conversation_unread_counts')
+ account = message.account
+ return unless account.feature_enabled?('conversation_unread_counts') || account.feature_enabled?(filtered_count_feature_flag)
- refresh(message.conversation)
+ conversation = message.conversation
+ refreshed = refresh(conversation) if message.incoming? && account.feature_enabled?('conversation_unread_counts')
+
+ invalidate_filtered_conversation(conversation)
+
+ notify_filtered_count_change(conversation) unless message.incoming? && refreshed
end
def conversation_status_changed(event)
conversation, = extract_conversation_and_account(event)
- refresh(conversation, event.data[:changed_attributes])
+ refresh_then_invalidate(conversation, event.data[:changed_attributes])
end
def conversation_updated(event)
- return unless label_changed?(event.data[:changed_attributes])
-
conversation, = extract_conversation_and_account(event)
- refresh(conversation, event.data[:changed_attributes])
+ changed_attributes = event.data[:changed_attributes]
+ notify_filtered_count_change(conversation) if filtered_conversation_update_changed?(changed_attributes) && !label_changed?(changed_attributes)
+ return unless label_changed?(changed_attributes)
+
+ refresh(conversation, changed_attributes)
+ end
+
+ def conversation_contact_changed(event)
+ conversation, = extract_conversation_and_account(event)
+ invalidate_filtered_conversation(conversation)
+ notify_filtered_count_change(conversation)
end
def assignee_changed(event)
conversation, = extract_conversation_and_account(event)
- refresh(conversation, event.data[:changed_attributes])
+ refresh_then_invalidate(conversation, event.data[:changed_attributes])
end
def team_changed(event)
conversation, = extract_conversation_and_account(event)
- refresh(conversation, event.data[:changed_attributes])
+ refresh_then_invalidate(conversation, event.data[:changed_attributes])
+ end
+
+ def conversation_mentioned(event)
+ conversation, = extract_conversation_and_account(event)
+ user = event.data[:user]
+ filtered_count_invalidator(conversation.account).user_visibility_changed!(user_id: user&.id)
end
def conversation_deleted(event)
@@ -36,14 +61,23 @@ class Conversations::UnreadCounts::Listener < BaseListener
return if conversation_data.blank?
account = Account.find_by(id: conversation_data[:account_id])
- return unless account&.feature_enabled?('conversation_unread_counts')
- return unless remove_deleted_conversation(account, conversation_data)
+ return if account.blank?
+
+ removed = account.feature_enabled?('conversation_unread_counts') && remove_deleted_conversation(account, conversation_data)
+ filtered_count_invalidator(account).conversation_changed!
+ return notify_deleted_filtered_count_change(account, conversation_data) unless removed
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
end
private
+ def refresh_then_invalidate(conversation, changed_attributes = nil)
+ refreshed = refresh(conversation, changed_attributes)
+ invalidate_filtered_conversation(conversation)
+ notify_filtered_count_change(conversation) unless refreshed
+ end
+
def refresh(conversation, changed_attributes = nil)
::Conversations::UnreadCounts::Notifier.new(conversation, changed_attributes: changed_attributes).perform
end
@@ -90,6 +124,37 @@ class Conversations::UnreadCounts::Listener < BaseListener
changed_attributes.key?('cached_label_list') || changed_attributes.key?(:cached_label_list)
end
+ def filtered_conversation_update_changed?(changed_attributes)
+ return false if changed_attributes.blank?
+
+ changed_attributes.keys.map(&:to_s).intersect?(FILTERED_CONVERSATION_UPDATE_KEYS)
+ end
+
+ def invalidate_filtered_conversation(conversation)
+ filtered_count_invalidator(conversation.account).conversation_changed!
+ end
+
+ def notify_filtered_count_change(conversation)
+ return unless conversation.account.feature_enabled?('conversation_unread_counts')
+ return unless conversation.account.feature_enabled?(filtered_count_feature_flag)
+
+ Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
+ end
+
+ def notify_deleted_filtered_count_change(account, conversation_data)
+ return unless account.feature_enabled?(filtered_count_feature_flag)
+
+ Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
+ end
+
+ def filtered_count_invalidator(account)
+ ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account)
+ end
+
+ def filtered_count_feature_flag
+ ::Conversations::UnreadCounts::FilteredCountInvalidator::FEATURE_FLAG
+ end
+
def store
::Conversations::UnreadCounts::Store
end
diff --git a/app/services/conversations/unread_counts/notifier.rb b/app/services/conversations/unread_counts/notifier.rb
index 652fbde3a..6075b4abc 100644
--- a/app/services/conversations/unread_counts/notifier.rb
+++ b/app/services/conversations/unread_counts/notifier.rb
@@ -10,10 +10,20 @@ class Conversations::UnreadCounts::Notifier
def perform
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
+ return dispatch_unread_count_changed if ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
+ return false unless conversation.account.feature_enabled?(filtered_count_feature_flag)
- return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
+ dispatch_unread_count_changed
+ end
+ private
+
+ def dispatch_unread_count_changed
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
true
end
+
+ def filtered_count_feature_flag
+ ::Conversations::UnreadCounts::FilteredCountInvalidator::FEATURE_FLAG
+ end
end
diff --git a/app/services/data_imports/intercom/activity_content_builder.rb b/app/services/data_imports/intercom/activity_content_builder.rb
new file mode 100644
index 000000000..6e56ff090
--- /dev/null
+++ b/app/services/data_imports/intercom/activity_content_builder.rb
@@ -0,0 +1,84 @@
+class DataImports::Intercom::ActivityContentBuilder
+ EVENT_KEYS = {
+ 'assignment' => :assignment,
+ 'assign_and_reopen' => :assign_and_reopen,
+ 'open' => :open,
+ 'close' => :close,
+ 'snoozed' => :snoozed,
+ 'participant_added' => :participant_added,
+ 'participant_removed' => :participant_removed,
+ 'conversation_attribute_updated_by_admin' => :conversation_attribute_updated,
+ 'conversation_attribute_updated_by_user' => :conversation_attribute_updated,
+ 'conversation_attribute_updated_by_workflow' => :conversation_attribute_updated,
+ 'ticket_attribute_updated_by_admin' => :ticket_attribute_updated,
+ 'ticket_state_updated_by_admin' => :ticket_state_updated,
+ 'custom_action_started' => :custom_action_started,
+ 'custom_action_finished' => :custom_action_finished,
+ 'quick_reply' => :quick_reply
+ }.freeze
+
+ def initialize(part)
+ @part = part.to_h
+ end
+
+ def perform
+ append_body(translated_content)
+ end
+
+ private
+
+ def translated_content
+ key = EVENT_KEYS.fetch(event_type, :generic)
+ key = "#{key}_with_target" if target_aware_event?(key) && target_name.present?
+
+ I18n.t(
+ "data_imports.intercom.activities.#{key}",
+ actor: actor_name,
+ target: target_name,
+ event: event_type.tr('_', ' ')
+ )
+ end
+
+ def event_type
+ @part['part_type'].to_s
+ end
+
+ def actor_name
+ author = @part['author'].to_h
+ return author['name'] if author['name'].present?
+
+ case author['type']
+ when 'user', 'contact', 'lead'
+ 'Contact'
+ when 'bot'
+ 'Intercom automation'
+ else
+ automation_event? ? 'Intercom automation' : 'Intercom teammate'
+ end
+ end
+
+ def target_name
+ assigned_to = @part['assigned_to'].to_h
+ event_details = @part['event_details'].to_h
+ participant = event_details['participant'].to_h
+
+ assigned_to['name'].presence || participant['name'].presence || event_details['participant_name'].presence || event_details['name'].presence
+ end
+
+ def target_aware_event?(key)
+ %i[assignment assign_and_reopen participant_added participant_removed].include?(key)
+ end
+
+ def automation_event?
+ event_type.include?('workflow') || event_type.start_with?('custom_action')
+ end
+
+ def append_body(content)
+ fragment = Nokogiri::HTML5.fragment(@part['body'].to_s)
+ fragment.css('script, style').remove
+ body = fragment.text.squish
+ return content if body.blank? || content.downcase.include?(body.downcase)
+
+ "#{content}: #{body}"
+ end
+end
diff --git a/app/services/data_imports/intercom/client.rb b/app/services/data_imports/intercom/client.rb
new file mode 100644
index 000000000..35193d22c
--- /dev/null
+++ b/app/services/data_imports/intercom/client.rb
@@ -0,0 +1,107 @@
+class DataImports::Intercom::Client
+ class Error < StandardError
+ attr_reader :status, :body
+
+ def initialize(message, status: nil, body: nil)
+ super(message)
+ @status = status
+ @body = body
+ end
+ end
+
+ class AuthenticationError < Error; end
+
+ class RateLimitError < Error
+ attr_reader :retry_after
+
+ def initialize(message, retry_after: nil, **)
+ super(message, **)
+ @retry_after = retry_after
+ end
+ end
+
+ BASE_URL = 'https://api.intercom.io'.freeze
+ API_VERSION = '2.15'.freeze
+ DEFAULT_PER_PAGE = 50
+
+ def initialize(access_token:)
+ @access_token = access_token
+ end
+
+ def list_contacts(starting_after: nil, per_page: DEFAULT_PER_PAGE)
+ get('/contacts', query: pagination_query(starting_after, per_page))
+ end
+
+ def list_conversations(starting_after: nil, per_page: DEFAULT_PER_PAGE)
+ get('/conversations', query: pagination_query(starting_after, per_page))
+ end
+
+ def retrieve_conversation(id)
+ get("/conversations/#{id}")
+ end
+
+ def retrieve_contact(id)
+ get("/contacts/#{id}")
+ end
+
+ private
+
+ def pagination_query(starting_after, per_page)
+ { per_page: per_page, starting_after: starting_after }.compact
+ end
+
+ def get(path, query: {})
+ response =
+ begin
+ HTTParty.get(
+ "#{BASE_URL}#{path}",
+ query: query,
+ headers: headers,
+ timeout: 30
+ )
+ rescue StandardError => e
+ raise Error.new(
+ "Intercom API request failed before receiving a response: #{e.message}",
+ body: { transport_error_class: e.class.name }
+ )
+ end
+
+ parse_response(response)
+ end
+
+ def headers
+ {
+ 'Authorization' => "Bearer #{@access_token}",
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+ 'Intercom-Version' => API_VERSION
+ }
+ end
+
+ def parse_response(response)
+ body = parsed_body(response)
+ return body if response.success?
+
+ message = error_message(body, response)
+ case response.code
+ when 401, 403
+ raise AuthenticationError.new(message, status: response.code, body: body)
+ when 429
+ raise RateLimitError.new(message, status: response.code, body: body, retry_after: response.headers['retry-after'])
+ else
+ raise Error.new(message, status: response.code, body: body)
+ end
+ end
+
+ def parsed_body(response)
+ response.parsed_response.presence || {}
+ rescue JSON::ParserError
+ {}
+ end
+
+ def error_message(body, response)
+ errors = body.is_a?(Hash) ? body['errors'] : nil
+ first_error = errors.is_a?(Array) ? errors.first : nil
+ first_error&.dig('message').presence || "Intercom API request failed with status #{response.code}"
+ end
+end
diff --git a/app/services/data_imports/intercom/creation_service.rb b/app/services/data_imports/intercom/creation_service.rb
new file mode 100644
index 000000000..401bbbeb4
--- /dev/null
+++ b/app/services/data_imports/intercom/creation_service.rb
@@ -0,0 +1,67 @@
+class DataImports::Intercom::CreationService
+ def initialize(account:, initiated_by:, source_params:)
+ @account = account
+ @initiated_by = initiated_by
+ @source_params = source_params.symbolize_keys
+ @access_token = @source_params[:access_token].to_s.strip
+ end
+
+ def perform
+ return if active_import?
+
+ totals = validate_source
+ @account.with_lock do
+ next if active_import?
+
+ @account.data_imports.new(attributes(totals)).tap do |data_import|
+ data_import.assign_active_intercom_import_run_id
+ data_import.save!
+ end
+ end
+ end
+
+ private
+
+ def validate_source
+ raise ArgumentError, 'Unsupported import source.' unless @source_params[:source_provider] == 'intercom'
+
+ DataImports::Intercom::CredentialsValidator.new(
+ access_token: @access_token,
+ import_types: import_types
+ ).perform
+ end
+
+ def attributes(totals)
+ {
+ name: @source_params[:name].presence || 'Intercom import',
+ data_type: 'intercom',
+ source_type: 'api',
+ source_provider: 'intercom',
+ import_types: import_types,
+ initiated_by: @initiated_by,
+ access_token: @access_token,
+ stats: initial_stats(totals)
+ }
+ end
+
+ def import_types
+ return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless @source_params.key?(:import_types)
+
+ Array(@source_params[:import_types]).compact_blank
+ end
+
+ def initial_stats(totals)
+ {
+ 'contacts' => { 'imported' => 0, 'skipped' => 0 },
+ 'conversations' => { 'imported' => 0, 'skipped' => 0 },
+ 'messages' => { 'imported' => 0, 'skipped' => 0 },
+ 'errors' => { 'count' => 0 }
+ }.tap do |stats|
+ totals.each { |type, total| stats[type]['total'] = total unless total.nil? }
+ end
+ end
+
+ def active_import?
+ @account.data_imports.active_intercom.exists?
+ end
+end
diff --git a/app/services/data_imports/intercom/credentials_validator.rb b/app/services/data_imports/intercom/credentials_validator.rb
new file mode 100644
index 000000000..10391033e
--- /dev/null
+++ b/app/services/data_imports/intercom/credentials_validator.rb
@@ -0,0 +1,36 @@
+class DataImports::Intercom::CredentialsValidator
+ def initialize(access_token:, import_types:)
+ @access_token = access_token.to_s.strip
+ @import_types = Array(import_types).compact_blank
+ end
+
+ def perform
+ validate_parameters!
+
+ {}.tap do |totals|
+ contacts_response = client.list_contacts(per_page: 1) if @import_types.intersect?(%w[contacts conversations])
+ totals['contacts'] = total_count(contacts_response) if @import_types.include?('contacts')
+ totals['conversations'] = total_count(client.list_conversations(per_page: 1)) if @import_types.include?('conversations')
+ end.compact
+ end
+
+ private
+
+ def validate_parameters!
+ raise ArgumentError, 'Intercom access key is required.' if @access_token.blank?
+ raise ArgumentError, 'Select at least one data type to import.' if @import_types.blank?
+
+ invalid_types = @import_types - DataImport::IMPORT_TYPES
+ return if invalid_types.blank?
+
+ raise ArgumentError, "Unsupported import types: #{invalid_types.join(', ')}"
+ end
+
+ def client
+ @client ||= DataImports::Intercom::Client.new(access_token: @access_token)
+ end
+
+ def total_count(response)
+ response['total_count'] if response.key?('total_count')
+ end
+end
diff --git a/app/services/data_imports/intercom/importer.rb b/app/services/data_imports/intercom/importer.rb
new file mode 100644
index 000000000..d7d040d89
--- /dev/null
+++ b/app/services/data_imports/intercom/importer.rb
@@ -0,0 +1,991 @@
+# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Rails/SkipsModelValidations
+class DataImports::Intercom::Importer
+ PageResult = Struct.new(:next_cursor, keyword_init: true) do
+ def done?
+ next_cursor.blank?
+ end
+ end
+
+ DEFAULT_IMPORT_TYPES = %w[contacts conversations].freeze
+ PROVIDER = 'intercom'.freeze
+ ALREADY_IMPORTED_ERROR_CODE = 'DataImports::Intercom::AlreadyImported'.freeze
+ SKIPPED_MESSAGE_ERROR_CODE = 'DataImports::Intercom::SkippedMessage'.freeze
+ TRUNCATED_PARTS_ERROR_CODE = 'DataImports::Intercom::TruncatedConversationParts'.freeze
+ E164_REGEX = /\A\+[1-9]\d{1,14}\z/
+ INTERCOM_NUMBER_REGEX = /\A[1-9]\d{1,14}\z/
+ REGULAR_MESSAGE_PART_TYPES = %w[comment note source].freeze
+
+ def initialize(data_import:, run_id: nil)
+ @data_import = data_import
+ @run_id = run_id
+ @account = data_import.account
+ @client = DataImports::Intercom::Client.new(access_token: data_import.access_token)
+ @placeholder_inboxes = DataImports::Intercom::PlaceholderInboxBuilder.new(account: @account)
+ @stats = default_stats.deep_merge(data_import.stats || {})
+ end
+
+ def perform
+ return unless start!
+
+ import_contacts if import_type?('contacts')
+ import_conversations if import_type?('conversations')
+ finish!
+ rescue StandardError => e
+ fail!(e)
+ raise
+ end
+
+ def start!
+ return if @data_import.reload.abandoned?
+
+ @data_import.update!(status: :processing, started_at: @data_import.started_at || Time.current)
+ end
+
+ def finish!
+ return if @data_import.reload.abandoned?
+
+ has_failures = @data_import.import_errors.non_skip_logs.exists? || @data_import.import_errors.failed.exists?
+ status = has_failures ? :completed_with_errors : :completed
+ @data_import.update!(
+ status: status,
+ completed_at: Time.current,
+ stats: @stats,
+ total_records: total_processed_records,
+ processed_records: total_successful_records
+ )
+ end
+
+ def fail!(error)
+ return if @data_import.reload.abandoned?
+
+ record_run_error(error)
+ @data_import.update!(status: :failed, last_error_at: Time.current)
+ end
+
+ def import_contacts_page(starting_after: cursor_for('contacts'))
+ response = @client.list_contacts(starting_after: starting_after)
+ update_stat_total('contacts', response['total_count']) if response['total_count'].present?
+ Array(response['data'] || response['contacts']).each do |contact|
+ break if import_stopped?
+
+ import_contact(contact)
+ end
+ return PageResult.new(next_cursor: nil) if import_stopped?
+
+ next_cursor = response.dig('pages', 'next', 'starting_after')
+ update_cursor('contacts', next_cursor)
+ PageResult.new(next_cursor: next_cursor)
+ end
+
+ def import_conversations_page(starting_after: cursor_for('conversations'))
+ response = @client.list_conversations(starting_after: starting_after)
+ update_stat_total('conversations', response['total_count']) if response['total_count'].present?
+ Array(response['data'] || response['conversations']).each do |conversation_summary|
+ break if import_stopped?
+
+ import_conversation_from_summary(conversation_summary)
+ end
+ return PageResult.new(next_cursor: nil) if import_stopped?
+
+ next_cursor = response.dig('pages', 'next', 'starting_after')
+ update_cursor('conversations', next_cursor)
+ PageResult.new(next_cursor: next_cursor)
+ end
+
+ def import_contacts?
+ import_type?('contacts')
+ end
+
+ def import_conversations?
+ import_type?('conversations')
+ end
+
+ def contacts_completed?
+ stage_completed?('contacts')
+ end
+
+ def conversations_completed?
+ stage_completed?('conversations')
+ end
+
+ def cursor_for(key)
+ @data_import.cursor&.dig(key, 'starting_after')
+ end
+
+ private
+
+ def import_contacts
+ cursor = cursor_for('contacts')
+ loop do
+ result = import_contacts_page(starting_after: cursor)
+ break if result.done?
+
+ cursor = result.next_cursor
+ end
+ end
+
+ def import_conversations
+ cursor = cursor_for('conversations')
+ loop do
+ result = import_conversations_page(starting_after: cursor)
+ break if result.done?
+
+ cursor = result.next_cursor
+ end
+ end
+
+ def import_conversation_from_summary(conversation_summary)
+ source_id = source_id_for(conversation_summary)
+ already_handled = item_handled?('conversation', source_id)
+ item = import_item('conversation', source_id, conversation_summary)
+ mapping = find_mapping('conversation', source_id)
+
+ conversation = @client.retrieve_conversation(source_id)
+ return if import_stopped?
+
+ update_message_total(item, conversation)
+
+ contact = import_contact(primary_conversation_contact(conversation), required_for_conversation: true)
+ source_type = conversation_source_type(conversation, conversation_summary)
+ inbox = @placeholder_inboxes.inbox_for(source_type)
+ contact_inbox = contact_inbox_for(contact, inbox)
+
+ mapped_conversation = mapping&.chatwoot_record
+ if mapped_conversation && mapping.data_import_id != @data_import.id
+ skip_already_imported_item(item, mapping, already_handled: already_handled)
+ import_source_message(conversation, mapped_conversation, contact)
+ import_conversation_parts(conversation, mapped_conversation, contact)
+ update_conversation_activity(mapped_conversation)
+ return
+ end
+
+ chatwoot_conversation = mapped_conversation || create_conversation(conversation, contact, contact_inbox, inbox, source_type)
+ if mapped_conversation
+ record_mapping('conversation', source_id, chatwoot_conversation, metadata: conversation_metadata(conversation, inbox, source_type))
+ end
+ item.update!(status: :imported, chatwoot_record_type: 'Conversation', chatwoot_record_id: chatwoot_conversation.id)
+ increment_stat('conversations', 'imported') unless already_handled
+
+ import_source_message(conversation, chatwoot_conversation, contact)
+ import_conversation_parts(conversation, chatwoot_conversation, contact)
+ update_conversation_activity(chatwoot_conversation)
+ rescue StandardError => e
+ raise if e.is_a?(DataImports::Intercom::Client::Error)
+
+ fail_item(item, e)
+ ensure
+ persist_stats
+ end
+
+ def import_stopped?
+ return true if @import_stopped
+
+ @data_import.reload
+ @import_stopped = @data_import.abandoned? || @data_import.completed? || @data_import.completed_with_errors? || stale_import_run?
+ end
+
+ def stale_import_run?
+ active_run_id = @data_import.active_intercom_import_run_id
+ @run_id.present? && active_run_id.present? && active_run_id != @run_id
+ end
+
+ def import_contact(contact_payload, required_for_conversation: false)
+ source_id = source_id_for(contact_payload)
+ if source_id.present? && (mapping = find_mapping('contact', source_id)) && (mapped_contact = mapping.chatwoot_record)
+ return reuse_mapped_contact(contact_payload, source_id, mapping, mapped_contact)
+ end
+
+ contact_payload = retrieve_contact_payload(contact_payload)
+ source_id = source_id_for(contact_payload)
+ already_handled = item_handled?('contact', source_id)
+ item = import_item('contact', source_id, contact_payload)
+ mapping = find_mapping('contact', source_id)
+
+ mapped_contact = mapping&.chatwoot_record
+ if mapped_contact && mapping.data_import_id != @data_import.id
+ skip_already_imported_item(item, mapping, already_handled: already_handled)
+ return mapped_contact
+ end
+
+ contact = Contact.transaction do
+ imported_contact = mapped_contact || find_existing_contact(contact_payload) || create_contact(contact_payload)
+ update_existing_contact(imported_contact, contact_payload)
+ record_mapping('contact', source_id, imported_contact, metadata: contact_metadata(contact_payload))
+ item.update!(status: :imported, chatwoot_record_type: 'Contact', chatwoot_record_id: imported_contact.id)
+ imported_contact
+ end
+ increment_stat('contacts', 'imported') unless already_handled
+ contact
+ rescue StandardError => e
+ raise if e.is_a?(DataImports::Intercom::Client::Error)
+
+ fail_item(item, e)
+ raise if required_for_conversation
+ ensure
+ persist_stats
+ end
+
+ def retrieve_contact_payload(contact_payload)
+ return contact_payload if contact_payload.blank?
+ return contact_payload if contact_payload['email'].present? || contact_payload['phone'].present? || contact_payload['name'].present?
+ return contact_payload if contact_payload['id'].blank?
+
+ @client.retrieve_contact(contact_payload['id'])
+ rescue DataImports::Intercom::Client::Error => e
+ raise unless e.status == 404
+
+ contact_payload
+ end
+
+ def create_contact(contact_payload)
+ attrs = contact_attributes(contact_payload).merge(created_at: timestamp_for(contact_payload['created_at']), updated_at: Time.current)
+ result = Contact.insert_all!([attrs], returning: %w[id])
+ Contact.find(result.rows.first.first)
+ rescue ActiveRecord::RecordNotUnique
+ find_existing_contact(contact_payload)
+ end
+
+ def reuse_mapped_contact(contact_payload, source_id, mapping, mapped_contact)
+ if mapping.data_import_id == @data_import.id
+ reconcile_current_run_contact(contact_payload, source_id, mapped_contact)
+ return mapped_contact
+ end
+
+ already_handled = item_handled?('contact', source_id)
+ item = import_item('contact', source_id, contact_payload)
+ skip_already_imported_item(item, mapping, already_handled: already_handled)
+ mapped_contact
+ end
+
+ def update_existing_contact(contact, contact_payload)
+ attrs = contact_attributes(contact_payload)
+ updates = {}
+ updates[:name] = attrs[:name] if contact.name.blank? && attrs[:name].present?
+ updates[:email] = attrs[:email] if contact_email_available?(contact, attrs[:email])
+ updates[:phone_number] = attrs[:phone_number] if contact_phone_number_available?(contact, attrs[:phone_number])
+ updates[:identifier] = attrs[:identifier] if contact.identifier.blank? && attrs[:identifier].present?
+ updates[:last_activity_at] = attrs[:last_activity_at] if contact.last_activity_at.blank? && attrs[:last_activity_at].present?
+ updates[:additional_attributes] = contact.additional_attributes.to_h.deep_merge(attrs[:additional_attributes])
+ updates[:custom_attributes] = contact.custom_attributes.to_h.deep_merge(attrs[:custom_attributes])
+ if contact.visitor? && attrs[:contact_type].present? && contact_resolved_after_update?(contact, updates)
+ updates[:contact_type] = attrs[:contact_type]
+ end
+ updates[:updated_at] = Time.current
+ contact.update_columns(updates) if updates.present?
+ contact.reload
+ end
+
+ def contact_email_available?(contact, email)
+ return false if contact.email.present? || email.blank?
+
+ @account.contacts.where.not(id: contact.id).where('LOWER(email) = ?', email.downcase).empty?
+ end
+
+ def contact_phone_number_available?(contact, phone_number)
+ return false if contact.phone_number.present? || phone_number.blank?
+
+ @account.contacts.where.not(id: contact.id).where(phone_number: phone_number).empty?
+ end
+
+ def contact_resolved_after_update?(contact, updates)
+ contact.email.present? || contact.phone_number.present? || updates[:email].present? || updates[:phone_number].present?
+ end
+
+ def find_existing_contact(contact_payload)
+ identifier = normalized_identifier(contact_payload)
+ email = normalized_email(contact_payload)
+ phone_number = normalized_phone(contact_payload)
+
+ if identifier.present?
+ contact = @account.contacts.find_by(identifier: identifier)
+ return contact if contact.present?
+ end
+
+ if email.present?
+ contact = @account.contacts.from_email(email)
+ return contact if contact.present?
+ end
+ return @account.contacts.find_by(phone_number: phone_number) if phone_number.present?
+
+ nil
+ end
+
+ def contact_attributes(contact_payload)
+ attrs = {
+ account_id: @account.id,
+ name: contact_payload['name'].presence || contact_payload['email'].presence || '',
+ email: normalized_email(contact_payload),
+ phone_number: normalized_phone(contact_payload),
+ identifier: normalized_identifier(contact_payload),
+ last_activity_at: contact_activity_at(contact_payload),
+ additional_attributes: {
+ source: {
+ provider: PROVIDER,
+ contact_id: contact_payload['id'],
+ external_id: contact_payload['external_id'],
+ raw_phone: contact_payload['phone']
+ }.compact
+ },
+ custom_attributes: {
+ intercom_contact_id: contact_payload['id'],
+ intercom_external_id: contact_payload['external_id']
+ }.compact
+ }
+ attrs[:contact_type] = Contact.contact_types[:lead] if attrs[:email].present? || attrs[:phone_number].present?
+ attrs
+ end
+
+ def create_conversation(conversation, contact, contact_inbox, inbox, source_type)
+ source_id = source_id_for(conversation)
+ metadata = conversation_metadata(conversation, inbox, source_type)
+ if (existing_conversation = @account.conversations.find_by(identifier: conversation_identifier(conversation)))
+ record_mapping('conversation', source_id, existing_conversation, metadata: metadata)
+ return existing_conversation
+ end
+
+ attrs = {
+ account_id: @account.id,
+ inbox_id: inbox.id,
+ status: Conversation.statuses['resolved'],
+ contact_id: contact.id,
+ contact_inbox_id: contact_inbox.id,
+ identifier: conversation_identifier(conversation),
+ additional_attributes: metadata,
+ custom_attributes: { intercom_conversation_id: source_id },
+ created_at: timestamp_for(conversation['created_at']),
+ updated_at: timestamp_for(conversation['updated_at']),
+ last_activity_at: timestamp_for(conversation['updated_at'])
+ }
+
+ Conversation.transaction do
+ result = Conversation.insert_all!([attrs], returning: %w[id])
+ chatwoot_conversation = Conversation.find(result.rows.first.first)
+ record_mapping('conversation', source_id, chatwoot_conversation, metadata: metadata)
+ chatwoot_conversation
+ end
+ rescue ActiveRecord::RecordNotUnique
+ @account.conversations.find_by!(identifier: conversation_identifier(conversation)).tap do |chatwoot_conversation|
+ record_mapping('conversation', source_id, chatwoot_conversation, metadata: metadata)
+ end
+ end
+
+ def import_source_message(conversation, chatwoot_conversation, contact)
+ source = conversation['source'].to_h
+ return unless source_message_importable?(source)
+
+ message_source_id = "conversation:#{source_id_for(conversation)}:source:#{source['id'].presence || 'initial'}"
+ source_part = source.merge('part_type' => 'source', 'created_at' => conversation['created_at'])
+ if (mapping = find_mapping('message', message_source_id)) && message_mapping_handled?(mapping, source_part)
+ if mapping.data_import_id == @data_import.id
+ reconcile_current_run_message_mapping(chatwoot_conversation, mapping, source_part)
+ return
+ end
+
+ skip_existing_message_mapping(chatwoot_conversation, mapping, source_part)
+ return
+ end
+
+ create_message(chatwoot_conversation, contact, source_part, message_source_id)
+ rescue StandardError => e
+ fail_message(chatwoot_conversation, message_source_id, source_part, e)
+ end
+
+ def import_conversation_parts(conversation, chatwoot_conversation, contact)
+ parts_payload = conversation['conversation_parts'].to_h
+ parts = Array(parts_payload['conversation_parts'])
+ record_truncated_conversation_parts(conversation, parts.size)
+
+ parts.each do |part|
+ message_source_id = "conversation:#{source_id_for(conversation)}:part:#{part['id']}"
+ if (mapping = find_mapping('message', message_source_id)) && message_mapping_handled?(mapping, part)
+ if mapping.data_import_id == @data_import.id
+ reconcile_current_run_message_mapping(chatwoot_conversation, mapping, part)
+ next
+ end
+
+ skip_existing_message_mapping(chatwoot_conversation, mapping, part)
+ next
+ end
+
+ create_message(chatwoot_conversation, contact, part, message_source_id)
+ rescue StandardError => e
+ fail_message(chatwoot_conversation, message_source_id, part, e)
+ end
+ end
+
+ def create_message(conversation, contact, part, message_source_id)
+ content = content_for(part)
+ return record_skipped_message(conversation, message_source_id, part) if content.blank?
+
+ attrs = message_attributes(conversation, contact, part, message_source_id, content)
+ message = nil
+ Message.transaction do
+ message = conversation.messages.find_by(source_id: attrs[:source_id])
+ unless message
+ result = Message.insert_all!([attrs], returning: %w[id])
+ message = Message.find(result.rows.first.first)
+ end
+ record_mapping('message', message_source_id, message, metadata: message_metadata(part))
+ end
+ increment_stat('messages', 'imported')
+ reindex_message_for_search(message)
+ message
+ end
+
+ def reindex_message_for_search(message)
+ return unless message.should_index?
+
+ message.__send__(:reindex_for_search)
+ rescue StandardError => e
+ Rails.logger.warn("Intercom import message reindex failed for message #{message.id}: #{e.class} - #{e.message}")
+ end
+
+ def record_skipped_message(conversation, message_source_id, part)
+ mapping = find_mapping('message', message_source_id)
+ if mapping
+ already_recorded = skip_log_recorded?('message', message_source_id, SKIPPED_MESSAGE_ERROR_CODE)
+ record_skipped_message_log(conversation, message_source_id, part)
+ increment_stat('messages', 'skipped') unless already_recorded
+ return mapping.chatwoot_record
+ end
+
+ DataImportMapping.create!(
+ account: @account,
+ data_import: @data_import,
+ source_provider: PROVIDER,
+ source_object_type: 'message',
+ source_object_id: message_source_id,
+ chatwoot_record_type: 'Conversation',
+ chatwoot_record_id: conversation.id,
+ metadata: message_metadata(part).merge(skipped: true, reason: 'blank_or_unsupported_intercom_part')
+ )
+ record_skipped_message_log(conversation, message_source_id, part)
+ increment_stat('messages', 'skipped')
+ end
+
+ def message_attributes(conversation, contact, part, message_source_id, content)
+ message_type = message_type_for(part)
+ created_at = timestamp_for(part['created_at'])
+ {
+ account_id: @account.id,
+ inbox_id: conversation.inbox_id,
+ conversation_id: conversation.id,
+ message_type: Message.message_types[message_type],
+ content_type: Message.content_types['text'],
+ content: content,
+ processed_message_content: content,
+ private: message_type != 'activity' && part['part_type'] == 'note',
+ status: Message.statuses['sent'],
+ sender_type: message_type == 'incoming' ? 'Contact' : nil,
+ sender_id: message_type == 'incoming' ? contact.id : nil,
+ source_id: "intercom:#{message_source_id}",
+ external_source_ids: { intercom: message_source_id },
+ content_attributes: {},
+ additional_attributes: message_metadata(part),
+ created_at: created_at,
+ updated_at: part['updated_at'].present? ? timestamp_for(part['updated_at']) : created_at
+ }
+ end
+
+ def message_type_for(part)
+ return 'activity' if activity_part?(part)
+
+ author_type = part.dig('author', 'type').to_s
+ return 'incoming' if %w[user contact lead].include?(author_type)
+
+ 'outgoing'
+ end
+
+ def content_for(part)
+ return DataImports::Intercom::ActivityContentBuilder.new(part).perform if activity_part?(part)
+
+ message_content(part)
+ end
+
+ def activity_part?(part)
+ part_type = part['part_type'].to_s
+ part_type.present? && REGULAR_MESSAGE_PART_TYPES.exclude?(part_type)
+ end
+
+ def message_content(part)
+ body = sanitized_text(part['body'])
+ subject = sanitized_text(part['subject'])
+ attachments = Array(part['attachments'])
+ content = [subject, body].reject(&:blank?).join("\n\n")
+ return content if attachments.blank?
+
+ [content.presence, "[Intercom attachment skipped: #{attachments.size}]"].compact.join("\n\n")
+ end
+
+ def sanitized_text(value)
+ Rails::HTML5::FullSanitizer.new.sanitize(value.to_s).squish
+ end
+
+ def update_conversation_activity(conversation)
+ latest_message = conversation.messages.reorder(created_at: :desc).first
+ return if latest_message.blank?
+
+ conversation.update_columns(last_activity_at: latest_message.created_at, updated_at: Time.current)
+ end
+
+ def contact_inbox_for(contact, inbox)
+ ContactInbox.find_or_create_by!(contact: contact, inbox: inbox) do |contact_inbox|
+ contact_inbox.source_id = "intercom:#{contact.id}"
+ end
+ end
+
+ def primary_conversation_contact(conversation)
+ contacts = conversation.dig('contacts', 'contacts') || []
+ contacts.first || conversation.dig('source', 'author') || {}
+ end
+
+ def conversation_source_type(conversation, conversation_summary)
+ conversation.dig('source', 'type').presence ||
+ conversation.dig('first_contact_reply', 'type').presence ||
+ conversation_summary.dig('source', 'type').presence ||
+ conversation_summary.dig('first_contact_reply', 'type').presence
+ end
+
+ def normalized_identifier(contact_payload)
+ contact_payload['external_id'].presence
+ end
+
+ def normalized_email(contact_payload)
+ email = contact_payload['email'].to_s.strip.downcase
+ email.match?(Devise.email_regexp) ? email : nil
+ end
+
+ def normalized_phone(contact_payload)
+ phone = contact_payload['phone'].to_s.strip
+ phone = "+#{phone}" if phone.match?(INTERCOM_NUMBER_REGEX)
+ phone.match?(E164_REGEX) ? phone : nil
+ end
+
+ def contact_activity_at(contact_payload)
+ return timestamp_for(contact_payload['last_seen_at']) if contact_payload['last_seen_at'].present?
+ return timestamp_for(contact_payload['last_replied_at']) if contact_payload['last_replied_at'].present?
+
+ nil
+ end
+
+ def source_id_for(payload)
+ payload['id'].presence || payload['external_id'].presence || payload['email'].presence
+ end
+
+ def conversation_identifier(conversation)
+ "intercom:#{source_id_for(conversation)}"
+ end
+
+ def import_item(object_type, source_id, metadata)
+ @data_import.items.find_or_initialize_by(
+ source_provider: PROVIDER,
+ source_object_type: object_type,
+ source_object_id: source_id
+ ).tap do |item|
+ item.status = :processing
+ item.attempt_count += 1
+ item.metadata = item.metadata.to_h.merge(metadata.to_h)
+ item.save!
+ end
+ end
+
+ def item_handled?(object_type, source_id)
+ @data_import.items.where(status: [:imported, :skipped]).exists?(
+ source_provider: PROVIDER,
+ source_object_type: object_type,
+ source_object_id: source_id
+ )
+ end
+
+ def find_mapping(object_type, source_id)
+ DataImportMapping.find_by(
+ account: @account,
+ source_provider: PROVIDER,
+ source_object_type: object_type,
+ source_object_id: source_id
+ )
+ end
+
+ def record_mapping(object_type, source_id, record, metadata: {})
+ DataImportMapping.find_or_initialize_by(
+ account: @account,
+ source_provider: PROVIDER,
+ source_object_type: object_type,
+ source_object_id: source_id
+ ).tap do |mapping|
+ mapping.data_import = @data_import
+ mapping.chatwoot_record_type = record.class.name
+ mapping.chatwoot_record_id = record.id
+ mapping.metadata = metadata
+ mapping.save!
+ end
+ end
+
+ def reconcile_current_run_contact(contact_payload, source_id, mapped_contact)
+ item = @data_import.items.find_by(
+ source_provider: PROVIDER,
+ source_object_type: 'contact',
+ source_object_id: source_id
+ )
+ item = import_item('contact', source_id, contact_payload) unless item&.imported?
+ item.update!(status: :imported, chatwoot_record_type: 'Contact', chatwoot_record_id: mapped_contact.id)
+ reconcile_item_stats('contact')
+ end
+
+ def reconcile_item_stats(source_object_type)
+ items = @data_import.items.where(source_provider: PROVIDER, source_object_type: source_object_type)
+ group = stat_group_for(source_object_type)
+ @stats[group]['imported'] = items.imported.count
+ @stats[group]['skipped'] = items.skipped.count
+ persist_stats
+ end
+
+ def reconcile_current_run_message_mapping(conversation, mapping, part)
+ record_skipped_message_log(conversation, mapping.source_object_id, part) if mapping.metadata['skipped']
+
+ mappings = @data_import.mappings.where(source_provider: PROVIDER, source_object_type: 'message')
+ skipped_mappings = mappings.where("metadata ->> 'skipped' = ?", 'true').count
+ message_logs = @data_import.import_errors.where(source_object_type: 'message')
+ @stats['messages']['imported'] = mappings.count - skipped_mappings
+ @stats['messages']['skipped'] = message_logs.where("details ->> 'kind' = ?", 'skipped').count
+ persist_stats
+ end
+
+ def skip_already_imported_item(item, mapping, already_handled:)
+ item.update!(
+ status: :skipped,
+ chatwoot_record_type: mapping.chatwoot_record_type,
+ chatwoot_record_id: mapping.chatwoot_record_id,
+ last_error_code: ALREADY_IMPORTED_ERROR_CODE,
+ last_error_message: 'Already imported in a previous import.'
+ )
+ record_already_imported_log(
+ data_import_item: item,
+ source_object_type: item.source_object_type,
+ source_object_id: item.source_object_id,
+ mapping: mapping
+ )
+ increment_stat(stat_group_for(item.source_object_type), 'skipped') unless already_handled
+ end
+
+ def skip_existing_message_mapping(conversation, mapping, part)
+ if mapping.metadata['skipped']
+ already_recorded = skip_log_recorded?('message', mapping.source_object_id, SKIPPED_MESSAGE_ERROR_CODE)
+ record_skipped_message_log(conversation, mapping.source_object_id, part)
+ else
+ already_recorded = skip_log_recorded?('message', mapping.source_object_id, ALREADY_IMPORTED_ERROR_CODE)
+ record_already_imported_log(source_object_type: 'message', source_object_id: mapping.source_object_id, mapping: mapping)
+ end
+ increment_stat('messages', 'skipped') unless already_recorded
+ end
+
+ def message_mapping_handled?(mapping, part)
+ return false if mapping.metadata['skipped'] && activity_part?(part)
+
+ mapping.metadata['skipped'] || mapping.chatwoot_record.present?
+ end
+
+ def fail_item(item, error)
+ increment_stat('errors', 'count')
+ item&.update!(status: :failed, last_error_code: error.class.name, last_error_message: error.message)
+ record_skip_log(
+ data_import_item: item,
+ source_object_type: item&.source_object_type,
+ source_object_id: item&.source_object_id,
+ error_code: error.class.name,
+ message: error.message,
+ details: {
+ kind: 'failed',
+ source_provider: PROVIDER,
+ error_class: error.class.name
+ }
+ )
+ end
+
+ def fail_message(conversation, message_source_id, part, error)
+ increment_stat('errors', 'count')
+ record_skip_log(
+ source_object_type: 'message',
+ source_object_id: message_source_id,
+ error_code: error.class.name,
+ message: error.message,
+ details: message_metadata(part).merge(
+ kind: 'failed',
+ source_provider: PROVIDER,
+ error_class: error.class.name,
+ conversation_id: conversation.identifier
+ )
+ )
+ end
+
+ def record_skipped_message_log(conversation, message_source_id, part)
+ record_skip_log(
+ source_object_type: 'message',
+ source_object_id: message_source_id,
+ error_code: SKIPPED_MESSAGE_ERROR_CODE,
+ message: skipped_message_log_message(part),
+ details: skipped_message_details(conversation, part)
+ )
+ end
+
+ def record_already_imported_log(source_object_type:, source_object_id:, mapping:, data_import_item: nil)
+ record_skip_log(
+ data_import_item: data_import_item,
+ source_object_type: source_object_type,
+ source_object_id: source_object_id,
+ error_code: ALREADY_IMPORTED_ERROR_CODE,
+ message: 'Already imported in a previous import.',
+ details: {
+ kind: 'skipped',
+ reason: 'already_imported',
+ source_provider: PROVIDER,
+ previous_data_import_id: mapping.data_import_id,
+ chatwoot_record_type: mapping.chatwoot_record_type,
+ chatwoot_record_id: mapping.chatwoot_record_id
+ }
+ )
+ end
+
+ def record_truncated_conversation_parts(conversation, imported_parts_count)
+ total_parts_count = total_conversation_parts_count(conversation)
+ return if total_parts_count <= imported_parts_count
+
+ source_id = source_id_for(conversation)
+ already_recorded = @data_import.import_errors.exists?(
+ source_object_type: 'conversation',
+ source_object_id: source_id,
+ error_code: TRUNCATED_PARTS_ERROR_CODE
+ )
+ record_import_error(
+ source_object_type: 'conversation',
+ source_object_id: source_id,
+ error_code: TRUNCATED_PARTS_ERROR_CODE,
+ message: "Intercom returned #{imported_parts_count} of #{total_parts_count} conversation parts.",
+ details: {
+ kind: 'incomplete',
+ source_provider: PROVIDER,
+ imported_parts_count: imported_parts_count,
+ total_parts_count: total_parts_count
+ }
+ )
+ increment_stat('errors', 'count') unless already_recorded
+ end
+
+ def total_conversation_parts_count(conversation)
+ conversation_parts_total_count = conversation.dig('conversation_parts', 'total_count')
+ return conversation_parts_total_count.to_i if conversation_parts_total_count.present?
+
+ [
+ conversation.dig('statistics', 'count_conversation_parts'),
+ conversation.dig('statistics', 'count_conversations_parts')
+ ].compact.map(&:to_i).max || 0
+ end
+
+ def source_message_importable?(source)
+ source['body'].present? || source['subject'].present? || source['attachments'].present?
+ end
+
+ def skipped_message_log_message(part)
+ "Skipped Intercom #{intercom_event_name(part)} event#{intercom_part_id_suffix(part)}: #{skipped_message_reason_details(part)}."
+ end
+
+ def skipped_message_details(conversation, part)
+ author = part['author'].to_h
+ message_metadata(part).merge(
+ {
+ kind: 'skipped',
+ reason: 'blank_or_unsupported_intercom_part',
+ reason_details: skipped_message_reason_details(part),
+ event_name: intercom_event_name(part),
+ event_type: part['part_type'],
+ author_type: author['type'],
+ author_name: author['name'],
+ conversation_id: conversation.identifier
+ }.compact
+ )
+ end
+
+ def skipped_message_reason_details(part)
+ return 'message body did not contain readable text after HTML sanitization' if part['body'].present?
+ return 'attachments are present but no importable message text was found' if Array(part['attachments']).present?
+
+ 'no message body or attachments to import'
+ end
+
+ def intercom_event_name(part)
+ part['part_type'].to_s.tr('_', ' ').presence || 'message part'
+ end
+
+ def intercom_part_id_suffix(part)
+ part['id'].present? ? " #{part['id']}" : ''
+ end
+
+ def skip_log_recorded?(source_object_type, source_object_id, error_code)
+ @data_import.import_errors.skip_logs.exists?(
+ source_object_type: source_object_type,
+ source_object_id: source_object_id,
+ error_code: error_code
+ )
+ end
+
+ def record_run_error(error)
+ @data_import.import_errors.create!(
+ error_code: error.class.name,
+ message: error.message,
+ details: {
+ kind: 'run_error',
+ source_provider: PROVIDER,
+ error_class: error.class.name
+ }
+ )
+ end
+
+ def record_skip_log(attributes)
+ record_import_error(attributes)
+ end
+
+ def record_import_error(attributes)
+ @data_import.import_errors.find_or_initialize_by(
+ data_import_item: attributes[:data_import_item],
+ source_object_type: attributes[:source_object_type],
+ source_object_id: attributes[:source_object_id],
+ error_code: attributes[:error_code]
+ ).tap do |import_error|
+ import_error.message = attributes[:message]
+ import_error.details = attributes[:details]
+ import_error.save!
+ end
+ end
+
+ def conversation_metadata(conversation, inbox, source_type)
+ {
+ source: {
+ provider: PROVIDER,
+ conversation_id: source_id_for(conversation),
+ source_type: source_type,
+ delivered_as: conversation.dig('source', 'delivered_as'),
+ source_url: conversation.dig('source', 'url'),
+ admin_assignee_id: conversation['admin_assignee_id'],
+ team_assignee_id: conversation['team_assignee_id'],
+ state: conversation['state'],
+ open: conversation['open'],
+ routing_method: 'source_bucket_api_inbox',
+ routed_inbox_id: inbox.id,
+ import_id: @data_import.id
+ }.compact
+ }
+ end
+
+ def contact_metadata(contact_payload)
+ {
+ source: {
+ provider: PROVIDER,
+ contact_id: contact_payload['id'],
+ external_id: contact_payload['external_id']
+ }.compact
+ }
+ end
+
+ def message_metadata(part)
+ {
+ source: {
+ provider: PROVIDER,
+ part_id: part['id'],
+ part_type: part['part_type'],
+ author: part['author'],
+ assigned_to: part['assigned_to'],
+ state: part['state'],
+ tags: part['tags'],
+ event_details: part['event_details'],
+ app_package_code: part['app_package_code'],
+ metadata: part['metadata'],
+ attachments: part['attachments'],
+ redacted: part['redacted']
+ }.compact
+ }
+ end
+
+ def timestamp_for(value)
+ return Time.current if value.blank?
+
+ Time.zone.at(value.to_i)
+ end
+
+ def update_cursor(key, cursor)
+ @data_import.cursor = @data_import.cursor.to_h.merge(
+ key => { starting_after: cursor, completed: cursor.blank?, updated_at: Time.current.iso8601 }
+ )
+ @data_import.save!
+ end
+
+ def stage_completed?(key)
+ @data_import.cursor&.dig(key, 'completed') == true
+ end
+
+ def import_type?(type)
+ import_types.include?(type)
+ end
+
+ def import_types
+ @import_types ||= (@data_import.import_types.presence || DEFAULT_IMPORT_TYPES)
+ end
+
+ def increment_stat(group, key)
+ @stats[group] ||= {}
+ @stats[group][key] = @stats[group][key].to_i + 1
+ end
+
+ def update_stat_total(group, total)
+ @stats[group] ||= {}
+ @stats[group]['total'] = total.to_i
+ persist_stats
+ end
+
+ def update_message_total(item, conversation)
+ parts = conversation['conversation_parts'].to_h
+ conversation_parts_total = if parts.key?('total_count')
+ parts['total_count'].to_i
+ else
+ Array(parts['conversation_parts']).size
+ end
+ contribution = conversation_parts_total
+ contribution += 1 if source_message_importable?(conversation['source'].to_h)
+ previous_contribution = item.metadata.to_h['message_total_contribution'].to_i
+
+ @stats['messages']['total'] = @stats['messages']['total'].to_i + contribution - previous_contribution
+ item.update!(metadata: item.metadata.to_h.merge('message_total_contribution' => contribution))
+ persist_stats
+ end
+
+ def stat_group_for(source_object_type)
+ "#{source_object_type}s"
+ end
+
+ def persist_stats
+ @data_import.update_columns(stats: @stats, updated_at: Time.current)
+ end
+
+ def default_stats
+ {
+ 'contacts' => { 'imported' => 0, 'skipped' => 0 },
+ 'conversations' => { 'imported' => 0, 'skipped' => 0 },
+ 'messages' => { 'imported' => 0, 'skipped' => 0 },
+ 'errors' => { 'count' => 0 }
+ }
+ end
+
+ def total_processed_records
+ total_successful_records +
+ @stats.fetch('contacts', {}).fetch('skipped', 0).to_i +
+ @stats.fetch('conversations', {}).fetch('skipped', 0).to_i +
+ @stats.fetch('messages', {}).fetch('skipped', 0).to_i +
+ @stats.fetch('errors', {}).fetch('count', 0).to_i
+ end
+
+ def total_successful_records
+ @stats.fetch('contacts', {}).fetch('imported', 0).to_i +
+ @stats.fetch('conversations', {}).fetch('imported', 0).to_i +
+ @stats.fetch('messages', {}).fetch('imported', 0).to_i
+ end
+end
+# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Rails/SkipsModelValidations
diff --git a/app/services/data_imports/intercom/placeholder_inbox_builder.rb b/app/services/data_imports/intercom/placeholder_inbox_builder.rb
new file mode 100644
index 000000000..c46a1f439
--- /dev/null
+++ b/app/services/data_imports/intercom/placeholder_inbox_builder.rb
@@ -0,0 +1,41 @@
+class DataImports::Intercom::PlaceholderInboxBuilder
+ AGENT_REPLY_TIME_WINDOW_HOURS = 1
+
+ def initialize(account:)
+ @account = account
+ end
+
+ def inbox_for(source_type)
+ bucket = DataImports::Intercom::SourceBucket.for(source_type)
+ placeholder_inboxes[bucket[:key]] ||= create_placeholder_inbox(bucket)
+ end
+
+ private
+
+ def placeholder_inboxes
+ @placeholder_inboxes ||= @account.inboxes.includes(:channel).where(channel_type: 'Channel::Api').each_with_object({}) do |inbox, inboxes|
+ attrs = inbox.channel.additional_attributes || {}
+ next unless attrs['source_provider'] == 'intercom' && attrs['import_placeholder'] == true
+
+ inboxes[attrs['source_bucket']] = inbox
+ end
+ end
+
+ def create_placeholder_inbox(bucket)
+ channel = @account.api_channels.create!(
+ additional_attributes: {
+ source_provider: 'intercom',
+ source_bucket: bucket[:key],
+ import_placeholder: true,
+ agent_reply_time_window: AGENT_REPLY_TIME_WINDOW_HOURS
+ }
+ )
+
+ @account.inboxes.create!(
+ name: "Intercom Import - #{bucket[:name]}",
+ channel: channel,
+ enable_auto_assignment: false,
+ allow_messages_after_resolved: false
+ )
+ end
+end
diff --git a/app/services/data_imports/intercom/restart_service.rb b/app/services/data_imports/intercom/restart_service.rb
new file mode 100644
index 000000000..6fd55c633
--- /dev/null
+++ b/app/services/data_imports/intercom/restart_service.rb
@@ -0,0 +1,55 @@
+class DataImports::Intercom::RestartService
+ attr_reader :data_import
+
+ def initialize(account:, data_import:)
+ @account = account
+ @data_import = data_import
+ end
+
+ def perform
+ @account.with_lock do
+ @data_import.reload
+ next :render_show unless @data_import.restartable?
+
+ if (active_import = find_active_import)
+ @data_import = active_import
+ next :render_show
+ end
+
+ next :access_token_missing if @data_import.access_token.blank?
+
+ @data_import.assign_active_intercom_import_run_id
+ retained_skip_logs = @data_import.import_errors.where("details ->> 'kind' = ?", 'skipped')
+ @data_import.import_errors.where.not(id: retained_skip_logs.select(:id)).delete_all
+ @data_import.update!(restart_attributes(retained_skip_logs))
+ :enqueue
+ end
+ end
+
+ private
+
+ def find_active_import
+ @account.data_imports.active_intercom.first
+ end
+
+ def restart_attributes(retained_skip_logs)
+ {
+ status: :pending,
+ abandoned_at: nil,
+ completed_at: nil,
+ last_error_at: nil,
+ started_at: nil,
+ stats: restart_stats(retained_skip_logs)
+ }
+ end
+
+ def restart_stats(retained_skip_logs)
+ @data_import.stats.to_h.deep_dup.tap do |stats|
+ %w[contact conversation message].each do |object_type|
+ stats["#{object_type}s"] ||= {}
+ stats["#{object_type}s"]['skipped'] = retained_skip_logs.where(source_object_type: object_type).count
+ end
+ stats['errors'] = { 'count' => 0 }
+ end
+ end
+end
diff --git a/app/services/data_imports/intercom/source_bucket.rb b/app/services/data_imports/intercom/source_bucket.rb
new file mode 100644
index 000000000..6d66f71dc
--- /dev/null
+++ b/app/services/data_imports/intercom/source_bucket.rb
@@ -0,0 +1,23 @@
+class DataImports::Intercom::SourceBucket
+ BUCKETS = {
+ 'email' => { key: 'email', name: 'Email' },
+ 'instagram' => { key: 'instagram', name: 'Instagram' },
+ 'facebook' => { key: 'facebook', name: 'Facebook' },
+ 'sms' => { key: 'sms', name: 'SMS' },
+ 'twitter' => { key: 'twitter', name: 'Twitter' },
+ 'whatsapp' => { key: 'whatsapp', name: 'WhatsApp' },
+ 'phone' => { key: 'phone', name: 'Phone' },
+ 'phone_call' => { key: 'phone', name: 'Phone' },
+ 'phone_switch' => { key: 'phone', name: 'Phone' },
+ 'inapp' => { key: 'messenger', name: 'Messenger' },
+ 'messenger' => { key: 'messenger', name: 'Messenger' },
+ 'conversation' => { key: 'messenger', name: 'Messenger' },
+ 'push' => { key: 'messenger', name: 'Messenger' }
+ }.freeze
+
+ DEFAULT_BUCKET = { key: 'unknown', name: 'Unknown' }.freeze
+
+ def self.for(source_type)
+ BUCKETS[source_type.to_s.downcase] || DEFAULT_BUCKET
+ end
+end
diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb
index e55355f3a..9c5b8e27b 100644
--- a/app/services/imap/base_fetch_email_service.rb
+++ b/app/services/imap/base_fetch_email_service.rb
@@ -38,7 +38,8 @@ class Imap::BaseFetchEmailService
end
def email_already_present?(channel, message_id)
- channel.inbox.messages.find_by(source_id: message_id).present? || deleted_message_tracker.deleted?(message_id)
+ # exists? avoids Message's default_scope ORDER BY, which full-scans large inboxes
+ channel.inbox.messages.exists?(source_id: message_id) || deleted_message_tracker.deleted?(message_id)
end
def deleted_message_tracker
diff --git a/app/services/labels/destroy_service.rb b/app/services/labels/destroy_service.rb
index 080e708e7..b5d7add33 100644
--- a/app/services/labels/destroy_service.rb
+++ b/app/services/labels/destroy_service.rb
@@ -2,19 +2,25 @@ class Labels::DestroyService
pattr_initialize [:label_title!, :account_id!, :label_deleted_at!]
def perform
- remove_conversation_labels
+ conversation_labels_removed = remove_conversation_labels
remove_contact_labels
+ invalidate_filtered_unread_count_conversations if conversation_labels_removed
end
private
def remove_conversation_labels
+ conversation_labels_removed = false
+
tagged_conversations.find_in_batches do |conversation_batch|
conversation_batch.each do |conversation|
update_conversation_cached_labels(conversation)
end
delete_label_taggings('Conversation', conversation_batch.map(&:id))
+ conversation_labels_removed = true
end
+
+ conversation_labels_removed
end
def remove_contact_labels
@@ -57,4 +63,8 @@ class Labels::DestroyService
def account
@account ||= Account.find(account_id)
end
+
+ def invalidate_filtered_unread_count_conversations
+ ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed!
+ end
end
diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
index 7981f54b5..373e47b3c 100644
--- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
@@ -40,7 +40,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def fetch_whatsapp_templates(url)
response = HTTParty.get(url)
- return [] unless response.success?
+ unless response.success?
+ Rails.logger.warn "[WHATSAPP] Template sync failed for account #{whatsapp_channel.account_id} " \
+ "inbox #{whatsapp_channel.inbox&.id}: #{response.code} #{error_message(response)}"
+ return []
+ end
next_url = next_url(response)
@@ -54,8 +58,17 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def validate_provider_config?
- response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
- response.success?
+ config = whatsapp_channel.provider_config
+ response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{config['api_key']}")
+ return log_transfer_failure('waba_or_token_check', response) unless response.success?
+ # The templates check only proves the WABA/token pair, so verify the phone_number_id belongs to this WABA when it changes.
+ return true unless whatsapp_channel.provider_config_changed?
+
+ phone_response = HTTParty.get("#{business_account_path}/phone_numbers?fields=id&limit=100&access_token=#{config['api_key']}")
+ ids = phone_response.parsed_response.is_a?(Hash) ? Array(phone_response.parsed_response['data']) : []
+ return true if phone_response.success? && ids.any? { |number| number['id'] == config['phone_number_id'].to_s }
+
+ log_transfer_failure('phone_number_id_check', phone_response)
end
def api_headers
@@ -81,6 +94,16 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
private
+ # Only credential updates on existing channels are transfer attempts; creation failures are regular setup errors. Returns false.
+ def log_transfer_failure(check, response)
+ return false unless whatsapp_channel.persisted? && whatsapp_channel.provider_config_changed?
+
+ error_message = response.parsed_response.is_a?(Hash) ? response.parsed_response.dig('error', 'message') : nil
+ Rails.logger.warn("[WHATSAPP_MANUAL_TRANSFER] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
+ "check=#{check} http_status=#{response.code} meta_error=#{error_message}")
+ false
+ end
+
def csat_template_service
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
end
@@ -136,7 +159,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def error_message(response)
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
- response.parsed_response&.dig('error', 'message')
+ response.parsed_response.dig('error', 'message') if response.parsed_response.is_a?(Hash)
end
def voice_message?(type, attachment)
diff --git a/app/services/whatsapp/webhook_teardown_service.rb b/app/services/whatsapp/webhook_teardown_service.rb
index 948d84f04..de794f8e3 100644
--- a/app/services/whatsapp/webhook_teardown_service.rb
+++ b/app/services/whatsapp/webhook_teardown_service.rb
@@ -23,7 +23,6 @@ class Whatsapp::WebhookTeardownService
def should_teardown_webhook?
@channel.provider == 'whatsapp_cloud' &&
- provider_config['source'] == 'embedded_signup' &&
provider_config['api_key'].present? &&
(provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?)
end
@@ -38,8 +37,11 @@ class Whatsapp::WebhookTeardownService
Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}"
end
- # The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one.
+ # Embedded signup only — a manual token's subscribed app is the customer's, not ours to unsubscribe.
+ # The subscription is shared across the WABA, so only unsubscribe when this is the last inbox.
def unsubscribe_app_if_last_inbox(api_client)
+ return unless provider_config['source'] == 'embedded_signup'
+
waba_id = provider_config['business_account_id']
return if waba_id.blank?
return if waba_sibling_exists?(waba_id)
diff --git a/app/views/api/v1/accounts/assignable_agents/index.json.jbuilder b/app/views/api/v1/accounts/assignable_agents/index.json.jbuilder
index 295c5cef9..1a00c7122 100644
--- a/app/views/api/v1/accounts/assignable_agents/index.json.jbuilder
+++ b/app/views/api/v1/accounts/assignable_agents/index.json.jbuilder
@@ -1,5 +1,16 @@
json.payload do
- json.array! @assignable_agents do |agent|
- json.partial! 'api/v1/models/agent', formats: [:json], resource: agent
+ owners = @assignable_agents.map { |agent| { type: 'User', resource: agent } }
+ owners += @agent_bots.map { |agent_bot| { type: 'AgentBot', resource: agent_bot } }
+
+ json.array! owners do |owner|
+ if owner[:type] == 'User'
+ json.partial! 'api/v1/models/agent', formats: [:json], resource: owner[:resource]
+ json.assignee_type 'User' if @include_agent_bots
+ else
+ json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: owner[:resource]
+ json.assignee_type 'AgentBot'
+ json.icon 'i-lucide-bot'
+ json.availability_status 'offline'
+ end
end
end
diff --git a/app/views/api/v1/accounts/data_imports/_data_import.json.jbuilder b/app/views/api/v1/accounts/data_imports/_data_import.json.jbuilder
new file mode 100644
index 000000000..ba56ac4d0
--- /dev/null
+++ b/app/views/api/v1/accounts/data_imports/_data_import.json.jbuilder
@@ -0,0 +1,24 @@
+json.id data_import.id
+json.name data_import.name
+json.data_type data_import.data_type
+json.source_type data_import.source_type
+json.source_provider data_import.source_provider
+json.import_types data_import.import_types
+json.status data_import.status
+json.total_records data_import.total_records
+json.processed_records data_import.processed_records
+json.stats data_import.stats
+json.cursor data_import.cursor
+json.created_at data_import.created_at
+json.updated_at data_import.updated_at
+json.started_at data_import.started_at
+json.completed_at data_import.completed_at
+json.abandoned_at data_import.abandoned_at
+json.initiated_by data_import.initiated_by&.slice(:id, :name, :email)
+if @import_errors_counts
+ json.import_errors_count @import_errors_counts.fetch(data_import.id, 0)
+ json.skip_logs_count (@skip_logs_counts || {}).fetch(data_import.id, 0)
+else
+ json.import_errors_count data_import.import_errors.non_skip_logs.count
+ json.skip_logs_count data_import.import_errors.skip_logs.count
+end
diff --git a/app/views/api/v1/accounts/data_imports/index.json.jbuilder b/app/views/api/v1/accounts/data_imports/index.json.jbuilder
new file mode 100644
index 000000000..beaf27125
--- /dev/null
+++ b/app/views/api/v1/accounts/data_imports/index.json.jbuilder
@@ -0,0 +1,5 @@
+json.payload do
+ json.array! @data_imports do |data_import|
+ json.partial! 'api/v1/accounts/data_imports/data_import', formats: [:json], data_import: data_import
+ end
+end
diff --git a/app/views/api/v1/accounts/data_imports/show.json.jbuilder b/app/views/api/v1/accounts/data_imports/show.json.jbuilder
new file mode 100644
index 000000000..d14ae2fe3
--- /dev/null
+++ b/app/views/api/v1/accounts/data_imports/show.json.jbuilder
@@ -0,0 +1,31 @@
+json.partial! 'api/v1/accounts/data_imports/data_import', formats: [:json], data_import: @data_import
+
+json.import_errors do
+ json.array! @import_errors_finder.import_errors do |import_error|
+ json.id import_error.id
+ json.error_code import_error.error_code
+ json.message import_error.message
+ json.source_object_type import_error.source_object_type
+ json.source_object_id import_error.source_object_id
+ json.details import_error.details
+ json.created_at import_error.created_at
+ end
+end
+
+json.skip_logs do
+ json.array! @skip_logs_finder.skip_logs do |skip_log|
+ json.id skip_log.id
+ json.kind skip_log.details['kind']
+ json.error_code skip_log.error_code
+ json.message skip_log.message
+ json.source_object_type skip_log.source_object_type
+ json.source_object_id skip_log.source_object_id
+ json.details skip_log.details
+ json.created_at skip_log.created_at
+ end
+end
+
+json.skip_logs_filters do
+ json.selected_source_object_type @skip_logs_finder.selected_source_object_type
+ json.counts_by_type @skip_logs_finder.counts_by_type
+end
diff --git a/config/features.yml b/config/features.yml
index c5bc8f608..39c8a53af 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -1,11 +1,19 @@
# DO NOT change the order of features EVER
############################################
-# name: the name to be used internally in the code
-# display_name: the name to be used in the UI
-# enabled: whether the feature is enabled by default
-# help_url: the url to the help center article
-# chatwoot_internal: whether the feature is internal to Chatwoot and should not be shown in the UI for other self hosted installations
-# deprecated: purpose of feature flag is done, no need to show it in the UI anymore
+# name: the name to be used internally in the code
+# display_name: the name to be used in the UI
+# enabled: whether the feature is enabled by default
+# column: the account bitset column used to store the flag. Defaults to feature_flags.
+# Use feature_flags_ext_1 for extension flags. Each bigint column supports 63 flags.
+# help_url: the url to the help center article
+# chatwoot_internal: whether the feature is internal to Chatwoot and should not be shown in the UI for other self hosted installations
+# deprecated: purpose of feature flag is done, no need to show it in the UI anymore
+#
+# ADDING A NEW FEATURE FLAG:
+# - The `feature_flags` column is FULL (63/63 slots used). Do NOT add to it.
+# - New flags MUST set `column: feature_flags_ext_1` and be appended at the end.
+# - Bit positions are persisted per column; never reorder or remove existing
+# entries, and never change an existing feature's `column` after release.
- name: inbound_emails
display_name: Inbound Emails
enabled: true
@@ -215,10 +223,10 @@
display_name: Reply Mailer Migration
enabled: false
chatwoot_internal: true
-- name: quoted_email_reply
- display_name: Quoted Email Reply
+- name: unread_count_for_filters
+ display_name: Unread Count For Filters
enabled: false
- deprecated: true
+ chatwoot_internal: true
- name: companies
display_name: Companies
enabled: false
@@ -241,3 +249,15 @@
display_name: Advanced Assignment
enabled: false
premium: true
+- name: whatsapp_manual_transfer
+ display_name: WhatsApp Manual Transfer
+ enabled: false
+ column: feature_flags_ext_1
+- name: data_import
+ display_name: Data Import
+ enabled: false
+ column: feature_flags_ext_1
+- name: api_and_webhooks
+ display_name: API and Webhooks
+ enabled: true
+ column: feature_flags_ext_1
diff --git a/config/llm.yml b/config/llm.yml
index b54a2cbb6..2be3d86c7 100644
--- a/config/llm.yml
+++ b/config/llm.yml
@@ -129,6 +129,20 @@ features:
gemini-3-pro,
]
default: gpt-4.1-mini
+ conversation_faq_generation:
+ models:
+ [
+ gpt-4.1-mini,
+ gpt-5-mini,
+ gpt-4.1,
+ gpt-5.1,
+ gpt-5.2,
+ claude-haiku-4.5,
+ claude-sonnet-4.5,
+ gemini-3-flash,
+ gemini-3-pro,
+ ]
+ default: gpt-5.2
pdf_faq_generation:
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
default: gpt-4.1-mini
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 7aafbef0a..011957fb4 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -47,6 +47,28 @@ en:
saml_not_available: SAML authentication is not available in this installation.
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
+ data_imports:
+ intercom:
+ activities:
+ assignment: '%{actor} assigned the conversation'
+ assignment_with_target: '%{actor} assigned the conversation to %{target}'
+ assign_and_reopen: '%{actor} assigned and reopened the conversation'
+ assign_and_reopen_with_target: '%{actor} assigned the conversation to %{target} and reopened it'
+ open: '%{actor} opened the conversation'
+ close: '%{actor} closed the conversation'
+ snoozed: '%{actor} snoozed the conversation'
+ participant_added: '%{actor} added a participant'
+ participant_added_with_target: '%{actor} added %{target} as a participant'
+ participant_removed: '%{actor} removed a participant'
+ participant_removed_with_target: '%{actor} removed %{target} as a participant'
+ conversation_attribute_updated: '%{actor} updated conversation attributes'
+ ticket_attribute_updated: '%{actor} updated ticket attributes'
+ ticket_state_updated: '%{actor} updated the ticket state'
+ custom_action_started: '%{actor} started a custom action'
+ custom_action_finished: '%{actor} finished a custom action'
+ quick_reply: '%{actor} used a quick reply'
+ generic: '%{actor} recorded %{event}'
+
profile_settings:
sessions:
cannot_revoke_current: You cannot revoke the current session.
@@ -82,6 +104,8 @@ en:
file_too_large: 'File exceeds the maximum allowed size'
unsupported_content_type: 'File type not supported (only images and videos are allowed)'
unexpected: 'An unexpected error occurred'
+ database:
+ query_canceled: 'The request took too long to complete. Please try again.'
saml:
feature_not_enabled: SAML feature not enabled for this account
sso_not_enabled: SAML SSO is not enabled for this installation
@@ -595,6 +619,7 @@ en:
copilot: 'Copilot'
label_suggestion: 'Label suggestion'
document_faq_generation: 'Document FAQ generation'
+ conversation_faq_generation: 'Conversation FAQ generation'
help_center_article_generation: 'Help center article generation'
onboarding_content_generation: 'Onboarding content generation'
help_center_query_translation: 'Help center query translation'
diff --git a/config/routes.rb b/config/routes.rb
index e0a1a8e50..1481c14c7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -66,6 +66,9 @@ Rails.application.routes.draw do
resources :assistants do
member do
post :playground
+ get :stats
+ get :summary
+ get :drilldown
end
collection do
get :tools
@@ -219,6 +222,17 @@ Rails.application.routes.draw do
post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?
end
end
+ resources :data_imports, only: [:index, :show, :create] do
+ collection do
+ post :validate_source
+ end
+ member do
+ post :start
+ post :abandon
+ get :error_logs
+ get :skip_logs
+ end
+ end
resources :csat_survey_responses, only: [:index] do
collection do
get :metrics
@@ -237,6 +251,7 @@ Rails.application.routes.draw do
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
if ChatwootApp.enterprise?
+ resources :calls, only: [:index]
resources :whatsapp_calls, only: [:show] do
member do
post :accept
diff --git a/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb b/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb
new file mode 100644
index 000000000..9c15dcda6
--- /dev/null
+++ b/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb
@@ -0,0 +1,7 @@
+class AddAccountCreatedAtIndexToCalls < ActiveRecord::Migration[7.1]
+ disable_ddl_transaction!
+
+ def change
+ add_index :calls, [:account_id, :created_at], algorithm: :concurrently
+ end
+end
diff --git a/db/migrate/20260629000000_repurpose_quoted_email_reply_flag_for_unread_count_for_filters.rb b/db/migrate/20260629000000_repurpose_quoted_email_reply_flag_for_unread_count_for_filters.rb
new file mode 100644
index 000000000..82f15a964
--- /dev/null
+++ b/db/migrate/20260629000000_repurpose_quoted_email_reply_flag_for_unread_count_for_filters.rb
@@ -0,0 +1,22 @@
+class RepurposeQuotedEmailReplyFlagForUnreadCountForFilters < ActiveRecord::Migration[7.1]
+ def up
+ # The quoted_email_reply flag (deprecated) has been renamed to unread_count_for_filters.
+ # Disable it on any accounts that had quoted_email_reply enabled so the repurposed
+ # flag starts in its intended default-off state.
+ Account.feature_unread_count_for_filters.find_each(batch_size: 100) do |account|
+ account.disable_features(:unread_count_for_filters)
+ account.save!(validate: false)
+ end
+
+ # Remove the stale quoted_email_reply entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
+ # ConfigLoader only adds new flags; it never removes renamed ones.
+ # Leaving it would cause NoMethodError in enable_default_features when
+ # creating new accounts (feature_quoted_email_reply= no longer exists).
+ config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
+ return if config&.value.blank?
+
+ config.value = config.value.reject { |feature| feature['name'] == 'quoted_email_reply' }
+ config.save!
+ GlobalConfig.clear_cache
+ end
+end
diff --git a/db/migrate/20260630000000_add_sender_created_index_to_messages.rb b/db/migrate/20260630000000_add_sender_created_index_to_messages.rb
new file mode 100644
index 000000000..3424ead71
--- /dev/null
+++ b/db/migrate/20260630000000_add_sender_created_index_to_messages.rb
@@ -0,0 +1,21 @@
+class AddSenderCreatedIndexToMessages < ActiveRecord::Migration[7.1]
+ disable_ddl_transaction!
+
+ # Adds created_at to the (sender_type, sender_id) index so per-assistant
+ # windowed lookups (Captain Overview stats) can range-scan the time slice
+ # instead of reading every lifetime row and filtering at the heap. The new
+ # index is a left-prefix superset of the old one.
+ #
+ # TODO: drop the now-redundant index_messages_on_sender_type_and_sender_id
+ # once this index has been running in production long enough to confirm it
+ # fully replaces the old one.
+ def up
+ add_index :messages, [:sender_type, :sender_id, :created_at],
+ name: 'index_messages_on_sender_and_created', algorithm: :concurrently, if_not_exists: true
+ end
+
+ def down
+ remove_index :messages, name: 'index_messages_on_sender_and_created',
+ algorithm: :concurrently, if_exists: true
+ end
+end
diff --git a/db/migrate/20260702000000_expand_data_imports_for_intercom_imports.rb b/db/migrate/20260702000000_expand_data_imports_for_intercom_imports.rb
new file mode 100644
index 000000000..0dcafaab4
--- /dev/null
+++ b/db/migrate/20260702000000_expand_data_imports_for_intercom_imports.rb
@@ -0,0 +1,31 @@
+class ExpandDataImportsForIntercomImports < ActiveRecord::Migration[7.1]
+ def change
+ add_data_import_columns
+ add_data_import_indexes
+ end
+
+ private
+
+ def add_data_import_columns
+ change_table :data_imports, bulk: true do |t|
+ t.string :name
+ t.string :source_type
+ t.string :source_provider
+ t.jsonb :import_types, default: [], null: false
+ t.integer :initiated_by_id
+ t.text :access_token
+ t.jsonb :source_metadata, default: {}, null: false
+ t.jsonb :stats, default: {}, null: false
+ t.jsonb :cursor, default: {}, null: false
+ t.datetime :started_at
+ t.datetime :completed_at
+ t.datetime :abandoned_at
+ t.datetime :last_error_at
+ end
+ end
+
+ def add_data_import_indexes
+ add_index :data_imports, :initiated_by_id
+ add_index :data_imports, :source_provider
+ end
+end
diff --git a/db/migrate/20260702000001_create_data_import_items.rb b/db/migrate/20260702000001_create_data_import_items.rb
new file mode 100644
index 000000000..d01009dd4
--- /dev/null
+++ b/db/migrate/20260702000001_create_data_import_items.rb
@@ -0,0 +1,35 @@
+class CreateDataImportItems < ActiveRecord::Migration[7.1]
+ def change
+ create_data_import_items
+ add_data_import_item_indexes
+ end
+
+ private
+
+ def create_data_import_items
+ create_table :data_import_items do |t|
+ t.references :data_import, null: false, index: true
+ t.string :source_provider, null: false
+ t.string :source_object_type, null: false
+ t.string :source_object_id, null: false
+ t.integer :status, default: 0, null: false
+ t.string :chatwoot_record_type
+ t.bigint :chatwoot_record_id
+ t.integer :attempt_count, default: 0, null: false
+ t.string :last_error_code
+ t.text :last_error_message
+ t.jsonb :metadata, default: {}, null: false
+
+ t.timestamps
+ end
+ end
+
+ def add_data_import_item_indexes
+ add_index :data_import_items,
+ [:data_import_id, :source_object_type, :source_object_id],
+ unique: true,
+ name: 'idx_data_import_items_on_import_and_source'
+ add_index :data_import_items, [:chatwoot_record_type, :chatwoot_record_id], name: 'idx_data_import_items_on_record'
+ add_index :data_import_items, [:source_provider, :source_object_type, :source_object_id], name: 'idx_data_import_items_on_source'
+ end
+end
diff --git a/db/migrate/20260702000002_create_data_import_mappings.rb b/db/migrate/20260702000002_create_data_import_mappings.rb
new file mode 100644
index 000000000..4a5fecbab
--- /dev/null
+++ b/db/migrate/20260702000002_create_data_import_mappings.rb
@@ -0,0 +1,22 @@
+class CreateDataImportMappings < ActiveRecord::Migration[7.1]
+ def change
+ create_table :data_import_mappings do |t|
+ t.integer :account_id, null: false
+ t.references :data_import, null: false, index: true
+ t.string :source_provider, null: false
+ t.string :source_object_type, null: false
+ t.string :source_object_id, null: false
+ t.string :chatwoot_record_type, null: false
+ t.bigint :chatwoot_record_id, null: false
+ t.jsonb :metadata, default: {}, null: false
+
+ t.timestamps
+ end
+
+ add_index :data_import_mappings,
+ [:account_id, :source_provider, :source_object_type, :source_object_id],
+ unique: true,
+ name: 'idx_data_import_mappings_on_account_and_source'
+ add_index :data_import_mappings, [:chatwoot_record_type, :chatwoot_record_id], name: 'idx_data_import_mappings_on_record'
+ end
+end
diff --git a/db/migrate/20260702000003_create_data_import_errors.rb b/db/migrate/20260702000003_create_data_import_errors.rb
new file mode 100644
index 000000000..21ebf6f28
--- /dev/null
+++ b/db/migrate/20260702000003_create_data_import_errors.rb
@@ -0,0 +1,17 @@
+class CreateDataImportErrors < ActiveRecord::Migration[7.1]
+ def change
+ create_table :data_import_errors do |t|
+ t.references :data_import, null: false, index: true
+ t.references :data_import_item, null: true, index: true
+ t.string :source_object_type
+ t.string :source_object_id
+ t.string :error_code, null: false
+ t.text :message
+ t.jsonb :details, default: {}, null: false
+
+ t.timestamps
+ end
+
+ add_index :data_import_errors, [:source_object_type, :source_object_id], name: 'idx_data_import_errors_on_source'
+ end
+end
diff --git a/db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb b/db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb
new file mode 100644
index 000000000..ae7263aac
--- /dev/null
+++ b/db/migrate/20260706215758_add_feature_flags_ext_2_to_accounts.rb
@@ -0,0 +1,5 @@
+class AddFeatureFlagsExt2ToAccounts < ActiveRecord::Migration[7.0]
+ def change
+ add_column :accounts, :feature_flags_ext_1, :bigint, default: 0, null: false
+ end
+end
diff --git a/db/migrate/20260709091147_create_agent_sessions.rb b/db/migrate/20260709091147_create_agent_sessions.rb
new file mode 100644
index 000000000..a2e3e9f0f
--- /dev/null
+++ b/db/migrate/20260709091147_create_agent_sessions.rb
@@ -0,0 +1,24 @@
+class CreateAgentSessions < ActiveRecord::Migration[7.1]
+ def change
+ create_table :agent_sessions do |t|
+ t.integer :session_type, null: false
+ t.references :subject, polymorphic: true, null: false, index: false
+ t.references :result, polymorphic: true, index: false
+ t.references :account, null: false, index: true
+ t.references :assistant, null: false, index: true
+ t.references :user, index: true
+ t.string :llm_model
+ t.float :credits_consumed
+ t.jsonb :faq_ids, default: []
+ t.jsonb :document_ids, default: []
+ t.jsonb :scenario_ids, default: []
+ t.jsonb :run_context, default: {}
+
+ t.timestamps
+ end
+
+ add_index :agent_sessions, [:account_id, :session_type, :created_at]
+ add_index :agent_sessions, [:account_id, :subject_type, :subject_id]
+ add_index :agent_sessions, [:account_id, :result_type, :result_id]
+ end
+end
diff --git a/db/migrate/20260710000000_change_captain_assistant_description_to_text.rb b/db/migrate/20260710000000_change_captain_assistant_description_to_text.rb
new file mode 100644
index 000000000..4d68ddc3a
--- /dev/null
+++ b/db/migrate/20260710000000_change_captain_assistant_description_to_text.rb
@@ -0,0 +1,9 @@
+class ChangeCaptainAssistantDescriptionToText < ActiveRecord::Migration[7.0]
+ def up
+ change_column :captain_assistants, :description, :text
+ end
+
+ def down
+ change_column :captain_assistants, :description, :string
+ end
+end
diff --git a/db/migrate/20260713184351_create_captain_faq_suggestions.rb b/db/migrate/20260713184351_create_captain_faq_suggestions.rb
new file mode 100644
index 000000000..6bc03f387
--- /dev/null
+++ b/db/migrate/20260713184351_create_captain_faq_suggestions.rb
@@ -0,0 +1,48 @@
+class CreateCaptainFaqSuggestions < ActiveRecord::Migration[7.1]
+ def change
+ create_faq_suggestions
+ create_faq_observations
+ end
+
+ private
+
+ def create_faq_suggestions
+ create_table :captain_faq_suggestions do |t|
+ t.string :question, null: false
+ t.text :answer, null: false
+ t.vector :embedding, limit: 1536
+ t.references :assistant, null: false, index: true
+ t.references :account, null: false, index: true
+ t.string :language, null: false, default: 'en'
+ t.integer :source_count, null: false, default: 0
+ t.integer :status, null: false, default: 0
+
+ t.timestamps
+ end
+
+ add_index :captain_faq_suggestions, [:account_id, :assistant_id, :status, :language],
+ name: 'idx_cap_faq_suggestions_on_account_assistant_status_language'
+ add_index :captain_faq_suggestions, :embedding, using: :ivfflat,
+ name: 'vector_idx_captain_faq_suggestions_embedding',
+ opclass: :vector_cosine_ops
+ end
+
+ def create_faq_observations
+ create_table :captain_faq_observations do |t|
+ t.references :account, null: false, index: true
+ t.references :conversation, null: false, index: true
+ t.references :faq_suggestion, index: true
+ t.string :generated_question, null: false
+ t.text :generated_answer, null: false
+ t.string :language, null: false, default: 'en'
+ t.integer :status, null: false, default: 0
+
+ t.timestamps
+ end
+
+ add_index :captain_faq_observations, [:conversation_id, :faq_suggestion_id],
+ unique: true,
+ where: 'faq_suggestion_id IS NOT NULL',
+ name: 'idx_captain_faq_observations_on_conversation_and_suggestion'
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 05afea9a1..43e7135b9 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
+ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -73,6 +73,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.integer "status", default: 0
t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
+ t.bigint "feature_flags_ext_1", default: 0, null: false
t.index ["status"], name: "index_accounts_on_status"
end
@@ -145,6 +146,31 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id"
end
+ create_table "agent_sessions", force: :cascade do |t|
+ t.integer "session_type", null: false
+ t.string "subject_type", null: false
+ t.bigint "subject_id", null: false
+ t.string "result_type"
+ t.bigint "result_id"
+ t.bigint "account_id", null: false
+ t.bigint "assistant_id", null: false
+ t.bigint "user_id"
+ t.string "llm_model"
+ t.float "credits_consumed"
+ t.jsonb "faq_ids", default: []
+ t.jsonb "document_ids", default: []
+ t.jsonb "scenario_ids", default: []
+ t.jsonb "run_context", default: {}
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "result_type", "result_id"], name: "idx_on_account_id_result_type_result_id_ca66c00cd7"
+ t.index ["account_id", "session_type", "created_at"], name: "idx_on_account_id_session_type_created_at_c20a14bd4e"
+ t.index ["account_id", "subject_type", "subject_id"], name: "idx_on_account_id_subject_type_subject_id_6d60963b3d"
+ t.index ["account_id"], name: "index_agent_sessions_on_account_id"
+ t.index ["assistant_id"], name: "index_agent_sessions_on_assistant_id"
+ t.index ["user_id"], name: "index_agent_sessions_on_user_id"
+ end
+
create_table "applied_slas", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "sla_policy_id", null: false
@@ -282,6 +308,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.datetime "updated_at", null: false
t.index ["account_id", "contact_id"], name: "index_calls_on_account_id_and_contact_id"
t.index ["account_id", "conversation_id"], name: "index_calls_on_account_id_and_conversation_id"
+ t.index ["account_id", "created_at"], name: "index_calls_on_account_id_and_created_at"
t.index ["message_id"], name: "index_calls_on_message_id"
t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true
end
@@ -341,7 +368,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
create_table "captain_assistants", force: :cascade do |t|
t.string "name", null: false
t.bigint "account_id", null: false
- t.string "description"
+ t.text "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "config", default: {}, null: false
@@ -390,6 +417,39 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.index ["status"], name: "index_captain_documents_on_status"
end
+ create_table "captain_faq_observations", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.bigint "conversation_id", null: false
+ t.bigint "faq_suggestion_id"
+ t.string "generated_question", null: false
+ t.text "generated_answer", null: false
+ t.string "language", default: "en", null: false
+ t.integer "status", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_captain_faq_observations_on_account_id"
+ t.index ["conversation_id", "faq_suggestion_id"], name: "idx_captain_faq_observations_on_conversation_and_suggestion", unique: true, where: "(faq_suggestion_id IS NOT NULL)"
+ t.index ["conversation_id"], name: "index_captain_faq_observations_on_conversation_id"
+ t.index ["faq_suggestion_id"], name: "index_captain_faq_observations_on_faq_suggestion_id"
+ end
+
+ create_table "captain_faq_suggestions", force: :cascade do |t|
+ t.string "question", null: false
+ t.text "answer", null: false
+ t.vector "embedding", limit: 1536
+ t.bigint "assistant_id", null: false
+ t.bigint "account_id", null: false
+ t.string "language", default: "en", null: false
+ t.integer "source_count", default: 0, null: false
+ t.integer "status", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_captain_faq_suggestions_on_account_id"
+ t.index ["account_id", "assistant_id", "status", "language"], name: "idx_cap_faq_suggestions_on_account_assistant_status_language"
+ t.index ["assistant_id"], name: "index_captain_faq_suggestions_on_assistant_id"
+ t.index ["embedding"], name: "vector_idx_captain_faq_suggestions_embedding", opclass: :vector_cosine_ops, using: :ivfflat
+ end
+
create_table "captain_inboxes", force: :cascade do |t|
t.bigint "captain_assistant_id", null: false
t.bigint "inbox_id", null: false
@@ -839,6 +899,57 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.index ["user_id"], name: "index_dashboard_apps_on_user_id"
end
+ create_table "data_import_errors", force: :cascade do |t|
+ t.bigint "data_import_id", null: false
+ t.bigint "data_import_item_id"
+ t.string "source_object_type"
+ t.string "source_object_id"
+ t.string "error_code", null: false
+ t.text "message"
+ t.jsonb "details", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["data_import_id"], name: "index_data_import_errors_on_data_import_id"
+ t.index ["data_import_item_id"], name: "index_data_import_errors_on_data_import_item_id"
+ t.index ["source_object_type", "source_object_id"], name: "idx_data_import_errors_on_source"
+ end
+
+ create_table "data_import_items", force: :cascade do |t|
+ t.bigint "data_import_id", null: false
+ t.string "source_provider", null: false
+ t.string "source_object_type", null: false
+ t.string "source_object_id", null: false
+ t.integer "status", default: 0, null: false
+ t.string "chatwoot_record_type"
+ t.bigint "chatwoot_record_id"
+ t.integer "attempt_count", default: 0, null: false
+ t.string "last_error_code"
+ t.text "last_error_message"
+ t.jsonb "metadata", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["chatwoot_record_type", "chatwoot_record_id"], name: "idx_data_import_items_on_record"
+ t.index ["data_import_id", "source_object_type", "source_object_id"], name: "idx_data_import_items_on_import_and_source", unique: true
+ t.index ["data_import_id"], name: "index_data_import_items_on_data_import_id"
+ t.index ["source_provider", "source_object_type", "source_object_id"], name: "idx_data_import_items_on_source"
+ end
+
+ create_table "data_import_mappings", force: :cascade do |t|
+ t.integer "account_id", null: false
+ t.bigint "data_import_id", null: false
+ t.string "source_provider", null: false
+ t.string "source_object_type", null: false
+ t.string "source_object_id", null: false
+ t.string "chatwoot_record_type", null: false
+ t.bigint "chatwoot_record_id", null: false
+ t.jsonb "metadata", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "source_provider", "source_object_type", "source_object_id"], name: "idx_data_import_mappings_on_account_and_source", unique: true
+ t.index ["chatwoot_record_type", "chatwoot_record_id"], name: "idx_data_import_mappings_on_record"
+ t.index ["data_import_id"], name: "index_data_import_mappings_on_data_import_id"
+ end
+
create_table "data_imports", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "data_type", null: false
@@ -848,7 +959,22 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.integer "processed_records"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.string "name"
+ t.string "source_type"
+ t.string "source_provider"
+ t.jsonb "import_types", default: [], null: false
+ t.integer "initiated_by_id"
+ t.text "access_token"
+ t.jsonb "source_metadata", default: {}, null: false
+ t.jsonb "stats", default: {}, null: false
+ t.jsonb "cursor", default: {}, null: false
+ t.datetime "started_at"
+ t.datetime "completed_at"
+ t.datetime "abandoned_at"
+ t.datetime "last_error_at"
t.index ["account_id"], name: "index_data_imports_on_account_id"
+ t.index ["initiated_by_id"], name: "index_data_imports_on_initiated_by_id"
+ t.index ["source_provider"], name: "index_data_imports_on_source_provider"
end
create_table "email_templates", force: :cascade do |t|
@@ -1034,6 +1160,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
t.index ["created_at"], name: "index_messages_on_created_at"
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
+ t.index ["sender_type", "sender_id", "created_at"], name: "index_messages_on_sender_and_created"
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
t.index ["source_id"], name: "index_messages_on_source_id"
end
diff --git a/enterprise/app/builders/captain/assistant_drilldown_builder.rb b/enterprise/app/builders/captain/assistant_drilldown_builder.rb
new file mode 100644
index 000000000..fb5d3a7e8
--- /dev/null
+++ b/enterprise/app/builders/captain/assistant_drilldown_builder.rb
@@ -0,0 +1,135 @@
+# Lists the underlying records behind a single Captain assistant stat card, so a
+# viewer can drill from an aggregate (e.g. "auto-resolution 42%") into the exact
+# conversations that produced it.
+#
+# The window is resolved by Captain::AssistantStatsWindow from the same `range`
+# and `timezone_offset` the stat card used, so the drilldown covers precisely the
+# rows the card counted. Records are serialized with the shared reports drilldown
+# serializer, so the existing frontend drilldown drawer/card can render them.
+class Captain::AssistantDrilldownBuilder
+ ASSISTANT_SENDER_TYPE = 'Captain::Assistant'.freeze
+ RESOLVED_EVENT_NAMES = Captain::AssistantStatsBuilder::RESOLVED_EVENT_NAMES
+ HANDOFF_EVENT_NAMES = Captain::AssistantStatsBuilder::HANDOFF_EVENT_NAMES
+
+ SUPPORTED_METRICS = %w[
+ conversations_handled auto_resolution_rate handoff_rate reopen_rate
+ ].freeze
+
+ DEFAULT_PAGE = 1
+ DEFAULT_PER_PAGE = 25
+ MAX_PER_PAGE = 100
+
+ pattr_initialize :assistant, :params
+
+ def self.supported_metric?(metric) = SUPPORTED_METRICS.include?(metric.to_s)
+
+ def build
+ records = paginated_records.to_a
+ { meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
+ end
+
+ private
+
+ def account = assistant.account
+
+ def window
+ @window ||= Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
+ end
+
+ def range = window.current
+
+ def meta
+ {
+ metric: metric,
+ current_page: current_page,
+ per_page: per_page,
+ total_count: paginated_records.total_count,
+ conversation_count: paginated_records.total_count,
+ range: { since: range.first.to_i, until: range.last.to_i }
+ }
+ end
+
+ def paginated_records
+ @paginated_records ||= drilldown_scope.page(current_page).per(per_page)
+ end
+
+ def drilldown_scope
+ case metric
+ when 'conversations_handled' then handled_conversations
+ when 'auto_resolution_rate' then conversations_for(resolved_events.select(:conversation_id))
+ when 'handoff_rate' then event_conversations(HANDOFF_EVENT_NAMES)
+ when 'reopen_rate' then reopened_conversations
+ else
+ raise ArgumentError, "Unsupported assistant drilldown metric: #{metric}"
+ end
+ end
+
+ # Messages the assistant authored in the window; the cohort every metric derives from.
+ def handled_messages
+ account.messages.where(sender_type: ASSISTANT_SENDER_TYPE, sender_id: assistant.id, created_at: range)
+ end
+
+ def handled_conversation_ids
+ handled_messages.select(:conversation_id)
+ end
+
+ def handled_conversations
+ conversations_for(handled_conversation_ids)
+ end
+
+ # Conversations in the handled cohort that recorded one of the given reporting
+ # events in the window (resolved or handed-off).
+ def event_conversations(event_names)
+ ids = account.reporting_events
+ .where(name: event_names, created_at: range, conversation_id: handled_conversation_ids)
+ .select(:conversation_id)
+ conversations_for(ids)
+ end
+
+ # Captain resolves in the window, excluding bot-resolved rows whose conversation
+ # was also handed off, mirroring AssistantStatsBuilder#resolved_clause so the
+ # drilldown lists exactly the conversations the auto-resolution card counted.
+ def resolved_events
+ handoff_ids = account.reporting_events.where(name: HANDOFF_EVENT_NAMES, created_at: range).select(:conversation_id)
+ account.reporting_events
+ .where(name: RESOLVED_EVENT_NAMES, created_at: range, conversation_id: handled_conversation_ids)
+ .where("NOT (name = ? AND conversation_id IN (#{handoff_ids.to_sql}))",
+ Captain::AssistantStatsBuilder::BOT_RESOLVED_EVENT_NAME)
+ end
+
+ # Auto-resolved conversations that reopened at/after their Captain resolve,
+ # mirroring AssistantStatsBuilder#reopen_rate's numerator cohort.
+ def reopened_conversations
+ ids = account.reporting_events
+ .where(name: 'conversation_opened')
+ .where('reporting_events.value > 0')
+ .where('reporting_events.event_end_time <= ?', range.last)
+ .joins("INNER JOIN (#{resolved_events.to_sql}) resolves " \
+ 'ON resolves.conversation_id = reporting_events.conversation_id ' \
+ 'AND reporting_events.event_end_time >= resolves.event_end_time')
+ .select('reporting_events.conversation_id')
+ conversations_for(ids)
+ end
+
+ def conversations_for(conversation_ids)
+ account.conversations
+ .where(id: conversation_ids)
+ .includes(:assignee, :contact, :inbox)
+ .order(created_at: :desc)
+ end
+
+ def record_serializer(records)
+ @record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(account, metric, false, records)
+ end
+
+ def metric = params[:metric].to_s
+
+ def current_page = [params[:page].to_i, DEFAULT_PAGE].max
+
+ def per_page
+ requested_per_page = params[:per_page].to_i
+ requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
+
+ [requested_per_page, MAX_PER_PAGE].min
+ end
+end
diff --git a/enterprise/app/builders/captain/assistant_stats_builder.rb b/enterprise/app/builders/captain/assistant_stats_builder.rb
new file mode 100644
index 000000000..d162406ad
--- /dev/null
+++ b/enterprise/app/builders/captain/assistant_stats_builder.rb
@@ -0,0 +1,219 @@
+# Computes per-assistant overview metrics for the Captain Overview page.
+# Each metric is returned for the current window and the previous equal-length
+# window, plus a derived trend.
+#
+# Queries are batched to cut round trips: the message-derived counts (handled,
+# public replies, depth) are computed for both windows in a single scan via
+# conditional FILTER aggregation.
+class Captain::AssistantStatsBuilder
+ RESOLVED_EVENT_NAMES = %w[conversation_captain_inference_resolved conversation_bot_resolved].freeze
+ HANDOFF_EVENT_NAMES = %w[conversation_captain_inference_handoff conversation_bot_handoff].freeze
+ BOT_RESOLVED_EVENT_NAME = 'conversation_bot_resolved'.freeze
+
+ # Assumed agent effort displaced by each public assistant reply. Reporting data
+ # only captures reply latency (customer wait time), not handling effort, so hours
+ # saved is a count-times-assumed-effort estimate rather than a measured duration.
+ SECONDS_SAVED_PER_REPLY = 2.minutes.to_i
+
+ attr_reader :assistant, :account
+
+ delegate :range, :period, to: :window
+
+ # `range` is either a day count ('7', '30', '90') or a named period
+ # ('this_month', 'last_month'). `timezone_offset` is the viewer's UTC offset in
+ # hours (as the reports API sends it), so month/day boundaries anchor to the
+ # viewer's day rather than UTC. Both windows are resolved by AssistantStatsWindow.
+ def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil)
+ @assistant = assistant
+ @account = assistant.account
+ @window = Captain::AssistantStatsWindow.new(range, timezone_offset)
+ end
+
+ def metrics
+ messages = message_window_metrics
+ current = window_metrics(current_range, messages[:current])
+ previous = window_metrics(previous_range, messages[:previous])
+
+ build_metrics(current, previous)
+ end
+
+ private
+
+ attr_reader :window
+
+ def current_range
+ window.current
+ end
+
+ def previous_range
+ window.previous
+ end
+
+ def build_metrics(current, previous)
+ {
+ conversations_handled: pack(current[:handled], previous[:handled], :percent),
+ auto_resolution_rate: pack(current[:auto_resolution], previous[:auto_resolution], :point),
+ handoff_rate: pack(current[:handoff], previous[:handoff], :point),
+ hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
+ reopen_rate: pack(current[:reopen], previous[:reopen], :point),
+ conversation_depth: pack(current[:depth], previous[:depth], :absolute),
+ knowledge: knowledge
+ }
+ end
+
+ # Combines the per-window message counts with the reporting-event metrics for one window.
+ def window_metrics(range, message_counts)
+ handled = message_counts[:handled]
+ public_count = message_counts[:public_count]
+ depth_conversations = message_counts[:depth_conversations]
+ resolution = resolution_counts(range)
+
+ {
+ handled: handled,
+ auto_resolution: rate(resolution[:resolved], handled),
+ handoff: rate(resolution[:handoff], handled),
+ hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
+ reopen: reopen_rate(range),
+ depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
+ }
+ end
+
+ # One scan over the assistant's messages computes handled, public-reply count,
+ # and depth-conversation count for both windows via conditional aggregation.
+ def message_window_metrics
+ public_clause = "message_type = #{Message.message_types[:outgoing]} AND private = false"
+ cur = window_clause(current_range)
+ prev = window_clause(previous_range)
+
+ row = handled_scope(full_span).reorder(nil).pick(
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev})"),
+ Arel.sql("COUNT(*) FILTER (WHERE #{cur} AND #{public_clause})"),
+ Arel.sql("COUNT(*) FILTER (WHERE #{prev} AND #{public_clause})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur} AND #{public_clause})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev} AND #{public_clause})")
+ )
+
+ {
+ current: { handled: row[0], public_count: row[2], depth_conversations: row[4] },
+ previous: { handled: row[1], public_count: row[3], depth_conversations: row[5] }
+ }
+ end
+
+ # Resolved and handed-off conversation counts for one window, in a single scan
+ # of the handled set's reporting events.
+ def resolution_counts(range)
+ row = account.reporting_events
+ .where(name: RESOLVED_EVENT_NAMES + HANDOFF_EVENT_NAMES,
+ created_at: range,
+ conversation_id: handled_scope(range).select(:conversation_id))
+ .reorder(nil)
+ .pick(
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{resolved_clause(range)})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE name IN (#{quoted(HANDOFF_EVENT_NAMES)}))")
+ )
+ { resolved: row[0], handoff: row[1] }
+ end
+
+ # A countable resolve is any inference resolve, or a bot resolve on a conversation
+ # with no handoff in the window. conversation_bot_resolved fires on any resolve
+ # without an agent message (reporting_event_listener), so a handed-off conversation
+ # that goes quiet and gets closed would otherwise count as an auto-resolution too;
+ # the reports bot_resolutions metric applies the same exclusion (:exclude_bot_handoffs).
+ def resolved_clause(range)
+ "name IN (#{quoted(RESOLVED_EVENT_NAMES)}) AND #{bot_resolve_handoff_exclusion(range)}"
+ end
+
+ def bot_resolve_handoff_exclusion(range)
+ "NOT (name = #{quote(BOT_RESOLVED_EVENT_NAME)} AND conversation_id IN (#{handoff_conversation_ids(range).to_sql}))"
+ end
+
+ def handoff_conversation_ids(range)
+ account.reporting_events.where(name: HANDOFF_EVENT_NAMES, created_at: range).select(:conversation_id)
+ end
+
+ # Conversations the assistant participated in (authored any message).
+ def handled_scope(range)
+ account.messages.where(sender_type: 'Captain::Assistant', sender_id: assistant.id, created_at: range)
+ end
+
+ # Span covering both windows so a single scan can split them with FILTER.
+ def full_span
+ [current_range.first, previous_range.first].min..current_range.last
+ end
+
+ def window_clause(range)
+ "created_at >= #{quote(range.first)} AND created_at <= #{quote(range.last)}"
+ end
+
+ def quote(value)
+ account.class.connection.quote(value)
+ end
+
+ def quoted(values)
+ values.map { |value| quote(value) }.join(', ')
+ end
+
+ # Of the conversations Captain auto-resolved, the share reopened afterwards. The cohort is
+ # derived from the assistant's handled conversations (not current inbox membership) so a later
+ # inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
+ # and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
+ def reopen_rate(range)
+ resolved_scope = account.reporting_events
+ .where(name: RESOLVED_EVENT_NAMES, created_at: range,
+ conversation_id: handled_scope(range).select(:conversation_id))
+ .where(bot_resolve_handoff_exclusion(range))
+ # event_end_time on a reopen is when it actually reopened. Join it to the conversation's own
+ # Captain resolves and keep only reopens at/after one of them, so a human resolve/reopen earlier
+ # in the same window isn't mistaken for a reopen-after-Captain-resolve. (Comparing the reopen's
+ # start time instead would misfire: the inference event is dispatched just after the generic
+ # conversation_resolved that seeds event_start_time, so it can land after the reopen's start.)
+ # The reopen itself must also fall inside the window, so a completed range (last_month, the
+ # previous window) doesn't count reopens that happened after it ended.
+ reopened = account.reporting_events
+ .where(name: 'conversation_opened')
+ .where('reporting_events.value > 0')
+ .where('reporting_events.event_end_time <= ?', range.last)
+ .joins("INNER JOIN (#{resolved_scope.to_sql}) resolves " \
+ 'ON resolves.conversation_id = reporting_events.conversation_id ' \
+ 'AND reporting_events.event_end_time >= resolves.event_end_time')
+ .distinct.count('reporting_events.conversation_id')
+ rate(reopened, resolved_scope.distinct.count(:conversation_id))
+ end
+
+ # Approved/pending FAQ counts and the document total in a single round trip.
+ def knowledge
+ approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
+ Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
+ Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
+ Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
+ )
+ total = approved + pending
+
+ {
+ approved: approved,
+ pending: pending,
+ documents: documents,
+ coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
+ }
+ end
+
+ def rate(numerator, denominator)
+ return 0 if denominator.zero?
+
+ (numerator.to_f / denominator * 100).round(1)
+ end
+
+ def pack(current, previous, mode)
+ { current: current, previous: previous, trend: trend(current, previous, mode) }
+ end
+
+ def trend(current, previous, mode)
+ case mode
+ when :percent
+ previous.zero? ? 0 : ((current - previous).to_f / previous * 100).round(1)
+ else # :point and :absolute are both current - previous
+ (current - previous).round(1)
+ end
+ end
+end
diff --git a/enterprise/app/builders/captain/assistant_stats_window.rb b/enterprise/app/builders/captain/assistant_stats_window.rb
new file mode 100644
index 000000000..4495ef1e8
--- /dev/null
+++ b/enterprise/app/builders/captain/assistant_stats_window.rb
@@ -0,0 +1,78 @@
+# Resolves the current and previous comparison windows for Captain assistant
+# stats. `range` is either a day count ('7', '30', '90') or a named period
+# ('this_month', 'last_month'). The previous window mirrors the current one: the
+# preceding N days for day ranges, or the preceding month for month ranges.
+# `timezone_offset` is the viewer's UTC offset in hours (as the reports API sends
+# it), so month/day boundaries anchor to the viewer's day rather than UTC.
+#
+# Shared by Captain::AssistantStatsBuilder (which needs both windows) and
+# Captain::AssistantDrilldownBuilder (which drills into the current window), so a
+# drilldown always covers exactly the rows its stat card counted.
+class Captain::AssistantStatsWindow
+ include TimezoneHelper
+
+ DEFAULT_RANGE = '30'.freeze
+ ALLOWED_RANGES = %w[7 30 90 this_month last_month].freeze
+
+ attr_reader :range
+
+ def initialize(range = DEFAULT_RANGE, timezone_offset = nil)
+ @range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
+ @timezone = timezone_name_from_offset(timezone_offset) || Time.zone
+ end
+
+ def current
+ resolved_ranges[:current]
+ end
+
+ def previous
+ resolved_ranges[:previous]
+ end
+
+ # Human-readable description of the period the current window covers, for
+ # grounding the LLM summary in real dates.
+ def period
+ { label: period_label, starts_on: current.first.to_date, ends_on: current.last.to_date }
+ end
+
+ private
+
+ def resolved_ranges
+ @resolved_ranges ||= case range
+ when 'this_month' then this_month_ranges
+ when 'last_month' then last_month_ranges
+ else day_ranges
+ end
+ end
+
+ # Current time anchored to the viewer's timezone, so calendar boundaries land on
+ # the viewer's day instead of UTC's.
+ def now
+ @now ||= Time.current.in_time_zone(@timezone)
+ end
+
+ def this_month_ranges
+ start = now.beginning_of_month
+ elapsed = now - start
+ previous_start = start - 1.month
+ # Clamp to the previous month's end so a longer current month can't pull the
+ # comparison window into the current month and double-count its rows.
+ previous_end = [previous_start + elapsed, previous_start.end_of_month].min
+ { current: start..now, previous: previous_start..previous_end }
+ end
+
+ def last_month_ranges
+ start = (now - 1.month).beginning_of_month
+ previous_start = start - 1.month
+ { current: start..start.end_of_month, previous: previous_start..previous_start.end_of_month }
+ end
+
+ def day_ranges
+ days = range.to_i
+ { current: (now - days.days)..now, previous: (now - (2 * days).days)..(now - days.days) }
+ end
+
+ def period_label
+ { 'this_month' => 'this month', 'last_month' => 'last month' }[range] || "the last #{range.to_i} days"
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/accounts/calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/calls_controller.rb
new file mode 100644
index 000000000..71772e4a0
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/calls_controller.rb
@@ -0,0 +1,7 @@
+class Api::V1::Accounts::CallsController < Api::V1::Accounts::EnterpriseAccountsController
+ def index
+ result = CallFinder.new(Current.user, Current.account, params).perform
+ @calls = result[:calls]
+ @calls_count = result[:count]
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index 282385a4a..94a031973 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
- before_action :set_assistant, only: [:show, :update, :destroy, :playground]
+ before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
def index
@assistants = account_assistants.ordered
@@ -43,8 +43,53 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
@tools = assistant.available_agent_tools
end
+ def stats
+ render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
+ end
+
+ def summary
+ result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]))
+
+ if result[:error]
+ render json: { error: result[:error] }, status: :unprocessable_content
+ else
+ render json: { message: result[:message] }
+ end
+ end
+
+ def drilldown
+ return head :unprocessable_entity unless Captain::AssistantDrilldownBuilder.supported_metric?(params[:metric])
+
+ render json: Captain::AssistantDrilldownBuilder.new(@assistant, drilldown_params).build
+ end
+
private
+ def drilldown_params
+ params.permit(:metric, :range, :timezone_offset, :page, :per_page)
+ end
+
+ def cached_or_generated_summary(builder)
+ cache_key = summary_cache_key(builder.range)
+ cached = Rails.cache.read(cache_key)
+ return cached if cached
+
+ result = Captain::OverviewSummaryService.new(
+ account: Current.account,
+ assistant: @assistant,
+ first_name: Current.user.name.to_s.split.first,
+ stats: builder.metrics,
+ period: builder.period
+ ).perform
+ # Don't cache transient LLM/config failures, otherwise every reload returns 422 for the next hour.
+ Rails.cache.write(cache_key, result, expires_in: 1.hour) unless result[:error]
+ result
+ end
+
+ def summary_cache_key(range)
+ "captain_overview_summary/#{@assistant.id}/#{Current.user.id}/#{range}/#{Date.current}"
+ end
+
def set_assistant
@assistant = account_assistants.find(params[:id])
end
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
index 273c082b1..d88cc6b48 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
@@ -9,16 +9,10 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
RESULTS_PER_PAGE = 25
def index
- base_query = @documents
- base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
- base_query = apply_source_filter(base_query, permitted_params[:source])
- base_query = apply_filter(base_query, permitted_params[:filter])
- base_query = apply_search(base_query, permitted_params[:search_key])
- base_query = apply_sort(base_query, permitted_params[:sort])
-
- @documents_count = base_query.count
+ @documents = filtered_documents
+ @documents_count = @documents.count
@sync_interval_hours = current_sync_interval&.in_hours&.to_i
- @documents = base_query.page(@current_page).per(RESULTS_PER_PAGE)
+ @documents = with_responses_count(@documents).page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
@@ -59,6 +53,21 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
@documents = Current.account.captain_documents.with_attached_pdf_file.includes(:assistant)
end
+ def filtered_documents
+ documents = @documents
+ documents = documents.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
+ documents = apply_source_filter(documents, permitted_params[:source])
+ documents = apply_filter(documents, permitted_params[:filter])
+ documents = apply_search(documents, permitted_params[:search_key])
+ apply_sort(documents, permitted_params[:sort])
+ end
+
+ def with_responses_count(scope)
+ scope.left_joins(:responses)
+ .select('captain_documents.*, COUNT(captain_assistant_responses.id) AS responses_count')
+ .group('captain_documents.id')
+ end
+
def set_document
@document = @documents.find(permitted_params[:id])
end
diff --git a/enterprise/app/fields/captain_model_overrides_field.rb b/enterprise/app/fields/captain_model_overrides_field.rb
index a8f3fe399..c361d511a 100644
--- a/enterprise/app/fields/captain_model_overrides_field.rb
+++ b/enterprise/app/fields/captain_model_overrides_field.rb
@@ -29,6 +29,8 @@ class CaptainModelOverridesField < Administrate::Field::Base
end
def default_model_id(feature_key)
+ return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && resource.feature_enabled?('captain_integration_v2')
+
Llm::Models.default_model_for(feature_key)
end
diff --git a/enterprise/app/finders/call_finder.rb b/enterprise/app/finders/call_finder.rb
new file mode 100644
index 000000000..31d6ae5f2
--- /dev/null
+++ b/enterprise/app/finders/call_finder.rb
@@ -0,0 +1,70 @@
+class CallFinder
+ RESULTS_PER_PAGE = 25
+
+ def initialize(current_user, current_account, params)
+ @current_user = current_user
+ @current_account = current_account
+ @params = params
+ end
+
+ def perform
+ @calls = @current_account.calls
+ filter_by_visibility
+ filter_by_status
+ filter_by_direction
+ filter_by_inbox
+ filter_by_agent
+ filter_by_date_range
+
+ { calls: paginated_calls, count: @calls.count }
+ end
+
+ private
+
+ # Admins and report managers see the whole account; everyone else only sees
+ # calls they handled within conversations they can still access.
+ def filter_by_visibility
+ return if account_wide_access?
+
+ @calls = @calls.where(accepted_by_agent_id: @current_user.id, conversation_id: accessible_conversations)
+ end
+
+ def accessible_conversations
+ Conversations::PermissionFilterService.new(@current_account.conversations, @current_user, @current_account).perform.select(:id)
+ end
+
+ def account_wide_access?
+ account_user = Current.account_user
+ account_user&.administrator? || account_user&.custom_role&.permissions&.include?('report_manage')
+ end
+
+ def filter_by_status
+ @calls = @calls.where(status: Call.status_from_display(@params[:status])) if @params[:status].present?
+ end
+
+ def filter_by_direction
+ @calls = @calls.where(direction: Call.direction_from_label(@params[:direction])) if @params[:direction].present?
+ end
+
+ def filter_by_inbox
+ @calls = @calls.where(inbox_id: @params[:inbox_id]) if @params[:inbox_id].present?
+ end
+
+ def filter_by_agent
+ @calls = @calls.where(accepted_by_agent_id: @params[:agent_id]) if @params[:agent_id].present?
+ end
+
+ # since/until are unix timestamps, matching DateRangeHelper conventions.
+ def filter_by_date_range
+ return if @params[:since].blank? || @params[:until].blank?
+
+ @calls = @calls.where(created_at: Time.zone.at(@params[:since].to_i)..Time.zone.at(@params[:until].to_i))
+ end
+
+ def paginated_calls
+ @calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
+ .order(created_at: :desc)
+ .page(@params[:page] || 1)
+ .per(RESULTS_PER_PAGE)
+ end
+end
diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb
index 71dfac100..8c76103ea 100644
--- a/enterprise/app/models/call.rb
+++ b/enterprise/app/models/call.rb
@@ -78,6 +78,17 @@ class Call < ApplicationRecord
DISPLAY_DIRECTION[direction]
end
+ # Normalize filter values back to stored forms so API/dashboard clients can
+ # query using either the display value (inbound/outbound, in-progress) or the
+ # stored value (incoming/outgoing, in_progress).
+ def self.direction_from_label(value)
+ DISPLAY_DIRECTION.key(value) || value
+ end
+
+ def self.status_from_display(value)
+ value.to_s.tr('-', '_')
+ end
+
def ringing?
status == 'ringing'
end
diff --git a/enterprise/app/models/captain/agent_session.rb b/enterprise/app/models/captain/agent_session.rb
new file mode 100644
index 000000000..d02dffcab
--- /dev/null
+++ b/enterprise/app/models/captain/agent_session.rb
@@ -0,0 +1,86 @@
+# == Schema Information
+#
+# Table name: agent_sessions
+#
+# id :bigint not null, primary key
+# credits_consumed :float
+# document_ids :jsonb
+# faq_ids :jsonb
+# llm_model :string
+# result_type :string
+# run_context :jsonb
+# scenario_ids :jsonb
+# session_type :integer not null
+# subject_type :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+# assistant_id :bigint not null
+# result_id :bigint
+# subject_id :bigint not null
+# user_id :bigint
+#
+# Indexes
+#
+# idx_on_account_id_result_type_result_id_ca66c00cd7 (account_id,result_type,result_id)
+# idx_on_account_id_session_type_created_at_c20a14bd4e (account_id,session_type,created_at)
+# idx_on_account_id_subject_type_subject_id_6d60963b3d (account_id,subject_type,subject_id)
+# index_agent_sessions_on_account_id (account_id)
+# index_agent_sessions_on_assistant_id (assistant_id)
+# index_agent_sessions_on_user_id (user_id)
+#
+class Captain::AgentSession < ApplicationRecord
+ self.table_name = 'agent_sessions'
+
+ SUBJECT_TYPES = { 'assistant' => 'Conversation', 'copilot' => 'CopilotThread' }.freeze
+ RESULT_TYPES = { 'assistant' => 'Message', 'copilot' => 'CopilotMessage' }.freeze
+
+ belongs_to :account
+ belongs_to :assistant, class_name: 'Captain::Assistant'
+ belongs_to :user, optional: true
+ belongs_to :subject, ->(session) { where(account_id: session.account_id) }, polymorphic: true
+ belongs_to :result, ->(session) { where(account_id: session.account_id) }, polymorphic: true, optional: true
+
+ enum :session_type, { assistant: 0, copilot: 1 }, prefix: :session
+
+ before_validation :ensure_account
+
+ validate :subject_type_matches_session_type
+ validate :result_type_matches_session_type, if: -> { result_type.present? }
+ validate :subject_belongs_to_account
+ validate :result_belongs_to_account, if: -> { result_id.present? }
+
+ private
+
+ def ensure_account
+ self.account = assistant&.account
+ end
+
+ def subject_type_matches_session_type
+ expected_type = SUBJECT_TYPES[session_type]
+ return if subject_type == expected_type
+
+ errors.add(:subject_type, "must be #{expected_type} for #{session_type} sessions")
+ end
+
+ def result_type_matches_session_type
+ expected_type = RESULT_TYPES[session_type]
+ return if result_type == expected_type
+
+ errors.add(:result_type, "must be #{expected_type} for #{session_type} sessions")
+ end
+
+ def subject_belongs_to_account
+ return if subject.nil? || subject.account_id == account_id
+
+ errors.add(:subject, 'must belong to the session account')
+ end
+
+ def result_belongs_to_account
+ target_class = result_type.safe_constantize
+ actual_account_id = target_class && target_class.unscoped.where(id: result_id).pick(:account_id)
+ return if actual_account_id == account_id
+
+ errors.add(:result, 'must belong to the session account')
+ end
+end
diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb
index 55558879e..1b1b05c87 100644
--- a/enterprise/app/models/captain/assistant.rb
+++ b/enterprise/app/models/captain/assistant.rb
@@ -4,7 +4,7 @@
#
# id :bigint not null, primary key
# config :jsonb not null
-# description :string
+# description :text
# guardrails :jsonb
# name :string not null
# response_guidelines :jsonb
@@ -17,6 +17,8 @@
# index_captain_assistants_on_account_id (account_id)
#
class Captain::Assistant < ApplicationRecord
+ DESCRIPTION_LENGTH_LIMIT = 500
+
include Avatarable
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
@@ -26,6 +28,7 @@ class Captain::Assistant < ApplicationRecord
belongs_to :account
has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async
+ has_many :faq_suggestions, class_name: 'Captain::FaqSuggestion', dependent: :destroy_async
has_many :captain_inboxes,
class_name: 'CaptainInbox',
foreign_key: :captain_assistant_id,
@@ -35,13 +38,14 @@ class Captain::Assistant < ApplicationRecord
has_many :messages, as: :sender, dependent: :nullify
has_many :copilot_threads, dependent: :destroy_async
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
+ has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name, :response_window
RESPONSE_WINDOWS = %w[always business_hours outside_business_hours].freeze
validates :name, presence: true
- validates :description, presence: true
+ validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
validates :account_id, presence: true
validate :validate_audience_structure
validate :validate_response_window
diff --git a/enterprise/app/models/captain/faq_observation.rb b/enterprise/app/models/captain/faq_observation.rb
new file mode 100644
index 000000000..15c5e1284
--- /dev/null
+++ b/enterprise/app/models/captain/faq_observation.rb
@@ -0,0 +1,42 @@
+# == Schema Information
+#
+# Table name: captain_faq_observations
+#
+# id :bigint not null, primary key
+# generated_answer :text not null
+# generated_question :string not null
+# language :string default("en"), not null
+# status :integer default("attached"), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+# conversation_id :bigint not null
+# faq_suggestion_id :bigint
+#
+class Captain::FaqObservation < ApplicationRecord
+ self.table_name = 'captain_faq_observations'
+
+ belongs_to :account
+ belongs_to :conversation, class_name: '::Conversation'
+ belongs_to :faq_suggestion, class_name: 'Captain::FaqSuggestion', optional: true, inverse_of: :observations
+
+ enum status: { attached: 0, discarded: 1 }
+
+ validates :generated_question, :generated_answer, :language, presence: true
+ validates :faq_suggestion, presence: true, if: :attached?
+ validate :faq_suggestion_belongs_to_account
+
+ before_validation :ensure_account
+
+ private
+
+ def ensure_account
+ self.account = conversation&.account
+ end
+
+ def faq_suggestion_belongs_to_account
+ return if faq_suggestion.blank? || faq_suggestion.account_id == account_id
+
+ errors.add(:faq_suggestion, :invalid)
+ end
+end
diff --git a/enterprise/app/models/captain/faq_suggestion.rb b/enterprise/app/models/captain/faq_suggestion.rb
new file mode 100644
index 000000000..047d5e1fe
--- /dev/null
+++ b/enterprise/app/models/captain/faq_suggestion.rb
@@ -0,0 +1,51 @@
+# == Schema Information
+#
+# Table name: captain_faq_suggestions
+#
+# id :bigint not null, primary key
+# answer :text not null
+# embedding :vector(1536)
+# language :string default("en"), not null
+# question :string not null
+# source_count :integer default(0), not null
+# status :integer default("open"), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+# assistant_id :bigint not null
+#
+class Captain::FaqSuggestion < ApplicationRecord
+ self.table_name = 'captain_faq_suggestions'
+
+ belongs_to :assistant, class_name: 'Captain::Assistant'
+ belongs_to :account
+ has_many :observations,
+ class_name: 'Captain::FaqObservation',
+ dependent: :delete_all,
+ inverse_of: :faq_suggestion
+ has_neighbors :embedding, normalize: true
+
+ enum status: { open: 0, approved: 1, dismissed: 2 }
+
+ validates :question, :answer, :language, presence: true
+
+ before_validation :ensure_account
+ after_commit :update_embedding, on: [:create, :update]
+
+ scope :ordered, -> { order(source_count: :desc, updated_at: :desc) }
+ scope :by_language, ->(language) { where(language: language) }
+
+ private
+
+ def ensure_account
+ self.account = assistant&.account
+ end
+
+ def update_embedding
+ return unless open?
+ return unless saved_change_to_question? || saved_change_to_answer? || embedding.nil?
+ return if previously_new_record? && embedding.present?
+
+ Captain::Llm::UpdateEmbeddingJob.perform_later(self, "#{question}: #{answer}")
+ end
+end
diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb
index 8a6a3c979..895133897 100644
--- a/enterprise/app/models/captain/scenario.rb
+++ b/enterprise/app/models/captain/scenario.rb
@@ -21,6 +21,8 @@
# index_captain_scenarios_on_enabled (enabled)
#
class Captain::Scenario < ApplicationRecord
+ DESCRIPTION_LENGTH_LIMIT = 500
+
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
@@ -43,7 +45,7 @@ class Captain::Scenario < ApplicationRecord
belongs_to :account
validates :title, presence: true
- validates :description, presence: true
+ validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
validates :instruction, presence: true
validates :assistant_id, presence: true
validates :account_id, presence: true
diff --git a/enterprise/app/models/concerns/agentable.rb b/enterprise/app/models/concerns/agentable.rb
index b5a6cd1b9..086deedc1 100644
--- a/enterprise/app/models/concerns/agentable.rb
+++ b/enterprise/app/models/concerns/agentable.rb
@@ -47,7 +47,7 @@ module Concerns::Agentable
def agent_model
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
- return route[:model] if route[:source] == :account_override
+ return route[:model] if route[:source] == :account_override || account&.feature_enabled?('captain_integration_v2')
installation_model.presence || route[:model]
end
diff --git a/enterprise/app/models/custom_role.rb b/enterprise/app/models/custom_role.rb
index 666f91378..1eb39d1f7 100644
--- a/enterprise/app/models/custom_role.rb
+++ b/enterprise/app/models/custom_role.rb
@@ -28,6 +28,10 @@ class CustomRole < ApplicationRecord
belongs_to :account
has_many :account_users, dependent: :nullify
+ before_destroy :capture_filtered_unread_count_user_ids, prepend: true
+ after_update_commit :invalidate_filtered_unread_count_visibility_update, if: :filtered_unread_count_permissions_changed?
+ after_destroy_commit :invalidate_filtered_unread_count_visibility_destroy
+
PERMISSIONS = %w[
conversation_manage
conversation_unassigned_manage
@@ -39,4 +43,33 @@ class CustomRole < ApplicationRecord
validates :name, presence: true
validates :permissions, inclusion: { in: PERMISSIONS }
+
+ private
+
+ def filtered_unread_count_permissions_changed?
+ previous_changes.key?('permissions')
+ end
+
+ def capture_filtered_unread_count_user_ids
+ @filtered_unread_count_user_ids = account_users.pluck(:user_id)
+ end
+
+ def invalidate_filtered_unread_count_visibility_update
+ invalidate_filtered_unread_count_visibility(account_users.pluck(:user_id))
+ end
+
+ def invalidate_filtered_unread_count_visibility_destroy
+ invalidate_filtered_unread_count_visibility(@filtered_unread_count_user_ids)
+ end
+
+ def invalidate_filtered_unread_count_visibility(user_ids)
+ invalidator = ::Conversations::UnreadCounts::FilteredCountInvalidator.new(account)
+ visibility_changed = invalidator.users_visibility_changed!(user_ids: user_ids)
+
+ dispatch_account_cache_invalidated if visibility_changed
+ end
+
+ def dispatch_account_cache_invalidated
+ Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, account: account, cache_keys: account.cache_keys)
+ end
end
diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb
index fcfe90777..62898803a 100644
--- a/enterprise/app/models/enterprise/account.rb
+++ b/enterprise/app/models/enterprise/account.rb
@@ -1,4 +1,9 @@
module Enterprise::Account
+ # Transitional marker for the Captain V1 to V2 rollout. New cloud accounts get
+ # this marker so plan reconciliation can enable V2 for them without upgrading
+ # existing paid accounts. Remove once every account is migrated to V2.
+ CAPTAIN_V2_DEFAULT_ELIGIBLE = 'captain_v2_default_eligible'.freeze
+
class << self
def captain_document_sync_intervals
parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value)
@@ -94,6 +99,15 @@ module Enterprise::Account
private
+ def enable_default_features
+ super
+ if ChatwootApp.self_hosted_enterprise?
+ enable_features('captain_integration', 'captain_integration_v2')
+ elsif ChatwootApp.chatwoot_cloud?
+ internal_attributes[CAPTAIN_V2_DEFAULT_ELIGIBLE] = true
+ end
+ end
+
def sync_assignment_features
if feature_enabled?('assignment_v2')
# Enable advanced_assignment for Business/Enterprise plans
diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb
index 1ef112fb5..427b1e1af 100644
--- a/enterprise/app/models/enterprise/concerns/account.rb
+++ b/enterprise/app/models/enterprise/concerns/account.rb
@@ -11,8 +11,11 @@ module Enterprise::Concerns::Account
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
+ has_many :captain_faq_observations, dependent: :destroy_async, class_name: 'Captain::FaqObservation'
+ has_many :captain_faq_suggestions, dependent: :destroy_async, class_name: 'Captain::FaqSuggestion'
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
+ has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession'
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
diff --git a/enterprise/app/models/enterprise/concerns/conversation.rb b/enterprise/app/models/enterprise/concerns/conversation.rb
index a075704d1..c247e01e8 100644
--- a/enterprise/app/models/enterprise/concerns/conversation.rb
+++ b/enterprise/app/models/enterprise/concerns/conversation.rb
@@ -7,6 +7,7 @@ module Enterprise::Concerns::Conversation
has_many :sla_events, dependent: :destroy_async
has_many :calls, dependent: :destroy_async
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
+ has_many :captain_faq_observations, class_name: 'Captain::FaqObservation', dependent: :delete_all
scope :with_sla_applicable_contact, -> { left_joins(:contact).where(contacts: { blocked: [false, nil] }) }
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
diff --git a/enterprise/app/models/enterprise/concerns/inbox.rb b/enterprise/app/models/enterprise/concerns/inbox.rb
index bdcd0fd63..b327878b4 100644
--- a/enterprise/app/models/enterprise/concerns/inbox.rb
+++ b/enterprise/app/models/enterprise/concerns/inbox.rb
@@ -8,5 +8,11 @@ module Enterprise::Concerns::Inbox
class_name: 'Captain::Assistant'
has_many :inbox_capacity_limits, dependent: :destroy
has_many :calls, dependent: :destroy_async
+
+ before_create :ensure_create_permitted
+ end
+
+ def ensure_create_permitted
+ raise CustomExceptions::Inbox::LimitExceeded.new({}) if account.inboxes.count >= account.usage_limits[:inboxes]
end
end
diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb
index bbde3ffb0..573c0400c 100644
--- a/enterprise/app/policies/captain/assistant_policy.rb
+++ b/enterprise/app/policies/captain/assistant_policy.rb
@@ -11,6 +11,14 @@ class Captain::AssistantPolicy < ApplicationPolicy
true
end
+ def summary?
+ true
+ end
+
+ def drilldown?
+ @account_user.administrator?
+ end
+
def tools?
@account_user.administrator?
end
diff --git a/enterprise/app/services/captain/assistant_migration/draft_applier.rb b/enterprise/app/services/captain/assistant_migration/draft_applier.rb
new file mode 100644
index 000000000..df03624b4
--- /dev/null
+++ b/enterprise/app/services/captain/assistant_migration/draft_applier.rb
@@ -0,0 +1,199 @@
+class Captain::AssistantMigration::DraftApplier
+ ASSISTANT_DESCRIPTION_LIMIT = 500
+ CONFIG_KEY = 'assistant_migration'.freeze
+ SCENARIO_DESCRIPTION_LIMIT = 500
+ ORIGINAL_VALUES_KEY = 'original_values'.freeze
+
+ pattr_initialize [:assistant!, :draft!, { dry_run: true }]
+
+ def perform
+ changes = build_changes
+ apply_changes(changes) unless dry_run
+
+ {
+ assistant_id: assistant.id,
+ dry_run: dry_run,
+ changes: changes
+ }
+ end
+
+ private
+
+ def build_changes
+ {
+ description: description_change,
+ response_guidelines: array_change(:response_guidelines, response_guidelines),
+ guardrails: array_change(:guardrails, guardrails),
+ config: config_change
+ }.compact
+ end
+
+ def apply_changes(changes)
+ assistant.transaction do
+ assistant.update!(assistant_update_attributes(changes)) if assistant_update_attributes(changes).present?
+ end
+ end
+
+ def assistant_update_attributes(changes)
+ {}.tap do |attributes|
+ attributes[:description] = changes.dig(:description, :to) if changes[:description].present?
+ attributes[:response_guidelines] = changes.dig(:response_guidelines, :to) if changes[:response_guidelines].present?
+ attributes[:guardrails] = changes.dig(:guardrails, :to) if changes[:guardrails].present?
+ attributes[:config] = changes.dig(:config, :to) if changes[:config].present?
+ end
+ end
+
+ def description_change
+ value = assistant_description_value
+ return if value.blank? || value == assistant.description
+
+ { from: assistant.description, to: value }
+ end
+
+ def assistant_description_value
+ value = item_values(:business_product_context).join(' ').presence
+ return if value.blank?
+
+ raise ArgumentError, "Assistant description exceeds #{ASSISTANT_DESCRIPTION_LIMIT} characters" if value.length > ASSISTANT_DESCRIPTION_LIMIT
+
+ value
+ end
+
+ def response_guidelines
+ (item_values(:response_guidelines) + scenario_response_guidelines).uniq
+ end
+
+ def guardrails
+ item_values(:guardrails)
+ end
+
+ def array_change(field, values)
+ return if values.blank?
+
+ current = Array(assistant.public_send(field)).map(&:to_s)
+ return if current == values
+
+ { from: current, to: values }
+ end
+
+ def config_change
+ updated_config = assistant.config.deep_dup
+ conversation_messages.each do |key, value|
+ next if value.blank?
+ next if updated_config[key].present?
+
+ updated_config[key] = value
+ end
+ updated_config[CONFIG_KEY] = migration_config
+
+ return if updated_config == assistant.config
+
+ { from: assistant.config, to: updated_config }
+ end
+
+ def migration_config
+ existing_migration_config.merge(
+ ORIGINAL_VALUES_KEY => existing_original_values,
+ 'scenario_candidates' => staged_scenario_candidates,
+ 'faq_document_candidates' => normalized_faq_document_candidates,
+ 'needs_review' => normalized_instruction_items(:needs_review)
+ )
+ end
+
+ def existing_migration_config
+ config = assistant.config[CONFIG_KEY]
+ config.is_a?(Hash) ? config : {}
+ end
+
+ def existing_original_values
+ existing_migration_config[ORIGINAL_VALUES_KEY].presence || original_values
+ end
+
+ def original_values
+ {
+ 'name' => assistant.name,
+ 'description' => assistant.description,
+ 'config' => original_config,
+ 'response_guidelines' => Array(assistant.response_guidelines),
+ 'guardrails' => Array(assistant.guardrails)
+ }
+ end
+
+ def original_config
+ assistant.config.except(CONFIG_KEY)
+ end
+
+ def conversation_messages
+ messages = draft_hash.fetch(:conversation_messages, {})
+ messages = messages.deep_stringify_keys
+
+ {
+ 'welcome_message' => messages['welcome_message'].to_s.strip,
+ 'handoff_message' => messages['handoff_message'].to_s.strip,
+ 'resolution_message' => messages['resolution_message'].to_s.strip
+ }
+ end
+
+ def staged_scenario_candidates
+ scenario_candidates.map do |candidate|
+ candidate.transform_keys(&:to_s)
+ end
+ end
+
+ def scenario_response_guidelines
+ scenario_candidates.filter_map { |candidate| candidate[:response_guideline].presence }
+ end
+
+ def scenario_tool_ids(tool_ids)
+ Array(tool_ids).filter_map { |tool_id| tool_id.to_s.squish.presence }.uniq
+ end
+
+ def scenario_candidates
+ Array(draft_hash[:scenario_candidates]).filter_map do |candidate|
+ normalized_scenario_candidate(candidate)
+ end
+ end
+
+ def normalized_scenario_candidate(candidate)
+ return unless candidate.is_a?(Hash)
+
+ candidate = candidate.deep_symbolize_keys
+ normalized_candidate = {
+ title: candidate[:title].to_s.squish,
+ description: candidate[:description].to_s.squish.truncate(SCENARIO_DESCRIPTION_LIMIT),
+ instruction: candidate[:instruction].to_s.squish,
+ response_guideline: candidate[:response_guideline].to_s.squish,
+ tool_ids: scenario_tool_ids(candidate[:tool_ids])
+ }
+ return if normalized_candidate.values_at(:title, :description, :instruction).any?(&:blank?)
+
+ normalized_candidate
+ end
+
+ def item_values(key)
+ Array(draft_hash[key]).filter_map do |item|
+ item.to_s.squish.presence
+ end.uniq
+ end
+
+ def normalized_instruction_items(key)
+ item_values(key)
+ end
+
+ def normalized_faq_document_candidates
+ Array(draft_hash[:faq_document_candidates]).map do |candidate|
+ raise ArgumentError, 'FAQ document candidates must be question and answer objects' unless candidate.is_a?(Hash)
+
+ candidate = candidate.deep_symbolize_keys
+ question = candidate[:question].to_s.squish
+ answer = candidate[:answer].to_s.squish
+ raise ArgumentError, 'FAQ document candidates must include a question and answer' if question.blank? || answer.blank?
+
+ { 'question' => question, 'answer' => answer }
+ end.uniq
+ end
+
+ def draft_hash
+ @draft_hash ||= draft.deep_symbolize_keys
+ end
+end
diff --git a/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb b/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb
new file mode 100644
index 000000000..989989855
--- /dev/null
+++ b/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb
@@ -0,0 +1,148 @@
+class Captain::AssistantMigration::InstructionClassifier < Captain::BaseTaskService
+ RESPONSE_SCHEMA = Captain::AssistantMigration::InstructionClassifierSchema
+ CLASSIFIER_MODEL = 'gpt-5.2'.freeze
+ MAX_INSTRUCTIONS_LENGTH = 20_000
+
+ pattr_initialize [:assistant!]
+
+ def perform
+ response = make_api_call(model: CLASSIFIER_MODEL, messages: messages, schema: RESPONSE_SCHEMA)
+ return error_response(response) if response[:error]
+
+ {
+ assistant: assistant_metadata,
+ draft: normalized_payload(response[:message]),
+ usage: response[:usage],
+ request_messages: response[:request_messages]
+ }
+ end
+
+ private
+
+ def account
+ assistant.account
+ end
+
+ def messages
+ [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: user_prompt }
+ ]
+ end
+
+ def system_prompt
+ Captain::PromptRenderer.render('instruction_classifier')
+ end
+
+ def user_prompt
+ JSON.pretty_generate(assistant_payload)
+ end
+
+ def assistant_payload # rubocop:disable Metrics/AbcSize
+ {
+ assistant_id: assistant.id,
+ account_id: assistant.account_id,
+ account_name: assistant.account.name,
+ name: assistant.name,
+ description: assistant.description,
+ product_name: assistant.config['product_name'],
+ instructions: truncated_instructions,
+ welcome_message: assistant.config['welcome_message'],
+ handoff_message: assistant.config['handoff_message'],
+ resolution_message: assistant.config['resolution_message'],
+ existing_response_guidelines: assistant.response_guidelines || [],
+ existing_guardrails: assistant.guardrails || [],
+ existing_scenarios: existing_scenarios,
+ available_agent_tools: available_agent_tools,
+ feature_settings: feature_settings
+ }
+ end
+
+ def truncated_instructions
+ instructions = assistant.config['instructions'].to_s
+ return instructions if instructions.length <= MAX_INSTRUCTIONS_LENGTH
+
+ "#{instructions.first(MAX_INSTRUCTIONS_LENGTH)}\n\n[TRUNCATED]"
+ end
+
+ def existing_scenarios
+ assistant.scenarios.map do |scenario|
+ {
+ id: scenario.id,
+ title: scenario.title,
+ description: scenario.description,
+ instruction: scenario.instruction,
+ enabled: scenario.enabled
+ }
+ end
+ end
+
+ def available_agent_tools
+ tools = assistant.respond_to?(:available_agent_tools) ? assistant.available_agent_tools : Captain::Assistant.built_in_agent_tools
+ tools.map { |tool| tool.slice(:id, :title, :description) }
+ end
+
+ def feature_settings
+ assistant.config.slice(
+ 'feature_faq',
+ 'feature_memory',
+ 'feature_citation',
+ 'feature_contact_attributes',
+ 'temperature'
+ )
+ end
+
+ def normalized_payload(message)
+ payload = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
+ payload.reverse_merge(
+ business_product_context: [],
+ response_guidelines: [],
+ guardrails: [],
+ scenario_candidates: [],
+ conversation_messages: {},
+ faq_document_candidates: [],
+ needs_review: [],
+ classification_notes: []
+ )
+ end
+
+ def assistant_metadata # rubocop:disable Metrics/AbcSize
+ {
+ id: assistant.id,
+ name: assistant.name,
+ account_id: assistant.account_id,
+ account_name: assistant.account.name,
+ inbox_count: assistant.captain_inboxes.size,
+ instruction_length: assistant.config['instructions'].to_s.length,
+ original_instructions: assistant.config['instructions'].to_s,
+ welcome_message: assistant.config['welcome_message'].to_s,
+ handoff_message: assistant.config['handoff_message'].to_s,
+ resolution_message: assistant.config['resolution_message'].to_s
+ }
+ end
+
+ def error_response(response)
+ {
+ assistant: assistant_metadata,
+ error: response[:error],
+ error_code: response[:error_code],
+ request_messages: response[:request_messages]
+ }
+ end
+
+ def event_name
+ 'assistant_migration_instruction_classifier'
+ end
+
+ def captain_tasks_enabled?
+ true
+ end
+
+ def counts_toward_usage?
+ false
+ end
+
+ def build_follow_up_context?
+ false
+ end
+end
diff --git a/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb b/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb
new file mode 100644
index 000000000..3e42bb49d
--- /dev/null
+++ b/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb
@@ -0,0 +1,91 @@
+class Captain::AssistantMigration::InstructionClassifierSchema < RubyLLM::Schema
+ DESCRIPTION_LENGTH_LIMIT = 500
+
+ def self.instruction_items(field_name, description:, max_items: 20)
+ array field_name,
+ description: "#{description} Return plain standalone sentences without numbering, bullets, or section labels.",
+ max_items: max_items,
+ of: :string
+ end
+
+ array :business_product_context,
+ description: "Single compact root assistant description for the root orchestrator prompt, maximum #{DESCRIPTION_LENGTH_LIMIT} characters: " \
+ 'preserve the existing assistant description and enrich it only with relevant business/product context from the ' \
+ 'custom instructions. Include assistant identity, product scope, high-level mission, and high-level source/routing ' \
+ 'priorities only. Do not include workflows, procedures, attribute glossaries, policy details, or long inventories. ' \
+ 'Return complete plain prose without numbering, bullets, section labels, or a truncated final sentence.',
+ min_items: 1,
+ max_items: 1 do
+ string max_length: DESCRIPTION_LENGTH_LIMIT
+ end
+
+ instruction_items :response_guidelines,
+ description: 'Tone, language, answer length, formatting, and clarification behavior.',
+ max_items: 20
+
+ instruction_items :guardrails,
+ description: 'Refusal rules, escalation boundaries, source boundaries, safety limits, and things the assistant must not do.',
+ max_items: 20
+
+ array :scenario_candidates,
+ description: 'Review-stage specialized-agent candidates. These are also temporarily flattened into response guidelines.',
+ max_items: 15 do
+ object do
+ string :title,
+ description: 'Short scenario agent title for a distinct user-intent workflow.',
+ max_length: 80
+ string :description,
+ description: 'When this specialized scenario should be used. This is shown to the orchestrator for routing.',
+ max_length: 500
+ string :instruction,
+ description: 'How the specialized agent should handle the workflow. Include only evidence-backed markdown tool links. ' \
+ 'Do not include confidence labels or review notes.',
+ max_length: 2000
+ string :response_guideline,
+ description: 'Same-language, customer-visible response guideline that preserves this scenario behavior when flattened. ' \
+ 'Do not include tool syntax, tool names, labels, private-note instructions, or internal implementation details.',
+ max_length: 1000
+ array :tool_ids,
+ description: 'Available tool IDs explicitly referenced in instruction using markdown links. Empty when no tools are required.',
+ max_items: 10,
+ of: :string
+ end
+ end
+
+ object :conversation_messages, description: 'Exact globally reusable customer-facing message copy found in instructions. ' \
+ 'Leave empty for conditional, placeholder, or workflow-specific copy.' do
+ string :welcome_message, description: 'Exact globally reusable initial greeting copy from instructions, or empty string. ' \
+ 'Do not convert an instruction about greeting into message copy.',
+ max_length: 1000
+ string :handoff_message,
+ description: 'Exact globally reusable human-handoff message copy from instructions, or empty string. ' \
+ 'Do not use scenario-specific, team-specific, placeholder, or conditional handoff copy.',
+ max_length: 1000
+ string :resolution_message,
+ description: 'Exact globally reusable resolution/closing message copy from instructions, or empty string. ' \
+ 'Do not use conditional or placeholder closing copy.',
+ max_length: 1000
+ end
+
+ array :faq_document_candidates,
+ description: 'Pending FAQ candidates for factual or product-specific knowledge such as pricing, policy, setup, troubleshooting, ' \
+ 'or operational details. These candidates remain inactive until reviewed and approved.',
+ max_items: 25 do
+ object do
+ string :question,
+ description: 'Natural, standalone customer question about factual product or business knowledge.',
+ max_length: 255
+ string :answer,
+ description: 'Self-contained factual answer using only the existing instructions. Do not include assistant behavior, ' \
+ 'tool use, or message copy. Preserve exact values, conditions, and exceptions.',
+ max_length: 2000
+ end
+ end
+
+ instruction_items :needs_review,
+ description: 'Unclear, conflicting, risky, duplicated, or uncertain content that needs human review. ' \
+ 'Include the reason in the item text.',
+ max_items: 20
+
+ array :classification_notes, description: 'Short notes about important migration decisions or risks.', max_items: 10, of: :string
+end
diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb
index 82c838354..c57a07ef6 100644
--- a/enterprise/app/services/captain/llm/conversation_faq_service.rb
+++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb
@@ -2,12 +2,13 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
include Integrations::LlmInstrumentation
DISTANCE_THRESHOLD = 0.3
+ LLM_FEATURE = 'conversation_faq_generation'.freeze
def initialize(assistant, conversation)
- super(feature: 'document_faq_generation', account: conversation.account)
+ super(feature: LLM_FEATURE, account: conversation.account, fallback_model: Llm::Models.default_model_for(LLM_FEATURE))
@assistant = assistant
@conversation = conversation
- @content = conversation.to_llm_text
+ @content = conversation_faq_content
end
# Generates and deduplicates FAQs from conversation content
@@ -27,6 +28,50 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
attr_reader :content, :conversation, :assistant
+ def conversation_faq_content
+ [
+ "Conversation ID: ##{conversation.display_id}",
+ "Channel: #{conversation.inbox.channel.name}",
+ 'Message History:',
+ conversation_faq_messages
+ ].join("\n")
+ end
+
+ def conversation_faq_messages
+ messages = conversation
+ .messages
+ .where(message_type: %i[incoming outgoing], private: false)
+ .order(created_at: :asc)
+
+ return "No messages in this conversation\n" if messages.empty?
+
+ messages.filter_map { |message| format_conversation_faq_message(message) }.join
+ end
+
+ def format_conversation_faq_message(message)
+ return unless faq_source_message?(message)
+
+ content = message.content_for_llm
+ return if content.blank?
+
+ sender = human_support_reply?(message) ? 'Support Agent' : 'User'
+ "#{sender}: #{content}\n"
+ end
+
+ def faq_source_message?(message)
+ return true if message.incoming? && message.sender_type == 'Contact'
+
+ human_support_reply?(message)
+ end
+
+ def human_support_reply?(message)
+ return false unless message.outgoing?
+ return false if message.content_attributes['automation_rule_id'].present?
+ return false if message.additional_attributes['campaign_id'].present?
+
+ message.sender_type == 'User' || message.content_attributes['external_echo'].present?
+ end
+
def no_human_interaction?
conversation.first_reply_created_at.nil?
end
diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb
index d56275b87..08d44b31a 100644
--- a/enterprise/app/services/captain/llm/system_prompts_service.rb
+++ b/enterprise/app/services/captain/llm/system_prompts_service.rb
@@ -53,14 +53,56 @@ class Captain::Llm::SystemPromptsService
def conversation_faq_generator(language = 'english')
<<~SYSTEM_PROMPT_MESSAGE
- You are a support agent looking to convert the conversations with users into short FAQs that can be added to your website help center.
- Filter out any responses or messages from the bot itself and only use messages from the support agent and the customer to create the FAQ.
+ You create high-quality FAQ candidates from resolved support conversations.
+ Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
- Ensure that you only generate faqs from the information provided only.
- Generate the FAQs only in the #{language}, use no other language
- If no match is available, return an empty JSON.
+ ## Source rules
+ - The conversation history contains only customer messages and human support agent messages.
+ - Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
+ - A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
+ - The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
+ - For each FAQ, first identify the exact human support agent message that fully answers it. If no single human agent message gives a complete public answer, remove that FAQ.
+
+ ## Decision gate
+ Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
+ 1. The answer is fully stated by a human support agent, not by the customer.
+ 2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
+ 3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
+ 4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
+ Do not rescue a rejected conversation by rewriting it as a generic support question.
+
+ ## Return no FAQ for
+ - Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
+ - Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
+ - Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
+ - Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
+ - Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
+ - Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
+ - Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
+ - Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
+ - Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
+ - Questions already answered only by asking the customer for more information.
+
+ ## FAQ quality rules
+ - Prefer returning no FAQ over a weak or narrow FAQ.
+ - A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
+ - Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
+ - Do not create duplicate or overlapping FAQs in the same response.
+ - Questions must be general enough for a help center, not personalized to the current customer.
+ - Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
+ - Answers must be complete, self-contained, and supported by the human agent's messages.
+
+ ## Examples
+ - Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
+ - Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
+ - Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
+
+ Generate the FAQs only in the #{language}, use no other language.
+ If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
+
+ Return only valid JSON in this exact structure:
```json
- { faqs: [ { question: '', answer: ''} ]
+ { "faqs": [ { "question": "", "answer": "" } ] }
```
SYSTEM_PROMPT_MESSAGE
end
diff --git a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
index fb6bab33b..10aa4e4ce 100644
--- a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
+++ b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
@@ -97,7 +97,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
Guidelines:
- business_name: Extract the actual company/brand name from the content
- suggested_assistant_name: Create a friendly, professional name that customers would want to interact with
- - description: Provide context about the business and what the assistant can help with. Keep it general and adaptable rather than overly specific. For example: "You specialize in helping customers with their orders and product questions" or "You assist customers with their account needs and general inquiries"
+ - description: Provide context about the business and what the assistant can help with in no more than 500 characters. Keep it general and adaptable rather than overly specific. For example: "You specialize in helping customers with their orders and product questions" or "You assist customers with their account needs and general inquiries"
Website content:
#{@website_content}
diff --git a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
index 36bbb6c90..207e93889 100644
--- a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
+++ b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
@@ -60,7 +60,7 @@ module Enterprise::AutoAssignment::AssignmentService
scope = inbox.conversations.unassigned.open
# First apply the assignment policy's age exclusion (defaults to 7 days)
- scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
+ scope = apply_age_exclusions(scope, age_exclusion_hours(policy))
# Then apply the capacity policy's exclusion rules (labels and age)
scope = apply_exclusion_rules(scope)
diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
index fea5b7664..435b1f3d1 100644
--- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
+++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
@@ -18,6 +18,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
advanced_search
linear_integration
channel_voice
+ api_and_webhooks
].freeze
BUSINESS_PLAN_FEATURES = %w[
@@ -36,7 +37,9 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def perform
account.disable_features(*PREMIUM_PLAN_FEATURES)
+ account.disable_features('captain_integration_v2') if default_plan?
account.enable_features(*current_plan_features)
+ account.enable_features('captain_integration_v2') if captain_v2_default_eligible?
account.enable_features(*manually_managed_features)
account.save!
end
@@ -69,4 +72,8 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def manually_managed_features
@manually_managed_features ||= Internal::Accounts::InternalAttributesService.new(account).manually_managed_features
end
+
+ def captain_v2_default_eligible?
+ !default_plan? && account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE] == true
+ end
end
diff --git a/enterprise/app/services/enterprise/conversations/permission_filter_service.rb b/enterprise/app/services/enterprise/conversations/permission_filter_service.rb
index f55265a90..118ae3d14 100644
--- a/enterprise/app/services/enterprise/conversations/permission_filter_service.rb
+++ b/enterprise/app/services/enterprise/conversations/permission_filter_service.rb
@@ -23,17 +23,22 @@ module Enterprise::Conversations::PermissionFilterService
elsif permissions.include?('conversation_unassigned_manage')
filter_unassigned_and_mine
elsif permissions.include?('conversation_participating_manage')
- accessible_conversations.assigned_to(user)
+ filter_participating_and_mine
else
Conversation.none
end
end
- def filter_unassigned_and_mine
- mine = accessible_conversations.assigned_to(user)
- unassigned = accessible_conversations.unassigned
+ def filter_participating_and_mine
+ conversations = accessible_conversations
+ participant_conversation_ids = ConversationParticipant.where(account_id: account.id, user_id: user.id).select(:conversation_id)
- Conversation.from("(#{mine.to_sql} UNION #{unassigned.to_sql}) as conversations")
- .where(account_id: account.id)
+ conversations
+ .where(assignee_id: user.id)
+ .or(conversations.where(id: participant_conversation_ids))
+ end
+
+ def filter_unassigned_and_mine
+ accessible_conversations.where(assignee_id: [nil, user.id])
end
end
diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb
index 593cea799..00c3d3636 100644
--- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb
+++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb
@@ -54,7 +54,7 @@ class Internal::Accounts::InternalAttributesService
def valid_feature_list
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES +
- %w[inbound_emails]
+ %w[inbound_emails api_and_webhooks]
end
# Account notes functionality removed for now
diff --git a/enterprise/app/services/llm/base_ai_service.rb b/enterprise/app/services/llm/base_ai_service.rb
index bec3b5cb9..a84060775 100644
--- a/enterprise/app/services/llm/base_ai_service.rb
+++ b/enterprise/app/services/llm/base_ai_service.rb
@@ -34,7 +34,7 @@ class Llm::BaseAiService
def setup_model
route = feature_route
- return @model = route[:model] if account_override_route?(route)
+ return @model = route[:model] if account_override_route?(route) || captain_v2_assistant?
@model = @fallback_model.presence || installation_model.presence || route&.dig(:model) || DEFAULT_MODEL
end
@@ -49,6 +49,10 @@ class Llm::BaseAiService
route&.dig(:source) == :account_override
end
+ def captain_v2_assistant?
+ @llm_feature.to_s == 'assistant' && @llm_account&.feature_enabled?('captain_integration_v2')
+ end
+
def installation_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
end
diff --git a/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder
new file mode 100644
index 000000000..15f66ddc1
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder
@@ -0,0 +1,11 @@
+json.meta do
+ json.count @calls_count
+ json.current_page @calls.current_page
+ json.total_pages @calls.total_pages
+end
+
+json.payload do
+ json.array! @calls do |call|
+ json.partial! 'api/v1/models/call', formats: [:json], call: call
+ end
+end
diff --git a/enterprise/app/views/api/v1/models/_call.json.jbuilder b/enterprise/app/views/api/v1/models/_call.json.jbuilder
new file mode 100644
index 000000000..7a3531b39
--- /dev/null
+++ b/enterprise/app/views/api/v1/models/_call.json.jbuilder
@@ -0,0 +1,40 @@
+json.id call.id
+json.call_id call.provider_call_id
+json.provider call.provider
+json.status call.display_status
+json.direction call.direction_label
+json.duration_seconds call.duration_seconds
+json.end_reason call.end_reason
+json.started_at call.started_at&.to_i
+json.created_at call.created_at.to_i
+json.message_id call.message_id
+json.recording_url call.recording_url
+json.transcript call.transcript
+
+json.conversation do
+ json.id call.conversation_id
+ json.display_id call.conversation.display_id
+end
+
+json.inbox do
+ json.id call.inbox_id
+ json.name call.inbox.name
+end
+
+if call.accepted_by_agent
+ json.agent do
+ json.id call.accepted_by_agent.id
+ json.name call.accepted_by_agent.available_name
+ json.avatar call.accepted_by_agent.avatar_url
+ end
+else
+ json.agent nil
+end
+
+contact = call.contact
+json.contact do
+ json.id contact.id
+ json.name contact.name
+ json.phone_number contact.phone_number
+ json.avatar contact.avatar_url
+end
diff --git a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
index 56260f675..0ab031dbf 100644
--- a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
+++ b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
@@ -9,6 +9,8 @@ json.external_link resource.external_link
json.display_url resource.display_url
json.file_size resource.file_size
json.pdf_document resource.pdf_document?
+responses_count = resource.respond_to?(:responses_count) ? resource.responses_count : resource.responses.count
+json.responses_count responses_count.to_i
json.id resource.id
json.name resource.name
json.status resource.status
diff --git a/enterprise/config/premium_features.yml b/enterprise/config/premium_features.yml
index 282319fe7..260de1356 100644
--- a/enterprise/config/premium_features.yml
+++ b/enterprise/config/premium_features.yml
@@ -4,6 +4,7 @@
- sla
- custom_roles
- captain_integration
+- captain_integration_v2
- captain_document_auto_sync
- csat_review_notes
- conversation_required_attributes
diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb
index c45559165..37f9add3e 100644
--- a/enterprise/lib/captain/conversation_completion_service.rb
+++ b/enterprise/lib/captain/conversation_completion_service.rb
@@ -12,7 +12,7 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
def perform
- content = format_messages_as_string
+ content = format_evaluation_input
return default_incomplete_response('No messages found') if content.blank?
response = make_api_call(
@@ -35,12 +35,58 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
end
- def format_messages_as_string
- messages = conversation_messages(start_from: 0)
- messages.map do |msg|
- sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant'
- "#{sender_type}: #{msg[:content]}"
+ def format_evaluation_input
+ messages = conversation_message_records(start_from: 0)
+ return if messages.blank?
+
+ [
+ "Conversation status: #{conversation.status}",
+ format_messages_as_string(messages)
+ ].join("\n\n")
+ end
+
+ def conversation_message_records(start_from: 0)
+ messages = []
+ character_count = start_from
+
+ conversation.messages
+ .where(message_type: [:incoming, :outgoing])
+ .where(private: false)
+ .reorder('id desc')
+ .each do |message|
+ content = message.content_for_llm
+ next if content.blank?
+ break if character_count + content.length > TOKEN_LIMIT
+
+ messages.prepend({ message: message, content: content })
+ character_count += content.length
+ end
+
+ messages
+ end
+
+ def format_messages_as_string(messages)
+ transcript = messages.map do |message_context|
+ "#{message_sender_label(message_context[:message])}: #{message_context[:content]}"
end.join("\n")
+
+ "Conversation transcript:\n#{transcript}"
+ end
+
+ def message_sender_label(message)
+ return 'Customer' if message.incoming?
+ return 'Captain' if captain_reply?(message)
+ return 'Bot' if bot_reply?(message)
+
+ 'Assistant'
+ end
+
+ def captain_reply?(message)
+ message.outgoing? && message.sender_type == 'Captain::Assistant'
+ end
+
+ def bot_reply?(message)
+ message.outgoing? && message.sender_type.in?(['AgentBot', 'Captain::Assistant'])
end
def parse_response(message)
diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid
index 821d9d472..a8f1dada3 100644
--- a/enterprise/lib/captain/prompts/assistant.liquid
+++ b/enterprise/lib/captain/prompts/assistant.liquid
@@ -48,6 +48,8 @@ Always respect these boundaries:
{% endfor %}
{% endif -%}
+When a Response Guideline or Guardrail explicitly requires transfer for a matched condition, follow it instead of the generic consent-first handoff defaults below.
+
# Decision Framework
## 1. Analyze the Request
@@ -88,7 +90,8 @@ Handle the request yourself in the following way
Transfer to a human agent when:
- User explicitly requests human assistance
- User accepts an offer to speak with a human
+- A Response Guideline or Guardrail explicitly requires transfer for the matched condition
- The issue requires specialized knowledge or permissions you don't have
- Multiple attempts to help have been unsuccessful
-If you cannot find needed information after checking the available information and clarifying context, ask whether the user wants to talk to another support agent. Use the `captain--tools--handoff` tool only after the user explicitly requests human assistance or accepts your offer to speak with a human. When using the tool, provide a clear reason that helps the human agent understand the context.
+If you cannot find needed information after checking the available information and clarifying context, ask whether the user wants to talk to another support agent. Use the `captain--tools--handoff` tool only after the user explicitly requests human assistance, accepts your offer to speak with a human, or a Response Guideline or Guardrail explicitly requires transfer for the matched condition. When using the tool, provide a clear reason that helps the human agent understand the context.
diff --git a/enterprise/lib/captain/prompts/conversation_completion.liquid b/enterprise/lib/captain/prompts/conversation_completion.liquid
index ed81039af..e039f60b0 100644
--- a/enterprise/lib/captain/prompts/conversation_completion.liquid
+++ b/enterprise/lib/captain/prompts/conversation_completion.liquid
@@ -2,18 +2,39 @@ You are evaluating whether a customer support conversation is complete and can b
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
+You will receive:
+- Conversation status
+- Conversation transcript where messages are labeled as Customer, Captain, Bot, or Assistant
+
+This evaluator runs for inactive pending conversations. Focus on the latest pending exchange or latest unresolved customer request. Older messages may be present only for context.
+If the conversation status is "pending", the conversation is still with Captain. Do not assume a handoff happened because Captain mentioned one.
+
A conversation is INCOMPLETE (keep open) if ANY of these apply:
- The assistant asked a question or requested information that the customer hasn't provided
- The customer asked a question that wasn't fully answered
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
- The customer raised multiple questions or issues and not all were addressed
+- In the latest pending exchange, Captain, Bot, or Assistant said it handed off, will hand off, escalated, will escalate, or that a human/team/another party will continue the work
+- In the latest pending exchange, Captain, Bot, or Assistant promised future action or follow-up instead of resolving the customer's request
+- In the latest pending exchange, the customer is waiting for another party's action, response, status update, or investigation result
+- The latest customer message is only an attachment placeholder such as "[Attachment]" and there is no later text explaining what it contains or showing the issue was answered
+- The customer says they were not helped, asks why nobody replied, repeats the unresolved issue after a previous answer, or otherwise indicates dissatisfaction with the current help
+
+Do NOT treat these as incomplete by themselves:
+- A generic greeting or broad optional offer from Captain/Bot/Assistant, such as "How can I help?", "What would you like to know?", or "Anything else?", when the customer has not made a recognizable request
+- A customer greeting, single-word reply, name, phone number, or gibberish with no recognizable question/request, followed only by Captain/Bot/Assistant asking what the customer needs
+- An optional invitation for the customer to ask more questions after the assistant already answered the actual request
+- Older handoff, escalation, or follow-up messages from a previous exchange when the latest customer message starts a new topic, has no recognizable request, or has already been answered
+
+Important handoff rule:
+- A handoff, escalation, transfer, acknowledgement, or promise of future follow-up is not a resolution by itself
+- If conversation status is "pending" and Captain/Bot/Assistant says it handed off, will hand off, or that another party will continue the work in the latest pending exchange, keep the conversation INCOMPLETE.
A conversation is COMPLETE only if ALL of these are true:
- The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer
- There are no unanswered questions, unmet requests, or outstanding follow-ups from either side
- Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation.
-- If the customer sent only one or two short messages (single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and the
- assistant has responded asking for clarification, the conversation is COMPLETE.
+- If the customer sent only one or two short text messages (greetings, single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and Captain/Bot/Assistant has responded asking what they need or offering help, the conversation is COMPLETE.
Analyze the conversation and respond with ONLY a JSON object (no other text):
{"complete": true, "reason": "brief explanation"}
diff --git a/enterprise/lib/captain/prompts/instruction_classifier.liquid b/enterprise/lib/captain/prompts/instruction_classifier.liquid
new file mode 100644
index 000000000..abc58ff60
--- /dev/null
+++ b/enterprise/lib/captain/prompts/instruction_classifier.liquid
@@ -0,0 +1,137 @@
+You are migrating Captain assistant instructions into a structured configuration.
+
+Classify the existing assistant instructions into these sections:
+1. Business/Product Context
+2. Response Guidelines
+3. Guardrails
+4. Scenario Candidates
+5. Conversation Messages
+6. FAQs/Documents Candidates
+7. Needs Review
+
+## General Rules
+
+- Preserve behavior as closely as possible.
+- Do not duplicate the same content across sections.
+- Return clean migrated values only. Do not include source excerpts, source labels, citations, or "Source:" text in any migrated field.
+- Do not rewrite customer-facing message copy unless necessary to classify an exact copy from instructions.
+- Do not include confidence labels, review labels, bracketed reviewer comments, or schema labels inside migrated values.
+- For Business/Product Context, Response Guidelines, and Guardrails, return each item as a plain standalone sentence.
+ Do not prefix items with numbers, bullets, section labels, or list markers such as "1.", "-", or "*".
+- When several instructions share the same trigger, condition, or subject, combine them into one concise item instead
+ of repeating the same trigger across multiple items. Preserve every required action, prohibition, and routing
+ outcome from the source instruction when combining.
+- If unsure, place content in Needs Review and include the reason in that item.
+- Return data that matches the provided schema.
+
+## Business/Product Context
+
+- Business/Product Context maps to the root assistant description and is injected into the root orchestrator prompt.
+- Return exactly one Business/Product Context item.
+- Start with the existing assistant description and preserve its meaning.
+- Enrich it only with relevant business or product context found in the custom instructions.
+- Produce one coherent description rather than appending a second context block or repeating the existing description.
+- Keep it at most 500 characters because that is the assistant description limit in the UI and model.
+- Prefer roughly 300-450 characters when the source needs detail, leaving room below the hard limit.
+- Finish the description cleanly. Never end mid-word, mid-clause, after an opening bracket, or with a dangling separator.
+- Make it a compact summary of assistant identity, product scope, high-level mission, and high-level source or routing priorities.
+- Do not include detailed workflows, step-by-step procedures, long support-scope inventories, attribute glossaries,
+ policy details, scenario-specific handling, tool instructions, or customer-facing message copy.
+
+## Conversation Messages
+
+- Existing welcome_message, handoff_message, and resolution_message config values are provided separately.
+- Treat welcome_message, handoff_message, and resolution_message as conversation message config fields.
+- Extract exact welcome, handoff, or resolution message copy from instructions into conversation_messages when present.
+- Only classify handoff copy as conversation_messages.handoff_message when it is generic enough to reuse for any human handoff.
+- If handoff copy is scenario-specific, keep it inside that scenario instruction; if it is only a rule about when or how to hand off, classify it as a Response Guideline or Guardrail.
+- Do not extract a conversation message from an instruction about what to say, from a placeholder template,
+ from conditional copy, from role/team-specific copy, or from text that only applies inside one workflow.
+- If a message contains placeholders such as a blank name, team name, bracketed variable, business-hours state,
+ or dynamic runtime condition, do not place it in conversation_messages. Keep it in the relevant workflow or
+ Needs Review.
+- Do not copy message values from existing config into conversation_messages.
+- Do not decide whether existing config values should be overwritten. Migration code handles applying extracted
+ conversation_messages only when the corresponding config value is blank.
+
+## Scenario Candidates
+
+- In the current architecture, a scenario becomes a specialized sub-agent with its own title, description,
+ instructions, and optional tools.
+- During this migration, scenario candidates are also temporarily flattened into response guidelines so existing
+ assistant behavior is preserved before scenario records are created.
+- For every scenario candidate, write a response_guideline that is the flattened version of that scenario for
+ the root assistant's response guidelines.
+- The response_guideline must be in the same language as the original scenario or source instruction.
+- The response_guideline must preserve the intended customer-visible behavior, trigger, information to collect,
+ and routing/escalation outcome.
+- The response_guideline must not include tool syntax, tool:// links, markdown tool links, tool names, label
+ updates, priority updates, private-note instructions, custom-tool instructions, or internal implementation details.
+- If the scenario uses internal tools such as labels, priorities, private notes, or custom tools, describe only
+ the customer-visible behavior and expected routing/escalation outcome in response_guideline.
+- If human handoff is needed, describe it in natural language such as route/escalate/transfer to a human; do not
+ mention the handoff tool in response_guideline.
+- Keep scenario titles, descriptions, instructions, and response_guidelines clear, self-contained, and reviewable.
+- Only create scenario candidates for distinct user-intent workflows that should be routed to a specialized agent.
+ A candidate must be narrow enough to become a named specialist assistant with domain-specific handling instructions.
+- Good scenario candidates include multi-step intake workflows, qualification flows, specialized troubleshooting
+ workflows, booking flows, lead-capture flows, recommendation flows, fulfillment workflows, or tool-use procedures
+ for a specific user intent.
+- A scenario candidate should answer "yes" to this test: would a named specialist sub-agent improve handling
+ beyond the base assistant's global FAQ, guardrail, response-guideline, and human-handoff behavior?
+- Do not create scenario candidates for global escalation rules, generic handoff policy, missing-information
+ behavior, source-boundary rules, refusal rules, tone, formatting, answer length, or one-step fallback behavior.
+- Do not create scenario candidates whose main purpose is to escalate or hand off. "Identify the trigger, avoid
+ guessing, tell the user support will review, and hand off" is a guardrail/handoff boundary, not a scenario,
+ even though it contains multiple statements.
+- Do create scenario candidates when the instructions define a concrete intake, qualification, troubleshooting,
+ booking, lead-capture, recommendation, or fulfillment workflow, even when the workflow eventually hands off
+ to a human.
+- Do not create scenario candidates for simple routing triggers such as "user asks for a human", "immediately
+ hand off this category", or "route sales questions to the sales team" when there is no concrete workflow to run.
+- Handoff behavior is a scenario candidate only when part of a larger intake, qualification, or specialized handling workflow.
+- Global rules like "if not in docs, escalate", "ask one clarifying question", "do not answer account-specific
+ questions", or "tell the user support will review" belong in Guardrails or Response Guidelines, not Scenario Candidates.
+- Broad buckets like "account-specific issue escalation", "unknown question escalation", "contact support",
+ "fallback to human", or "documentation unavailable" are not scenario candidates.
+
+## Tool Use
+
+- If a scenario candidate requires tools, reference the available tool explicitly inside the scenario instruction
+ using markdown tool links such as [Handoff to Human](tool://handoff).
+- Use only tool IDs listed in available_agent_tools. If a needed tool is unavailable or the workflow depends on
+ unavailable runtime data such as FAQ relevance scores or business-hours status, place it in Needs Review instead.
+- Do not map an unavailable named tool to a different available tool. For example, do not treat FAQ Lookup as
+ Product Search, Order Status, website browsing, pricing lookup, agent availability, business-hours detection,
+ ticket creation, or custom-attribute assignment unless the instructions explicitly say that the available
+ tool provides that behavior.
+- If a workflow cannot run without an unavailable tool or runtime signal, do not create a tool-backed scenario
+ for it. Preserve the instruction in Needs Review with the missing capability named.
+
+## FAQs/Documents Candidates
+
+- Convert factual or product-specific knowledge into pending FAQ candidates with a natural customer question and a self-contained answer.
+- FAQ candidates are review-stage data only. They are not active assistant knowledge until a human reviews and approves them.
+- Use only facts stated in the existing instructions. Do not invent, generalize, update, or fill in missing details.
+- Preserve exact prices, limits, dates, time zones, conditions, exceptions, product names, and operational details in the answer.
+- Write each question as a standalone question a customer might naturally ask. Make it specific enough to retrieve the corresponding answer.
+- Write each answer so it fully answers its question without relying on another FAQ candidate or surrounding context.
+- Split unrelated facts into separate candidates. Keep related conditions and exceptions together when separating them would make an answer incomplete.
+- Do not create FAQ candidates about what the assistant should say or do, how it should use sources or tools, when it should route or escalate,
+ or which exact message it should send. Classify those as Response Guidelines, Guardrails, Scenario Candidates, Conversation Messages,
+ or Needs Review as appropriate.
+- FAQ questions must ask about the product or business, not about the assistant. Do not write questions such as "What should the assistant answer?",
+ "What should I say?", "Which source should the assistant use?", or "Which tool should be called?".
+- FAQ answers must contain customer-facing knowledge, not instructions to call tools, inspect internal data, update records, transfer conversations,
+ or follow internal workflows.
+- When factual sources conflict and the instructions do not explicitly establish which fact overrides the others, put the conflict in Needs Review
+ instead of creating an FAQ candidate. Use an explicitly stated override or superseding fact when one is present.
+- Only factual or product-specific knowledge should become FAQs/Documents candidates.
+- Generic capability statements such as "answer product questions", "help with billing",
+ "troubleshoot common issues", or "direct to documentation" are not FAQ/document candidates.
+ Put them in Business/Product Context or Response Guidelines when useful.
+- Product facts, pricing, policies, setup steps, troubleshooting facts, support hours, emergency contacts,
+ and operational details should become pending FAQ candidates, not Response Guidelines or trusted approved knowledge.
+- Do not create FAQ/document candidates for topic labels or unsupported capabilities when the factual content is
+ missing. Put "pricing details are needed", "same-day delivery schedule details are needed", or similar gaps in
+ Needs Review instead.
diff --git a/lib/captain/overview_summary_service.rb b/lib/captain/overview_summary_service.rb
new file mode 100644
index 000000000..e11132d42
--- /dev/null
+++ b/lib/captain/overview_summary_service.rb
@@ -0,0 +1,83 @@
+# Generates the LLM welcome summary for the Captain Overview page from the
+# assistant's stats hash (see Captain::AssistantStatsBuilder). Renders the
+# captain_overview_summary.liquid prompt and returns markdown.
+class Captain::OverviewSummaryService < Captain::BaseTaskService
+ pattr_initialize [:account!, :assistant!, :first_name!, :stats!, :period!]
+
+ def perform
+ api_response = make_api_call(
+ feature: 'editor',
+ messages: [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: 'Write the summary.' }
+ ]
+ )
+
+ return api_response if api_response[:error]
+
+ { message: api_response[:message] }
+ end
+
+ private
+
+ def system_prompt
+ Liquid::Template.parse(prompt_from_file('captain_overview_summary')).render(prompt_variables)
+ end
+
+ def prompt_variables
+ stat_variables.merge(period_variables)
+ end
+
+ def stat_variables
+ {
+ 'first_name' => first_name.to_s,
+ 'assistant_name' => assistant.name.to_s,
+ 'conversations_handled' => current(:conversations_handled),
+ 'hours_saved' => current(:hours_saved),
+ 'auto_resolution_rate' => current(:auto_resolution_rate),
+ 'auto_resolution_trend' => trend(:auto_resolution_rate),
+ 'handoff_rate' => current(:handoff_rate),
+ 'handoff_trend' => trend(:handoff_rate),
+ 'reopen_rate' => current(:reopen_rate),
+ 'reopen_trend' => trend(:reopen_rate),
+ 'knowledge_coverage' => stats.dig(:knowledge, :coverage).to_s,
+ 'knowledge_approved' => stats.dig(:knowledge, :approved).to_s,
+ 'knowledge_documents' => stats.dig(:knowledge, :documents).to_s
+ }
+ end
+
+ def period_variables
+ {
+ 'today' => formatted_date(Time.zone.today),
+ 'period_label' => period[:label].to_s,
+ 'period_start' => formatted_date(period[:starts_on]),
+ 'period_end' => formatted_date(period[:ends_on])
+ }
+ end
+
+ def formatted_date(date)
+ date.strftime('%B %-d, %Y')
+ end
+
+ def current(key)
+ stats.dig(key, :current).to_s
+ end
+
+ def trend(key)
+ stats.dig(key, :trend).to_s
+ end
+
+ def event_name
+ 'captain_overview_summary'
+ end
+
+ def use_account_openai_hook?
+ true
+ end
+
+ # The overview summary is an internal analytics readout, not a customer-facing
+ # response, so it should not consume or be blocked by the captain_responses quota.
+ def counts_toward_usage?
+ false
+ end
+end
diff --git a/lib/custom_exceptions/inbox/limit_exceeded.rb b/lib/custom_exceptions/inbox/limit_exceeded.rb
new file mode 100644
index 000000000..9b5624929
--- /dev/null
+++ b/lib/custom_exceptions/inbox/limit_exceeded.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class CustomExceptions::Inbox::LimitExceeded < CustomExceptions::Base
+ def message
+ 'Account limit exceeded. Upgrade to a higher plan'
+ end
+
+ def to_hash
+ { error: message }
+ end
+
+ def http_status
+ :payment_required
+ end
+end
diff --git a/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid b/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid
new file mode 100644
index 000000000..19c32ed7c
--- /dev/null
+++ b/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid
@@ -0,0 +1,38 @@
+You are writing a short, warm summary of how an AI support assistant named "{{ assistant_name }}" performed over a reporting period, for {{ first_name }}, the person who manages it.
+
+Voice and format:
+- Address {{ first_name }} directly and open with "Hey {{ first_name }},". Be conversational, never robotic.
+- Always call the assistant by its name, {{ assistant_name }}. Never call it "Captain", "the assistant", or "your assistant".
+- This is a static, read-only poster on an analytics dashboard, not a chat. The reader cannot reply or ask you for anything. Never ask a question, invite a reply, offer further help, or say things like "let me know" or "I can dive in".
+- Write 2 to 4 sentences in one short paragraph. Add a second short paragraph only for a genuinely useful heads-up.
+- Output plain markdown only: no headings, lists, preamble, or sign-off. Do not use em dashes.
+- Never state an exact figure. This summary is cached and the live numbers keep moving, so a precise value would quickly look wrong. Round every number down to a clean approximation and soften it with words like "around", "roughly", "about", "nearly", "just over", or "upwards of". For example, render **1,248** as "upwards of **1,200**", **63.2%** as "around **60%**", and **612** hours as "roughly **600** hours". For a small count, use a loose phrase like "a handful" instead of the exact number.
+- Wrap the approximate figure in **double asterisks** so the interface can highlight it. Bold only the figures, never whole phrases or the softening word.
+
+Timing: today is {{ today }}. These stats cover {{ period_label }} ({{ period_start }} to {{ period_end }}). You may lightly reference the month, the season, or how far into the period things stand when it genuinely fits, but never invent events or facts.
+
+The stats for this period:
+
+- Conversations handled: {{ conversations_handled }}. Distinct conversations {{ assistant_name }} replied in at least once. Raw volume and adoption, not a measure of quality.
+- Hours saved: {{ hours_saved }} hours. A rough, directional estimate of agent time saved. A feel-good figure, not exact measured labor.
+- Auto-resolution rate: {{ auto_resolution_rate }}% ({{ auto_resolution_trend }} points vs previous period). Of the conversations it handled, the share {{ assistant_name }} resolved on its own with no human reply. The core performance signal; higher is better.
+- Handoff rate: {{ handoff_rate }}% ({{ handoff_trend }} points vs previous period). Of the conversations it handled, the share it escalated to a human agent. The inverse of deflection; lower is better.
+- Reopen-after-resolve rate: {{ reopen_rate }}% ({{ reopen_trend }} points vs previous period). Of the conversations it auto-resolved, the share later reopened. A quality signal; lower is better, and a high value means it closed conversations the customer was not actually done with.
+- Knowledge base: {{ knowledge_approved }} approved FAQ answers, {{ knowledge_documents }} documents, {{ knowledge_coverage }}% coverage (the share of FAQ answers the team has approved). This is setup the team controls, not something {{ assistant_name }} earned. It is a leading indicator: low coverage tends to cause low auto-resolution.
+
+
+Only the auto-resolution, handoff, and reopen rates reflect how {{ assistant_name }} actually performed, and they are the only things worth crediting it for. Conversations handled and hours saved are context. The knowledge base is an input, never a win to praise.
+
+How to judge the numbers (rough bands, do not quote them in the summary):
+- Auto-resolution rate: below 30% is low and early-stage, 30 to 50% is decent, above 50% is genuinely strong.
+- Handoff rate: above 60% is high, 30 to 60% is moderate, below 30% is strong.
+- Reopen-after-resolve rate: below 5% is healthy, 5 to 15% is worth watching, above 15% is a real problem.
+- Knowledge coverage: only worth mentioning when below 85% (below 60% is seriously thin), as a likely cause of weak auto-resolution. At 85% or above it is just the healthy baseline, so do not mention or praise it.
+- When the conversation volume is small (roughly under 30), rates are noisy, so describe them tentatively and do not over-interpret a perfect or terrible looking percentage.
+
+Writing the summary:
+- Cold start: if conversations handled is 0, there is no performance to report. Skip the auto-resolution, handoff, reopen, and hours-saved figures entirely. Instead note the knowledge base and say {{ assistant_name }} is set up and ready to start handling support (or ready to start once some knowledge is added, if the base is empty). Ignore the rest of these points in this case.
+- Be honest and proportionate. Do not call a result impressive, strong, excellent, solid, flawless, or perfect unless it clears the "strong" band above. State a low or middling number plainly or as room to grow, never dressed up. A modest summary is fine and often correct.
+- Lead with the genuinely strong results if there are any. If nothing clears the strong band, open plainly with the volume of work handled, without overselling it.
+- Mention a trend only when it is meaningful, and judge it against the bands rather than the direction alone (a rate that rose but is still in the low band is not yet a win).
+- Surface at most one proactive concern when a stat warrants it (a high handoff rate, a low auto-resolution rate, a rising reopen rate, or thin coverage). Skip it entirely when everything looks healthy. Keep it a calm observation about the data, not an alarm.
diff --git a/lib/llm/feature_router.rb b/lib/llm/feature_router.rb
index da0aa56e9..d75b06dca 100644
--- a/lib/llm/feature_router.rb
+++ b/lib/llm/feature_router.rb
@@ -1,6 +1,8 @@
module Llm::FeatureRouter
class UnknownFeatureError < StandardError; end
+ CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.2'.freeze
+
class << self
def resolve(feature:, account: nil)
feature_key = feature.to_s
@@ -8,6 +10,7 @@ module Llm::FeatureRouter
model = account_model_override(account, feature_key)
source = model.present? ? :account_override : :default
+ model ||= captain_v2_assistant_model(account, feature_key)
model ||= Llm::Models.default_model_for(feature_key)
{
@@ -25,5 +28,12 @@ module Llm::FeatureRouter
return unless model
return model if Llm::Models.valid_model_for?(feature_key, model)
end
+
+ def captain_v2_assistant_model(account, feature_key)
+ return unless feature_key == 'assistant'
+ return unless account&.feature_enabled?('captain_integration_v2')
+
+ CAPTAIN_V2_ASSISTANT_MODEL
+ end
end
end
diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb
index 6d503df22..b782270ef 100644
--- a/lib/redis/redis_keys.rb
+++ b/lib/redis/redis_keys.rb
@@ -33,6 +33,23 @@ module Redis::RedisKeys
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::TEAM::%d::INBOX::%d::UNASSIGNED'.freeze
UNREAD_CONVERSATIONS_TEAM_INBOX_ASSIGNEE =
'UNREAD_CONVERSATIONS::V1::ACCOUNT::%d::TEAM::%d::INBOX::%d::ASSIGNEE::%d'.freeze
+ UNREAD_CONVERSATIONS_V2_ACCOUNT_PREFIX = 'UNREAD_CONVERSATIONS::V2::ACCOUNT::%d'.freeze
+ UNREAD_CONVERSATIONS_V2_USER_PREFIX = "#{UNREAD_CONVERSATIONS_V2_ACCOUNT_PREFIX}::USER::%d".freeze
+ UNREAD_CONVERSATIONS_V2_FILTER_PREFIX = "#{UNREAD_CONVERSATIONS_V2_ACCOUNT_PREFIX}::FILTER::%d".freeze
+ UNREAD_CONVERSATIONS_V2_CONVERSATION_VERSION = "#{UNREAD_CONVERSATIONS_V2_ACCOUNT_PREFIX}::CONVERSATION_VERSION".freeze
+ UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_VERSION = "#{UNREAD_CONVERSATIONS_V2_USER_PREFIX}::BUILT_IN_FILTER_VERSION".freeze
+ UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_COUNTS = "#{UNREAD_CONVERSATIONS_V2_USER_PREFIX}::BUILT_IN_FILTER_COUNTS".freeze
+ UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_BUILD_LOCK = "#{UNREAD_CONVERSATIONS_V2_USER_PREFIX}::BUILT_IN_FILTER_BUILD_LOCK".freeze
+ UNREAD_CONVERSATIONS_V2_BUILT_IN_FILTER_REFRESH_THROTTLE =
+ "#{UNREAD_CONVERSATIONS_V2_USER_PREFIX}::BUILT_IN_FILTER_REFRESH_THROTTLE".freeze
+ UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_VERSION = "#{UNREAD_CONVERSATIONS_V2_USER_PREFIX}::FOLDER_INDEX_VERSION".freeze
+ UNREAD_CONVERSATIONS_V2_FOLDER_INDEX = "#{UNREAD_CONVERSATIONS_V2_USER_PREFIX}::FOLDER_INDEX".freeze
+ UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_BUILD_LOCK = "#{UNREAD_CONVERSATIONS_V2_USER_PREFIX}::FOLDER_INDEX_BUILD_LOCK".freeze
+ UNREAD_CONVERSATIONS_V2_FOLDER_INDEX_REFRESH_THROTTLE = "#{UNREAD_CONVERSATIONS_V2_USER_PREFIX}::FOLDER_INDEX_REFRESH_THROTTLE".freeze
+ UNREAD_CONVERSATIONS_V2_FILTER_VERSION = "#{UNREAD_CONVERSATIONS_V2_FILTER_PREFIX}::VERSION".freeze
+ UNREAD_CONVERSATIONS_V2_FILTER_COUNT = "#{UNREAD_CONVERSATIONS_V2_FILTER_PREFIX}::COUNT".freeze
+ UNREAD_CONVERSATIONS_V2_FILTER_BUILD_LOCK = "#{UNREAD_CONVERSATIONS_V2_FILTER_PREFIX}::BUILD_LOCK".freeze
+ UNREAD_CONVERSATIONS_V2_FILTER_REFRESH_THROTTLE = "#{UNREAD_CONVERSATIONS_V2_FILTER_PREFIX}::REFRESH_THROTTLE".freeze
## User Keys
# SSO Auth Tokens
diff --git a/lib/seeders/reports/assistant_conversation_creator.rb b/lib/seeders/reports/assistant_conversation_creator.rb
new file mode 100644
index 000000000..9462d899e
--- /dev/null
+++ b/lib/seeders/reports/assistant_conversation_creator.rb
@@ -0,0 +1,203 @@
+# frozen_string_literal: true
+
+require 'faker'
+require 'active_support/testing/time_helpers'
+
+# Seeds Captain assistant activity for the reports/overview test data.
+#
+# Produces a variety of assistant-handled conversations in a single web inbox so
+# every Captain assistant overview metric (handled, auto-resolution, handoff,
+# hours saved, reopen rate, conversation depth) has realistic data:
+# - :resolved_by_assistant assistant answers and Captain auto-resolves
+# - :handled_by_both assistant answers, a human also replies and resolves
+# - :handed_off assistant answers, then hands off to a human
+# - :resolved_and_reopened Captain resolves, then the conversation reopens
+#
+# Reporting events are fired through ReportingEventListener directly (mirroring
+# ConversationCreator) so the same rows the builder reads from get populated.
+class Seeders::Reports::AssistantConversationCreator
+ include ActiveSupport::Testing::TimeHelpers
+
+ OUTCOMES = %i[resolved_by_assistant handled_by_both handed_off resolved_and_reopened].freeze
+
+ def initialize(account:, assistant:, inbox:, resources:)
+ @account = account
+ @assistant = assistant
+ @inbox = inbox
+ @contacts = resources[:contacts]
+ @agents = inbox.members.to_a.presence || resources[:agents]
+ end
+
+ def create_conversation(created_at:, outcome:)
+ conversation = nil
+
+ travel_to(created_at) do
+ conversation = build_conversation
+ conversation.save!
+ seed_dialogue(conversation, outcome)
+ end
+ travel_back
+
+ apply_outcome(conversation, created_at, outcome)
+ conversation
+ end
+
+ private
+
+ def build_conversation
+ contact = @contacts.sample
+ contact_inbox = @inbox.contact_inboxes.find_or_create_by!(contact: contact, source_id: SecureRandom.hex)
+
+ contact_inbox.conversations.create!(
+ account: @account,
+ inbox: @inbox,
+ contact: contact,
+ priority: [nil, 'high', 'medium', 'low'].sample
+ )
+ end
+
+ # Builds the message exchange for the conversation while time is frozen at its
+ # creation moment. Every outcome starts with a customer question and at least
+ # one public assistant reply so the conversation lands in the assistant's
+ # handled set; some outcomes add a human reply or a handoff.
+ def seed_dialogue(conversation, outcome)
+ customer_message = incoming_message(conversation)
+
+ travel(rand((20.seconds)..(5.minutes)))
+ assistant_reply(conversation, waiting_since: customer_message.created_at)
+
+ case outcome
+ when :handed_off then seed_handoff(conversation)
+ when :handled_by_both then seed_human_turn(conversation)
+ else seed_assistant_follow_up(conversation)
+ end
+ end
+
+ def seed_handoff(conversation)
+ travel(rand((1.minute)..(10.minutes)))
+ handoff_to_human(conversation)
+ travel(rand((1.minute)..(15.minutes)))
+ human_reply(conversation)
+ end
+
+ def seed_human_turn(conversation)
+ travel(rand((1.minute)..(15.minutes)))
+ human_reply(conversation)
+ end
+
+ # Pure assistant threads occasionally take a second turn, giving depth > 1.
+ def seed_assistant_follow_up(conversation)
+ return unless rand < 0.6
+
+ travel(rand((1.minute)..(10.minutes)))
+ follow_up = incoming_message(conversation)
+ travel(rand((20.seconds)..(5.minutes)))
+ assistant_reply(conversation, waiting_since: follow_up.created_at)
+ end
+
+ def apply_outcome(conversation, created_at, outcome)
+ resolved_at = created_at + rand((30.minutes)..(8.hours))
+
+ case outcome
+ when :resolved_by_assistant
+ resolve_by_captain(conversation, resolved_at)
+ when :handled_by_both
+ resolve_by_human(conversation, resolved_at)
+ when :handed_off
+ resolve_by_human(conversation, resolved_at) if rand < 0.6
+ when :resolved_and_reopened
+ resolve_by_captain(conversation, resolved_at)
+ reopen(conversation, resolved_at + rand((1.hour)..(24.hours)))
+ end
+ end
+
+ def incoming_message(conversation)
+ conversation.messages.create!(
+ account: @account,
+ inbox: @inbox,
+ message_type: :incoming,
+ content: Faker::Lorem.paragraph(sentence_count: rand(1..3)),
+ sender: conversation.contact
+ )
+ end
+
+ def assistant_reply(conversation, waiting_since:)
+ message = conversation.messages.create!(
+ account: @account,
+ inbox: @inbox,
+ message_type: :outgoing,
+ private: false,
+ content: Faker::Lorem.paragraph(sentence_count: rand(1..4)),
+ sender: @assistant
+ )
+ trigger_reply_time(message, waiting_since)
+ message
+ end
+
+ def human_reply(conversation)
+ agent = @agents.sample
+ conversation.update_column(:assignee_id, agent.id) if conversation.assignee_id.nil? # rubocop:disable Rails/SkipsModelValidations
+
+ conversation.messages.create!(
+ account: @account,
+ inbox: @inbox,
+ message_type: :outgoing,
+ private: false,
+ content: Faker::Lorem.paragraph(sentence_count: rand(1..4)),
+ sender: agent
+ )
+ end
+
+ def resolve_by_captain(conversation, resolved_at)
+ mark_resolved(conversation, resolved_at)
+ travel_to(resolved_at) do
+ trigger_event('conversation_resolved', conversation)
+ trigger_event('conversation_captain_inference_resolved', conversation)
+ end
+ travel_back
+ end
+
+ def resolve_by_human(conversation, resolved_at)
+ mark_resolved(conversation, resolved_at)
+ travel_to(resolved_at) do
+ trigger_event('conversation_resolved', conversation)
+ end
+ travel_back
+ end
+
+ def reopen(conversation, reopened_at)
+ # rubocop:disable Rails/SkipsModelValidations
+ conversation.update_column(:status, :open)
+ conversation.update_column(:updated_at, reopened_at)
+ # rubocop:enable Rails/SkipsModelValidations
+
+ travel_to(reopened_at) do
+ trigger_event('conversation_opened', conversation)
+ end
+ travel_back
+ end
+
+ def handoff_to_human(conversation)
+ trigger_event('conversation_captain_inference_handoff', conversation)
+ end
+
+ def mark_resolved(conversation, resolved_at)
+ # rubocop:disable Rails/SkipsModelValidations
+ conversation.update_column(:status, :resolved)
+ conversation.update_column(:updated_at, resolved_at)
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
+ def trigger_event(name, conversation)
+ ReportingEventListener.instance.public_send(
+ name, Events::Base.new(name, Time.current, { conversation: conversation })
+ )
+ end
+
+ def trigger_reply_time(message, waiting_since)
+ ReportingEventListener.instance.reply_created(
+ Events::Base.new('reply_created', Time.current,
+ { message: message, conversation: message.conversation, waiting_since: waiting_since })
+ )
+ end
+end
diff --git a/lib/seeders/reports/report_data_seeder.rb b/lib/seeders/reports/report_data_seeder.rb
index 909818b72..fecb02deb 100644
--- a/lib/seeders/reports/report_data_seeder.rb
+++ b/lib/seeders/reports/report_data_seeder.rb
@@ -17,6 +17,9 @@
# - 5 teams with realistic distribution
# - 30 labels with random assignments
# - 3 inboxes with agent assignments
+# - 1 Captain assistant bound to a single web inbox, with knowledge (FAQs + documents)
+# and a variety of assistant-handled conversations (auto-resolved, handed off,
+# handled with a human, resolved-then-reopened) for the assistant overview page
# - Realistic reporting events with historical timestamps
#
# Note: This seeder clears existing data for the account before seeding.
@@ -24,8 +27,9 @@
require 'faker'
require_relative 'conversation_creator'
require_relative 'message_creator'
+require_relative 'assistant_conversation_creator'
-# rubocop:disable Rails/Output
+# rubocop:disable Rails/Output, Metrics/ClassLength
class Seeders::Reports::ReportDataSeeder
include ActiveSupport::Testing::TimeHelpers
@@ -36,6 +40,11 @@ class Seeders::Reports::ReportDataSeeder
TOTAL_LABELS = 30
TOTAL_INBOXES = 3
MESSAGES_PER_CONVERSATION = 5
+ # Captain assistant conversations, split across the outcomes the overview page reports on.
+ TOTAL_ASSISTANT_CONVERSATIONS = 120
+ ASSISTANT_KNOWLEDGE_APPROVED = 14
+ ASSISTANT_KNOWLEDGE_PENDING = 6
+ ASSISTANT_DOCUMENTS = 4
START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
END_DATE = Time.current
@@ -48,6 +57,8 @@ class Seeders::Reports::ReportDataSeeder
@labels = []
@inboxes = []
@contacts = []
+ @assistant = nil
+ @assistant_inbox = nil
end
def perform!
@@ -61,8 +72,10 @@ class Seeders::Reports::ReportDataSeeder
create_labels
create_inboxes
create_contacts
+ create_assistant
create_conversations
+ create_assistant_conversations
puts "Completed reports data seeding for account: #{@account.name}"
end
@@ -71,6 +84,7 @@ class Seeders::Reports::ReportDataSeeder
def clear_existing_data
puts "Clearing existing data for account: #{@account.id}"
+ clear_assistant_data
@account.teams.destroy_all
@account.conversations.destroy_all
@account.labels.destroy_all
@@ -80,6 +94,16 @@ class Seeders::Reports::ReportDataSeeder
@account.reporting_events.destroy_all
end
+ # Delete Captain records directly (assistant associations are destroy_async, which
+ # would leave rows around mid-reseed); order respects foreign keys.
+ def clear_assistant_data
+ assistant_ids = Captain::Assistant.for_account(@account.id).select(:id)
+ Captain::AssistantResponse.by_account(@account.id).delete_all
+ Captain::Document.for_account(@account.id).delete_all
+ CaptainInbox.where(captain_assistant_id: assistant_ids).delete_all
+ Captain::Assistant.for_account(@account.id).delete_all
+ end
+
def create_teams
TOTAL_TEAMS.times do |i|
team = @account.teams.create!(
@@ -208,6 +232,80 @@ class Seeders::Reports::ReportDataSeeder
print "\n"
end
+ # One assistant, bound to a single web inbox (the first one), as the overview page expects.
+ def create_assistant
+ @account.enable_features!('captain_integration', 'captain_integration_v2')
+ @assistant_inbox = @inboxes.first
+ @assistant = Captain::Assistant.create!(
+ account: @account,
+ name: "#{Faker::Company.name} Copilot",
+ description: 'Captain assistant handling website support conversations.',
+ config: { feature_faq: true, feature_memory: true, product_name: @account.name }
+ )
+ CaptainInbox.create!(captain_assistant: @assistant, inbox: @assistant_inbox)
+ create_assistant_knowledge
+
+ puts "Created assistant '#{@assistant.name}' for inbox '#{@assistant_inbox.name}'"
+ end
+
+ def create_assistant_knowledge
+ ASSISTANT_KNOWLEDGE_APPROVED.times { create_assistant_response(:approved) }
+ ASSISTANT_KNOWLEDGE_PENDING.times { create_assistant_response(:pending) }
+
+ ASSISTANT_DOCUMENTS.times do
+ Captain::Document.create!(
+ account: @account,
+ assistant: @assistant,
+ name: Faker::Company.catch_phrase,
+ external_link: "https://#{Faker::Internet.domain_name}/#{Faker::Internet.slug}",
+ content: Faker::Lorem.paragraphs(number: rand(2..4)).join("\n\n"),
+ status: :available,
+ sync_status: :synced
+ )
+ end
+ end
+
+ def create_assistant_response(status)
+ Captain::AssistantResponse.create!(
+ account: @account,
+ assistant: @assistant,
+ question: "#{Faker::Lorem.sentence(word_count: rand(4..8)).chomp('.')}?",
+ answer: Faker::Lorem.paragraph(sentence_count: rand(2..4)),
+ status: status
+ )
+ end
+
+ def create_assistant_conversations
+ creator = Seeders::Reports::AssistantConversationCreator.new(
+ account: @account,
+ assistant: @assistant,
+ inbox: @assistant_inbox,
+ resources: { contacts: @contacts, agents: @agents }
+ )
+
+ outcomes = assistant_outcome_distribution
+ outcomes.each_with_index do |outcome, i|
+ created_at = Faker::Time.between(from: 65.days.ago, to: END_DATE)
+ creator.create_conversation(created_at: created_at, outcome: outcome)
+
+ print "\rCreating assistant conversations: #{i + 1}/#{outcomes.size}"
+ end
+
+ print "\n"
+ end
+
+ # Weighted mix of outcomes so every overview metric has meaningful numbers, shuffled
+ # so they interleave across the time span rather than clustering by type.
+ def assistant_outcome_distribution
+ counts = {
+ resolved_by_assistant: (TOTAL_ASSISTANT_CONVERSATIONS * 0.4).round,
+ handled_by_both: (TOTAL_ASSISTANT_CONVERSATIONS * 0.25).round,
+ handed_off: (TOTAL_ASSISTANT_CONVERSATIONS * 0.2).round,
+ resolved_and_reopened: (TOTAL_ASSISTANT_CONVERSATIONS * 0.15).round
+ }
+ counts.flat_map { |outcome, count| [outcome] * count }.shuffle
+ end
+
def create_conversations
conversation_creator = Seeders::Reports::ConversationCreator.new(
account: @account,
@@ -231,4 +329,4 @@ class Seeders::Reports::ReportDataSeeder
print "\n"
end
end
-# rubocop:enable Rails/Output
+# rubocop:enable Rails/Output, Metrics/ClassLength
diff --git a/lib/tasks/captain_assistant_migration.rake b/lib/tasks/captain_assistant_migration.rake
new file mode 100644
index 000000000..7cb8c2e1c
--- /dev/null
+++ b/lib/tasks/captain_assistant_migration.rake
@@ -0,0 +1,278 @@
+require 'json'
+require 'fileutils'
+require 'csv'
+
+# rubocop:disable Metrics/BlockLength
+namespace :captain do
+ namespace :assistant_migration do
+ desc 'Generate structured migration drafts. Usage: rake captain:assistant_migration:generate IDS=1,2,3 LIMIT=50 ' \
+ 'OUTPUT=tmp/captain_migration.jsonl'
+ task generate: :environment do
+ assistants = CaptainAssistantMigrationTask.assistants
+ output_path = ENV.fetch('OUTPUT', Rails.root.join('tmp/captain_assistant_migration_drafts.jsonl').to_s)
+
+ FileUtils.mkdir_p(File.dirname(output_path))
+ processed = 0
+
+ File.open(output_path, 'w') do |file|
+ CaptainAssistantMigrationTask.each_assistant(assistants) do |assistant|
+ result = Captain::AssistantMigration::InstructionClassifier.new(assistant: assistant).perform
+ file.puts(JSON.generate(result))
+ processed += 1
+ puts "Generated migration draft for assistant #{assistant.id} (#{processed}/#{CaptainAssistantMigrationTask.assistant_count(assistants)})"
+ end
+ end
+
+ puts "Wrote #{processed} migration drafts to #{output_path}"
+ end
+
+ desc 'Apply reviewed migration drafts. Usage: rake captain:assistant_migration:apply INPUT=tmp/reviewed.jsonl DRY_RUN=true'
+ task apply: :environment do
+ input_path = ENV.fetch('INPUT')
+ dry_run = CaptainAssistantMigrationTask.truthy?('DRY_RUN', default: true)
+
+ results = CaptainAssistantMigrationTask.apply_drafts(
+ input_path: input_path,
+ dry_run: dry_run
+ )
+
+ results.each { |result| puts(JSON.generate(result)) }
+ puts "Processed #{results.size} migration drafts from #{input_path}"
+ puts 'Dry run only. Re-run with DRY_RUN=false to write changes.' if dry_run
+ end
+
+ desc 'Restore conversation message config from migration backup. Usage: rake captain:assistant_migration:restore_messages IDS=1,2 DRY_RUN=true'
+ task restore_messages: :environment do
+ dry_run = CaptainAssistantMigrationTask.truthy?('DRY_RUN', default: true)
+ results = CaptainAssistantMigrationTask.restore_conversation_messages(dry_run: dry_run)
+
+ results.each { |result| puts(JSON.generate(result)) }
+ puts "Processed #{results.size} assistant message restores"
+ puts 'Dry run only. Re-run with DRY_RUN=false to restore conversation messages.' if dry_run
+ end
+ end
+end
+# rubocop:enable Metrics/BlockLength
+
+# rubocop:disable Style/OneClassPerFile
+class CaptainAssistantMigrationTask
+ CsvAccount = Struct.new(:id, :name, keyword_init: true) do
+ def captain_models
+ {}
+ end
+
+ def conversations
+ CsvRelation.new
+ end
+ end
+
+ CsvAssociation = Struct.new(:inbox_count, keyword_init: true) do
+ def size
+ inbox_count
+ end
+ end
+
+ class CsvRelation
+ def find_by(*)
+ nil
+ end
+
+ def exists?
+ false
+ end
+ end
+
+ CsvAssistant = Struct.new(
+ :id,
+ :name,
+ :account_id,
+ :account,
+ :description,
+ :config,
+ :response_guidelines,
+ :guardrails,
+ :captain_inboxes,
+ :scenarios,
+ keyword_init: true
+ )
+
+ class << self
+ def assistants
+ return csv_assistants if ENV['CSV_INPUT'].present?
+
+ scope = Captain::Assistant.includes(:account, :captain_inboxes, :scenarios)
+
+ ids = ENV.fetch('IDS', '').split(',').filter_map { |id| id.strip.presence }
+ scope = scope.where(id: ids) if ids.any?
+
+ scope = migration_eligible_scope(scope).order(:id)
+
+ limit = ENV.fetch('LIMIT', 50).to_i
+ limit.positive? ? scope.limit(limit) : scope
+ end
+
+ def each_assistant(assistants, &)
+ return assistants.find_each(&) if assistants.respond_to?(:find_each)
+
+ assistants.each(&)
+ end
+
+ def assistant_count(assistants)
+ assistants.respond_to?(:size) ? assistants.size : assistants.count
+ end
+
+ def restore_conversation_messages(dry_run:)
+ ENV.fetch('IDS').split(',').filter_map { |id| id.strip.presence }.map do |assistant_id|
+ assistant = Captain::Assistant.find(assistant_id)
+ restore_conversation_messages_for(assistant, dry_run: dry_run)
+ rescue ActiveRecord::RecordNotFound
+ { assistant_id: assistant_id, error: 'Assistant not found' }
+ end
+ end
+
+ def apply_drafts(input_path:, dry_run:)
+ File.readlines(input_path, chomp: true).filter_map.with_index(1) do |line, line_number|
+ next if line.blank?
+
+ apply_draft(JSON.parse(line), line_number: line_number, dry_run: dry_run)
+ rescue JSON::ParserError => e
+ { line_number: line_number, error: "Invalid JSON: #{e.message}" }
+ end
+ end
+
+ def apply_draft(payload, line_number:, dry_run:)
+ return { line_number: line_number, skipped: true, reason: payload['error'] } if payload['error'].present?
+
+ assistant_id = payload.dig('assistant', 'id') || payload['assistant_id']
+ assistant = Captain::Assistant.find(assistant_id)
+ return skipped_result(line_number, assistant_id, 'Assistant is not a V1 migration candidate') unless migration_candidate?(assistant)
+
+ draft = payload['draft'] || payload
+
+ Captain::AssistantMigration::DraftApplier.new(
+ assistant: assistant,
+ draft: draft,
+ dry_run: dry_run
+ ).perform.merge(line_number: line_number)
+ rescue ActiveRecord::RecordNotFound
+ { line_number: line_number, assistant_id: assistant_id, error: 'Assistant not found' }
+ end
+
+ def truthy?(key, default:)
+ value = ENV.fetch(key, nil)
+ return default if value.nil?
+
+ value.to_s.downcase.in?(%w[1 true yes y])
+ end
+
+ private
+
+ def restore_conversation_messages_for(assistant, dry_run:)
+ original_config = assistant.config.dig(
+ Captain::AssistantMigration::DraftApplier::CONFIG_KEY,
+ Captain::AssistantMigration::DraftApplier::ORIGINAL_VALUES_KEY,
+ 'config'
+ )
+ return skipped_result(nil, assistant.id, 'No stored migration original config found') if original_config.nil?
+
+ config, changes = restored_message_config(assistant.config.deep_dup, original_config)
+ assistant.update!(config: config) if !dry_run && changes.present?
+
+ { assistant_id: assistant.id, dry_run: dry_run, changes: changes }
+ end
+
+ def restored_message_config(config, original_config)
+ changes = {}
+ %w[welcome_message handoff_message resolution_message].each do |key|
+ original_present = original_config.key?(key)
+ next if config[key] == original_config[key] && config.key?(key) == original_present
+
+ changes[key] = { from: config[key], to: original_config[key] }
+ original_present ? config[key] = original_config[key] : config.delete(key)
+ end
+ [config, changes]
+ end
+
+ def skipped_result(line_number, assistant_id, reason)
+ {
+ line_number: line_number,
+ assistant_id: assistant_id,
+ skipped: true,
+ reason: reason
+ }
+ end
+
+ def migration_eligible_scope(scope)
+ scope.left_outer_joins(:scenarios)
+ .joins(:captain_inboxes)
+ .where("NULLIF(captain_assistants.config->>'instructions', '') IS NOT NULL")
+ .where("captain_assistants.response_guidelines IS NULL OR captain_assistants.response_guidelines = '[]'::jsonb")
+ .where("captain_assistants.guardrails IS NULL OR captain_assistants.guardrails = '[]'::jsonb")
+ .where(captain_scenarios: { id: nil })
+ .distinct
+ end
+
+ def migration_candidate?(assistant)
+ assistant.config['instructions'].present? &&
+ assistant.captain_inboxes.size.positive? &&
+ Array(assistant.response_guidelines).blank? &&
+ Array(assistant.guardrails).blank? &&
+ !scenarios_exist?(assistant)
+ end
+
+ def scenarios_exist?(assistant)
+ scenarios = assistant.scenarios
+ return scenarios.exists? if scenarios.respond_to?(:exists?)
+
+ scenarios.present?
+ end
+
+ def csv_assistants # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ rows = CSV.read(ENV.fetch('CSV_INPUT'), headers: true)
+ ids = ENV.fetch('IDS', '').split(',').filter_map { |id| id.strip.presence }
+ status = ENV.fetch('STATUS', '').presence
+
+ assistants = rows.filter_map do |row|
+ next if ids.any? && ids.exclude?(row['id'].to_s)
+ next if status.present? && row['status'].to_s != status
+
+ assistant = csv_assistant(row)
+ next unless migration_candidate?(assistant)
+
+ assistant
+ end
+
+ limit = ENV.fetch('LIMIT', 50).to_i
+ limit.positive? ? assistants.first(limit) : assistants
+ end
+
+ def csv_assistant(row)
+ config = parse_json(row['config'], fallback: {})
+ CsvAssistant.new(
+ id: normalize_integer(row['id']),
+ name: row['name'].to_s,
+ account_id: normalize_integer(row['account_id']),
+ account: CsvAccount.new(id: normalize_integer(row['account_id']), name: row['account_name'].to_s),
+ description: row['description'].to_s,
+ config: config,
+ response_guidelines: parse_json(row['response_guidelines'], fallback: []),
+ guardrails: parse_json(row['guardrails'], fallback: []),
+ captain_inboxes: CsvAssociation.new(inbox_count: normalize_integer(row['inbox_count'])),
+ scenarios: []
+ )
+ end
+
+ def parse_json(value, fallback:)
+ return fallback if value.blank?
+
+ JSON.parse(value)
+ rescue JSON::ParserError
+ fallback
+ end
+
+ def normalize_integer(value)
+ value.to_s.delete(',').to_i
+ end
+ end
+end
+# rubocop:enable Style/OneClassPerFile
diff --git a/lib/tasks/feature_defaults.rake b/lib/tasks/feature_defaults.rake
new file mode 100644
index 000000000..6b6e0443a
--- /dev/null
+++ b/lib/tasks/feature_defaults.rake
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+# rubocop:disable Metrics/BlockLength
+namespace :feature_defaults do
+ desc 'Interactively toggle a feature on/off in ACCOUNT_LEVEL_FEATURE_DEFAULTS (affects new account signups only)'
+ task toggle: :environment do
+ config = InstallationConfig.find_by!(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
+
+ loop do
+ features = config.value
+ print_feature_list(features)
+
+ print "\nEnter the number of the feature to toggle (or 'q' to quit): "
+ input = $stdin.gets.chomp
+ break if input.casecmp('q').zero?
+
+ feature = select_feature(features, input)
+ if feature.nil?
+ puts 'Invalid selection.'
+ next
+ end
+
+ toggle_feature(config, features, feature)
+ end
+
+ puts 'Done.'
+ end
+
+ def print_feature_list(features)
+ puts "\n#{'#'.ljust(4)}#{'name'.ljust(35)}#{'display_name'.ljust(30)}enabled"
+ features.each_with_index do |feature, index|
+ puts "#{(index + 1).to_s.ljust(4)}#{feature['name'].to_s.ljust(35)}#{feature['display_name'].to_s.ljust(30)}#{feature['enabled']}"
+ end
+ end
+
+ def select_feature(features, input)
+ index = Integer(input, exception: false)
+ return nil if index.nil? || !index.between?(1, features.length)
+
+ features[index - 1]
+ end
+
+ def toggle_feature(config, features, feature)
+ print "#{feature['name']} is currently enabled: #{feature['enabled']}. Type 'true' or 'false' to set (anything else cancels): "
+ input = $stdin.gets.chomp
+
+ case input
+ when 'true'
+ new_state = true
+ when 'false'
+ new_state = false
+ else
+ puts 'Cancelled.'
+ return
+ end
+
+ feature['enabled'] = new_state
+ config.value = features
+ config.save!
+ GlobalConfig.clear_cache
+ puts "Updated #{feature['name']} to enabled: #{new_state}"
+ end
+end
+# rubocop:enable Metrics/BlockLength
diff --git a/package.json b/package.json
index 917a1b97d..d964a30a4 100644
--- a/package.json
+++ b/package.json
@@ -87,6 +87,7 @@
"opus-recorder": "^8.0.5",
"pinia": "^3.0.4",
"prosemirror-commands": "^1.7.1",
+ "prosemirror-inputrules": "^1.4.0",
"prosemirror-schema-list": "^1.5.1",
"qrcode": "^1.5.4",
"semver": "7.6.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 80fbf318a..0ffb85c18 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -183,6 +183,9 @@ importers:
prosemirror-commands:
specifier: ^1.7.1
version: 1.7.1
+ prosemirror-inputrules:
+ specifier: ^1.4.0
+ version: 1.4.0
prosemirror-schema-list:
specifier: ^1.5.1
version: 1.5.1
@@ -9037,7 +9040,7 @@ snapshots:
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.22.3
- prosemirror-transform: 1.10.0
+ prosemirror-transform: 1.12.0
prosemirror-view: 1.34.1
prosemirror-tables@1.5.0:
@@ -9065,7 +9068,7 @@ snapshots:
dependencies:
prosemirror-model: 1.22.3
prosemirror-state: 1.4.3
- prosemirror-transform: 1.10.0
+ prosemirror-transform: 1.12.0
proto-list@1.2.4: {}
diff --git a/public/dashboard/images/integrations/intercom.png b/public/dashboard/images/integrations/intercom.png
new file mode 100644
index 000000000..01fe070c0
Binary files /dev/null and b/public/dashboard/images/integrations/intercom.png differ
diff --git a/spec/controllers/api/v1/accounts/assignable_agents_controller_spec.rb b/spec/controllers/api/v1/accounts/assignable_agents_controller_spec.rb
index 6f1a068ee..3b6ff2393 100644
--- a/spec/controllers/api/v1/accounts/assignable_agents_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/assignable_agents_controller_spec.rb
@@ -62,6 +62,24 @@ RSpec.describe 'Assignable Agents API', type: :request do
expect(response_data.size).to eq(2)
expect(response_data.pluck(:role)).to include('agent', 'administrator')
end
+
+ context 'with Agent Bots' do
+ let!(:account_bot) { create(:agent_bot, account: account, name: 'Account bot') }
+ let!(:global_bot) { create(:agent_bot, account: nil, name: 'Global bot') }
+
+ it 'returns assignable agents and accessible agent bots' do
+ get "/api/v1/accounts/#{account.id}/assignable_agents",
+ params: { inbox_ids: [inbox1.id, inbox2.id], include_agent_bots: true },
+ headers: agent1.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ response_data = response.parsed_body['payload']
+ expect(response_data.pluck('assignee_type')).to include('User', 'AgentBot')
+ expect(response_data.pluck('name')).to include(agent1.name, admin.name, account_bot.name, global_bot.name)
+ end
+ end
end
end
end
diff --git a/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb b/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
index dfc2e4ff0..6a28c60da 100644
--- a/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
@@ -67,6 +67,50 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
source: 'default'
)
end
+
+ it 'returns the assistant YAML default for V1 accounts' do
+ get "/api/v1/accounts/#{account.id}/captain/preferences",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response.dig(:features, :assistant)).to include(
+ default: Llm::Models.default_model_for('assistant'),
+ selected: Llm::Models.default_model_for('assistant'),
+ source: 'default'
+ )
+ end
+
+ it 'returns GPT-5.2 as the assistant default for V2 accounts' do
+ account.enable_features!('captain_integration_v2')
+
+ get "/api/v1/accounts/#{account.id}/captain/preferences",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response.dig(:features, :assistant)).to include(
+ default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
+ selected: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
+ source: 'default'
+ )
+ end
+
+ it 'keeps the V2 assistant default when an account override is selected' do
+ account.enable_features!('captain_integration_v2')
+ account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
+
+ get "/api/v1/accounts/#{account.id}/captain/preferences",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response.dig(:features, :assistant)).to include(
+ default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
+ selected: 'gpt-5.1',
+ source: 'account_override'
+ )
+ end
end
end
@@ -154,6 +198,17 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
expect(account.reload.captain_models['document_faq_generation']).to eq('gpt-5.2')
end
+ it 'updates captain_models for conversation FAQ generation' do
+ put "/api/v1/accounts/#{account.id}/captain/preferences",
+ headers: admin.create_new_auth_token,
+ params: { captain_models: { conversation_faq_generation: 'gpt-4.1-mini' } },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response.dig(:features, :conversation_faq_generation, :selected)).to eq('gpt-4.1-mini')
+ expect(account.reload.captain_models['conversation_faq_generation']).to eq('gpt-4.1-mini')
+ end
+
it 'updates captain_models for PDF FAQ generation' do
put "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
index 9ab8d7316..766fd3b6b 100644
--- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
@@ -51,6 +51,22 @@ RSpec.describe 'Conversation Messages API', type: :request do
expect(json_response['error']).to eq('Validation failed: Content is too long (maximum is 150000 characters)')
end
+ it 'returns a customer-safe error when the database query is canceled' do
+ message_builder = instance_double(Messages::MessageBuilder)
+ allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
+ allow(message_builder).to receive(:perform)
+ .and_raise(ActiveRecord::QueryCanceled, 'PG::QueryCanceled: ERROR: canceling statement due to statement timeout')
+
+ post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
+ params: { content: 'test-message', private: true },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq(I18n.t('errors.database.query_canceled'))
+ expect(response.parsed_body['error']).not_to include('PG::QueryCanceled')
+ end
+
it 'creates an outgoing text message with a specific bot sender' do
agent_bot = create(:agent_bot)
time_stamp = Time.now.utc.to_s
diff --git a/spec/controllers/api/v1/accounts/conversations/participants_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/participants_controller_spec.rb
index 6238314ab..33d3f64be 100644
--- a/spec/controllers/api/v1/accounts/conversations/participants_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations/participants_controller_spec.rb
@@ -68,6 +68,23 @@ RSpec.describe 'Conversation Participants API', type: :request do
expect(response.body).to include(participant.email)
expect(conversation.conversation_participants.count).to eq(1)
end
+
+ it 'notifies unread counts when a participant is added' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ params = { user_ids: [participant.id] }
+
+ post api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
+ params: params,
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(ActiveSupport::TimeWithZone),
+ conversation: conversation
+ )
+ end
end
end
@@ -106,6 +123,25 @@ RSpec.describe 'Conversation Participants API', type: :request do
expect(response.body).to include(participant_to_be_added.email)
expect(conversation.conversation_participants.count).to eq(2)
end
+
+ it 'notifies unread counts when participant membership changes' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ params = { user_ids: [participant.id, participant_to_be_added.id] }
+ create(:conversation_participant, conversation: conversation, user: participant)
+ create(:conversation_participant, conversation: conversation, user: participant_to_be_removed)
+
+ put api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
+ params: params,
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(ActiveSupport::TimeWithZone),
+ conversation: conversation
+ )
+ end
end
end
@@ -137,6 +173,24 @@ RSpec.describe 'Conversation Participants API', type: :request do
expect(response).to have_http_status(:success)
expect(conversation.conversation_participants.count).to eq(0)
end
+
+ it 'notifies unread counts when a participant is removed' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ params = { user_ids: [participant.id] }
+ create(:conversation_participant, conversation: conversation, user: participant)
+
+ delete api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
+ params: params,
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(ActiveSupport::TimeWithZone),
+ conversation: conversation
+ )
+ end
end
end
end
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index bdb8117ac..b0eddd639 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -159,6 +159,28 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
end
+
+ it 'returns filtered unread counts when the filtered count feature is enabled' do
+ account.enable_features!(:unread_count_for_filters)
+ allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:summarize_request) do |**_attributes, &block|
+ block.call
+ end
+ mentioned = create_unread_conversation(account: account, inbox: visible_inbox)
+ create(:mention, account: account, conversation: mentioned, user: agent)
+
+ get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['payload']).to include(
+ 'mentions_count' => 1,
+ 'participating_count' => 0,
+ 'unattended_count' => 1,
+ 'folders' => {}
+ )
+ expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:summarize_request).with(account_id: account.id)
+ end
end
it 'returns forbidden when conversation unread counts feature is disabled' do
@@ -865,6 +887,59 @@ RSpec.describe 'Conversations API', type: :request do
Conversations::UnreadCounts::Store.clear_account!(account.id)
end
+ it 'refreshes unread count cache before invalidating filtered counts when conversation is marked read' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ conversation.update!(agent_last_seen_at: 1.hour.ago)
+ create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
+ notifier = instance_double(Conversations::UnreadCounts::Notifier)
+ invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
+
+ allow(Conversations::UnreadCounts::Notifier).to receive(:new).with(conversation).and_return(notifier)
+ allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
+ expect(notifier).to receive(:perform).ordered.and_return(true)
+ expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
+
+ post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'invalidates filtered unread counts when conversation is marked read' do
+ conversation.update!(agent_last_seen_at: 1.hour.ago)
+ create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
+ account.enable_features!(:unread_count_for_filters)
+
+ expect do
+ post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
+ headers: agent.create_new_auth_token,
+ as: :json
+ end.to change { Conversations::UnreadCounts::FilteredCountStore.conversation_version(account.id) }.by(1)
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'notifies clients when marking read only affects filtered counts' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ conversation.update!(agent_last_seen_at: 1.hour.ago)
+ create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
+ allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(
+ instance_double(Conversations::UnreadCounts::Refresher, perform: false)
+ )
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+
+ post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation: conversation
+ )
+ end
+
it 'updates both if one timestamp is old even when the other is recent' do
conversation.update!(assignee_id: agent.id, agent_last_seen_at: 2.hours.ago, assignee_last_seen_at: 30.minutes.ago)
# Ensure all messages are older than assignee_last_seen_at (no unread messages)
@@ -951,6 +1026,56 @@ RSpec.describe 'Conversations API', type: :request do
ensure
Conversations::UnreadCounts::Store.clear_account!(account.id)
end
+
+ it 'refreshes unread count cache before invalidating filtered counts when conversation is marked unread' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
+ notifier = instance_double(Conversations::UnreadCounts::Notifier)
+ invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
+
+ allow(Conversations::UnreadCounts::Notifier).to receive(:new).with(conversation).and_return(notifier)
+ allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
+ expect(notifier).to receive(:perform).ordered.and_return(true)
+ expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
+
+ post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'invalidates filtered unread counts when conversation is marked unread' do
+ conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
+ account.enable_features!(:unread_count_for_filters)
+
+ expect do
+ post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
+ headers: agent.create_new_auth_token,
+ as: :json
+ end.to change { Conversations::UnreadCounts::FilteredCountStore.conversation_version(account.id) }.by(1)
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'notifies clients when marking unread only affects filtered counts' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
+ allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(
+ instance_double(Conversations::UnreadCounts::Refresher, perform: false)
+ )
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+
+ post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation: conversation
+ )
+ end
end
end
diff --git a/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb
index 18a26a393..ea88756a5 100644
--- a/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
]
expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
- expect(url).not_to match(/(?:\?|&)prompt=/)
+ expect(params['prompt']).to eq(['select_account'])
# Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present
diff --git a/spec/controllers/api/v1/widget/contacts_controller_spec.rb b/spec/controllers/api/v1/widget/contacts_controller_spec.rb
index 7abbcf22e..1d1616d2e 100644
--- a/spec/controllers/api/v1/widget/contacts_controller_spec.rb
+++ b/spec/controllers/api/v1/widget/contacts_controller_spec.rb
@@ -116,6 +116,78 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
end
end
+ describe 'PATCH /api/v1/widget/contact with HMAC enforcement' do
+ let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
+ let!(:victim) { create(:contact, account: account, identifier: 'victim-identifier', name: 'Victim') }
+ let(:correct_identifier_hash) { OpenSSL::HMAC.hexdigest('sha256', web_widget.hmac_token, 'victim-identifier') }
+
+ context 'when an identifier is supplied on a mandatory-hmac inbox' do
+ it 'rejects when identifier_hash is omitted' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, identifier: 'victim-identifier', name: 'Attacker' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(victim.reload.name).to eq('Victim')
+ end
+
+ it 'rejects when identifier_hash is blank' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: '', name: 'Attacker' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(victim.reload.name).to eq('Victim')
+ end
+
+ it 'rejects when identifier_hash is null' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: nil, name: 'Attacker' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(victim.reload.name).to eq('Victim')
+ end
+
+ it 'rejects when identifier_hash is invalid' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
+ identifier_hash: 'DEFINITELY_INVALID_AAAAA_NOT_A_REAL_HMAC', name: 'Attacker' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(victim.reload.name).to eq('Victim')
+ end
+
+ it 'succeeds when a valid identifier_hash is provided' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
+ identifier_hash: correct_identifier_hash, name: 'Legit' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ context 'when no identifier is supplied (anonymous prechat update)' do
+ it 'allows updating name/email without an identifier_hash' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, email: 'prechat@test.com', name: 'Prechat User' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(victim.reload.email).to be_nil
+ expect(Contact.from_email('prechat@test.com')).to be_present
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
+
describe 'PATCH /api/v1/widget/contact/set_user' do
let(:params) { { website_token: web_widget.website_token, identifier: 'test' } }
let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
diff --git a/spec/controllers/api/v1/widget/conversations_controller_spec.rb b/spec/controllers/api/v1/widget/conversations_controller_spec.rb
index 6966c87ea..56bb01282 100644
--- a/spec/controllers/api/v1/widget/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/widget/conversations_controller_spec.rb
@@ -140,6 +140,51 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
expect(json_response['messages'][0]['content']).to eq 'This is a test message'
end
+ it 'saves contact custom attributes on the widget contact' do
+ post '/api/v1/widget/conversations',
+ headers: { 'X-Auth-Token' => token },
+ params: {
+ website_token: web_widget.website_token,
+ contact: {
+ name: 'contact-name',
+ email: 'contact-email@chatwoot.com',
+ custom_attributes: { cpf: '123.456.789-09' }
+ },
+ message: {
+ content: 'This is a test message'
+ }
+ },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(contact.reload.custom_attributes['cpf']).to eq('123.456.789-09')
+ end
+
+ it 'saves contact custom attributes on the surviving contact when merged into an existing contact' do
+ existing_contact = create(:contact, account: account, email: 'contact-email@chatwoot.com', custom_attributes: { 'cpf' => 'old-value' })
+
+ post '/api/v1/widget/conversations',
+ headers: { 'X-Auth-Token' => token },
+ params: {
+ website_token: web_widget.website_token,
+ contact: {
+ name: 'contact-name',
+ email: existing_contact.email,
+ custom_attributes: { cpf: '123.456.789-09' }
+ },
+ message: {
+ content: 'This is a test message'
+ }
+ },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ # the widget contact is merged into the existing contact; the freshly
+ # submitted value must land on the surviving contact and win over stale data
+ expect(Contact.exists?(contact.id)).to be(false)
+ expect(existing_contact.reload.custom_attributes['cpf']).to eq('123.456.789-09')
+ end
+
it 'doesnt not add phone number if the invalid phone number is provided' do
existing_contact = create(:contact, account: account)
diff --git a/spec/controllers/public/api/v1/inbox/contacts_controller_spec.rb b/spec/controllers/public/api/v1/inbox/contacts_controller_spec.rb
index 244d31c4d..5745b8e26 100644
--- a/spec/controllers/public/api/v1/inbox/contacts_controller_spec.rb
+++ b/spec/controllers/public/api/v1/inbox/contacts_controller_spec.rb
@@ -47,5 +47,17 @@ RSpec.describe 'Public Inbox Contacts API', type: :request do
data = response.parsed_body
expect(data['name']).to eq 'John Smith'
end
+
+ it 'does not expose internal contact columns' do
+ contact.update!(identifier: 'contact-identifier', custom_attributes: { tier: 'vip' }, additional_attributes: { company_name: 'Acme' })
+
+ patch "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}",
+ params: { name: 'John Smith' }
+
+ expect(response).to have_http_status(:success)
+ data = response.parsed_body
+ expect(data.keys).to include('email', 'id', 'name', 'phone_number', 'pubsub_token', 'source_id')
+ expect(data.keys).not_to include('account_id', 'identifier', 'custom_attributes', 'additional_attributes', 'company_id')
+ end
end
end
diff --git a/spec/controllers/super_admin/accounts_controller_spec.rb b/spec/controllers/super_admin/accounts_controller_spec.rb
index b2f4ff405..366e178cd 100644
--- a/spec/controllers/super_admin/accounts_controller_spec.rb
+++ b/spec/controllers/super_admin/accounts_controller_spec.rb
@@ -65,6 +65,21 @@ RSpec.describe 'Super Admin accounts API', type: :request do
expect(editor_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
end
+
+ it 'shows the Captain V2 assistant default in the model selector', if: ChatwootApp.enterprise? do
+ account.enable_features!('captain_integration_v2')
+ sign_in(super_admin, scope: :super_admin)
+
+ get "/super_admin/accounts/#{account.id}/edit"
+
+ document = Nokogiri::HTML(response.body)
+ assistant_select = document.at_css('select[name="account[captain_models][assistant]"]')
+ default_model_id = Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL
+ default_model = Llm::Models.model_config(default_model_id)['display_name']
+
+ expect(response).to have_http_status(:success)
+ expect(assistant_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
+ end
end
end
@@ -97,6 +112,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
it 'rejects invalid Captain model overrides' do
sign_in(super_admin, scope: :super_admin)
+ existing_captain_models = account.captain_models
patch "/super_admin/accounts/#{account.id}",
params: {
@@ -112,7 +128,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('not a valid model for label_suggestion')
- expect(account.reload.captain_models).to be_nil
+ expect(account.reload.captain_models).to eq(existing_captain_models)
end
end
end
diff --git a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb
new file mode 100644
index 000000000..6575c9856
--- /dev/null
+++ b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb
@@ -0,0 +1,271 @@
+require 'rails_helper'
+
+RSpec.describe Captain::AssistantStatsBuilder do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+
+ before { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
+
+ describe '#metrics' do
+ # Two conversations handled in the current 30-day window, one in the previous.
+ let(:current_convo_a) { create(:conversation, account: account, inbox: inbox) }
+ let(:current_convo_b) { create(:conversation, account: account, inbox: inbox) }
+ let(:previous_convo) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ [current_convo_a, current_convo_b].each do |conversation|
+ create(:message, account: account, inbox: inbox, conversation: conversation,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 5.days.ago)
+ end
+ create(:message, account: account, inbox: inbox, conversation: previous_convo,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 45.days.ago)
+ end
+
+ it 'returns every metric for the current and previous window' do
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics.keys).to contain_exactly(
+ :conversations_handled, :auto_resolution_rate, :handoff_rate,
+ :hours_saved, :reopen_rate, :conversation_depth, :knowledge
+ )
+ expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
+ end
+
+ it 'counts distinct handled conversations per window and the percent trend' do
+ handled = described_class.new(assistant, '30').metrics[:conversations_handled]
+
+ expect(handled[:current]).to eq(2)
+ expect(handled[:previous]).to eq(1)
+ expect(handled[:trend]).to eq(100.0)
+ end
+
+ it 'derives auto-resolution and handoff rates from reporting events on the handled set' do
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_resolved')
+ create(:reporting_event, account: account, conversation: current_convo_b,
+ name: 'conversation_captain_inference_handoff')
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
+ expect(metrics[:handoff_rate][:current]).to eq(50.0)
+ end
+
+ it 'does not count a bot resolve as an auto-resolution when the conversation was handed off' do
+ # convo_a: handoff, customer goes quiet, resolve lands without an agent message, so the
+ # listener still emits conversation_bot_resolved for the handed-off conversation. It must
+ # not count as an auto-resolution, but still counts as a handoff.
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_bot_handoff')
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_bot_resolved')
+ # convo_b: a clean bot resolve with no handoff still counts, so the exclusion is scoped
+ # to handed-off conversations and doesn't drop every bot resolve.
+ create(:reporting_event, account: account, conversation: current_convo_b,
+ name: 'conversation_bot_resolved')
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
+ expect(metrics[:handoff_rate][:current]).to eq(50.0)
+ end
+
+ it 'still counts an inference resolve when the conversation was also handed off' do
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_handoff')
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_resolved')
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
+ expect(metrics[:handoff_rate][:current]).to eq(50.0)
+ end
+
+ it 'excludes resolution events that fall outside the current window' do
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_resolved', created_at: 60.days.ago)
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(0.0)
+ end
+
+ it 'computes conversation depth as public replies per handled conversation' do
+ depth = described_class.new(assistant, '30').metrics[:conversation_depth]
+
+ # 2 public outgoing replies across 2 distinct conversations in the current window.
+ expect(depth[:current]).to eq(1.0)
+ end
+
+ it 'ignores private notes and incoming messages when counting public replies' do
+ create(:message, account: account, inbox: inbox, conversation: current_convo_a,
+ sender: assistant, message_type: :outgoing, private: true, created_at: 5.days.ago)
+
+ depth = described_class.new(assistant, '30').metrics[:conversation_depth]
+
+ expect(depth[:current]).to eq(1.0)
+ end
+ end
+
+ describe 'range handling' do
+ it 'accepts the allowed day and named ranges' do
+ %w[7 30 90 this_month last_month].each do |allowed|
+ expect(described_class.new(assistant, allowed).range).to eq(allowed)
+ end
+ end
+
+ it 'falls back to the default range for values outside the allowed set' do
+ expect(described_class.new(assistant, '365000').range).to eq('30')
+ expect(described_class.new(assistant, 'bogus').range).to eq('30')
+ expect(described_class.new(assistant, nil).range).to eq('30')
+ end
+ end
+
+ describe '#metrics reopen_rate' do
+ # A conversation the assistant handled (messaged) inside the current 30-day window.
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ create(:message, account: account, inbox: inbox, conversation: conversation,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
+ end
+
+ it 'counts a reopen that happened after the captain resolve' do
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
+ end
+
+ it 'ignores a human resolve/reopen that happened before the captain resolve' do
+ # Earlier resolve/reopen cycle, then Captain resolves later in the same window.
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 20.days.ago, event_end_time: 18.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_bot_resolved', event_start_time: 5.days.ago, event_end_time: 5.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(0.0)
+ end
+
+ it 'counts an evaluated-path reopen when bot_resolved is skipped and the inference event is newer' do
+ # Prior human reply => create_bot_resolved_event skips conversation_bot_resolved, so the cohort
+ # only holds the inference event, which is dispatched a moment after the generic conversation_resolved
+ # that seeds the reopen's event_start_time. The match must use the reopen's actual reopen time.
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_captain_inference_resolved',
+ event_start_time: 6.days.ago, event_end_time: 6.days.ago + 1.second)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 3.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
+ end
+
+ it 'counts both inference and time-based bot resolves in the denominator' do
+ # conversation: inference-resolved and reopened
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
+ # other: time-based bot-resolved, never reopened
+ other = create(:conversation, account: account, inbox: inbox)
+ create(:message, account: account, inbox: inbox, conversation: other,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: other,
+ name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(50.0)
+ end
+
+ it 'ignores a reopen that landed after a completed window ended' do
+ travel_to(Time.utc(2026, 7, 15)) do
+ convo = create(:conversation, account: account, inbox: inbox)
+ create(:message, account: account, inbox: inbox, conversation: convo,
+ sender: assistant, message_type: :outgoing, private: false, created_at: Time.utc(2026, 6, 10))
+ create(:reporting_event, account: account, inbox: inbox, conversation: convo,
+ name: 'conversation_bot_resolved', created_at: Time.utc(2026, 6, 12),
+ event_start_time: Time.utc(2026, 6, 12), event_end_time: Time.utc(2026, 6, 12))
+ # Reopened on July 1, after the June window closed; June's rate must not count it.
+ create(:reporting_event, account: account, inbox: inbox, conversation: convo,
+ name: 'conversation_opened', value: 120,
+ event_start_time: Time.utc(2026, 6, 12), event_end_time: Time.utc(2026, 7, 1))
+
+ expect(described_class.new(assistant, 'last_month').metrics[:reopen_rate][:current]).to eq(0.0)
+ end
+ end
+
+ it 'derives the cohort from handled conversations, not current inbox membership' do
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
+ # The assistant is later removed from the inbox; the cohort must still resolve via handled messages.
+ CaptainInbox.where(captain_assistant: assistant).delete_all
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
+ end
+ end
+
+ describe 'timezone anchoring' do
+ # 2026-07-01 03:00 UTC is still 2026-06-30 in any timezone behind UTC by 4h+.
+ it 'anchors the this_month window to the supplied offset, not UTC' do
+ travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
+ utc = described_class.new(assistant, 'this_month').period
+ la = described_class.new(assistant, 'this_month', -7).period
+
+ expect(utc[:starts_on]).to eq(Date.new(2026, 7, 1))
+ expect(la[:starts_on]).to eq(Date.new(2026, 6, 1))
+ expect(la[:ends_on]).to eq(Date.new(2026, 6, 30))
+ end
+ end
+
+ it 'defaults to UTC when no offset is given' do
+ travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
+ expect(described_class.new(assistant, 'this_month').period[:starts_on]).to eq(Date.new(2026, 7, 1))
+ end
+ end
+ end
+
+ describe '#metrics knowledge' do
+ before do
+ create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
+ create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
+ create_list(:captain_document, 2, assistant: assistant, account: account)
+ end
+
+ it 'returns approved, pending, document counts and coverage' do
+ knowledge = described_class.new(assistant, '30').metrics[:knowledge]
+
+ expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
+ end
+
+ it 'reports zero coverage when there are no responses' do
+ Captain::AssistantResponse.where(assistant: assistant).delete_all
+
+ knowledge = described_class.new(assistant, '30').metrics[:knowledge]
+
+ expect(knowledge[:coverage]).to eq(0)
+ end
+ end
+
+ describe '#period' do
+ it 'labels a day range and exposes its bounds' do
+ period = described_class.new(assistant, '30').period
+
+ expect(period[:label]).to eq('the last 30 days')
+ expect(period[:starts_on]).to eq(30.days.ago.to_date)
+ expect(period[:ends_on]).to eq(Time.zone.today)
+ end
+
+ it 'labels the this_month range' do
+ expect(described_class.new(assistant, 'this_month').period[:label]).to eq('this month')
+ end
+
+ it 'labels the last_month range' do
+ expect(described_class.new(assistant, 'last_month').period[:label]).to eq('last month')
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb
new file mode 100644
index 000000000..33cc95fc1
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Callbacks API', type: :request do
+ describe 'POST /api/v1/accounts/{account.id}/callbacks/register_facebook_page' do
+ let(:account) { create(:account, limits: { inboxes: 1 }) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:params) do
+ {
+ user_access_token: 'user-token',
+ page_access_token: 'page-token',
+ page_id: '12345',
+ inbox_name: 'Facebook Inbox'
+ }
+ end
+
+ before do
+ create(:inbox, account: account)
+ end
+
+ it 'returns payment required before creating a Facebook channel when account inbox limit is reached' do
+ expect do
+ post "/api/v1/accounts/#{account.id}/callbacks/register_facebook_page",
+ headers: admin.create_new_auth_token,
+ params: params,
+ as: :json
+ end.not_to change(Channel::FacebookPage, :count)
+
+ expect(response).to have_http_status(:payment_required)
+ expect(response.parsed_body['error']).to eq('Account limit exceeded. Upgrade to a higher plan')
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb
new file mode 100644
index 000000000..86e4fb317
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb
@@ -0,0 +1,46 @@
+require 'rails_helper'
+
+RSpec.describe 'Calls API', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, :with_phone_number, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+ let!(:agent_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact,
+ accepted_by_agent: agent, status: 'completed', transcript: 'hello world')
+ end
+ let!(:other_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact, accepted_by_agent: admin)
+ end
+
+ before { create(:inbox_member, user: agent, inbox: inbox) }
+
+ describe 'GET /api/v1/accounts/:account_id/calls' do
+ it 'returns 401 when unauthenticated' do
+ get "/api/v1/accounts/#{account.id}/calls"
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'returns the whole account with sensitive fields for an administrator' do
+ get "/api/v1/accounts/#{account.id}/calls", headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ body = response.parsed_body
+ expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id, other_call.id)
+ item = body['payload'].find { |c| c['id'] == agent_call.id }
+ expect(item['transcript']).to eq('hello world')
+ expect(item['contact']['phone_number']).to eq(contact.phone_number)
+ end
+
+ it 'scopes the list to calls the agent accepted' do
+ get "/api/v1/accounts/#{account.id}/calls", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ body = response.parsed_body
+ expect(body['meta']['count']).to eq(1)
+ expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id)
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
index caa815642..e5187d4a6 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
@@ -275,6 +275,48 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end
end
+ describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/summary' do
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:alice) { create(:user, account: account, role: :administrator, name: 'Alice Adams') }
+ let(:bob) { create(:user, account: account, role: :administrator, name: 'Bob Brown') }
+ let(:summary_service) { instance_double(Captain::OverviewSummaryService) }
+
+ def get_summary(user)
+ get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/summary",
+ params: { range: '30' },
+ headers: user.create_new_auth_token,
+ as: :json
+ end
+
+ before do
+ # Test env uses a null store; swap in a real store so caching behaviour is observable.
+ allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
+ allow(Captain::OverviewSummaryService).to receive(:new).and_return(summary_service)
+ end
+
+ it 'caches the summary per viewer so one user never receives another user\'s greeting' do
+ allow(summary_service).to receive(:perform).and_return({ message: 'Hi Alice' })
+
+ get_summary(alice)
+ get_summary(alice) # served from Alice's cache, no regeneration
+ get_summary(bob) # distinct cache key, regenerated for Bob
+
+ expect(response).to have_http_status(:success)
+ expect(Captain::OverviewSummaryService).to have_received(:new).twice
+ end
+
+ it 'does not cache failures so a transient error is retried' do
+ allow(summary_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
+
+ get_summary(alice)
+ get_summary(alice)
+
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(json_response[:error]).to eq('LLM unavailable')
+ expect(Captain::OverviewSummaryService).to have_received(:new).twice
+ end
+ end
+
describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) do
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
index 77cb25f49..4d4b10fcb 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
@@ -51,6 +51,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(json_response[:payload].length).to eq(5)
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
end
+
+ it 'returns the generated FAQ count for each document' do
+ document = create(:captain_document, assistant: assistant, account: account)
+ create_list(:captain_assistant_response, 2,
+ assistant: assistant, account: account, documentable: document)
+
+ get "/api/v1/accounts/#{account.id}/captain/documents",
+ headers: agent.create_new_auth_token, as: :json
+
+ matching_document = json_response[:payload].find { |item| item[:id] == document.id }
+ expect(matching_document[:responses_count]).to eq(2)
+ end
end
context 'when filtering by assistant_id' do
@@ -142,6 +154,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(json_response[:external_link]).to eq(document.external_link)
end
+ it 'returns the crawled content for the document' do
+ expect(json_response[:content]).to eq(document.content)
+ end
+
it 'returns sync metadata when the document has been synced' do
synced_at = 1.hour.ago
document.update!(sync_status: :synced, last_synced_at: synced_at)
diff --git a/spec/enterprise/finders/call_finder_spec.rb b/spec/enterprise/finders/call_finder_spec.rb
new file mode 100644
index 000000000..4f4607370
--- /dev/null
+++ b/spec/enterprise/finders/call_finder_spec.rb
@@ -0,0 +1,108 @@
+require 'rails_helper'
+
+describe CallFinder do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before { create(:inbox_member, user: agent, inbox: inbox) }
+
+ def perform(user, params = {})
+ Current.account = account
+ Current.account_user = account.account_users.find_by(user_id: user.id)
+ described_class.new(user, account, params).perform
+ end
+
+ describe 'visibility' do
+ let!(:agent_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: agent)
+ end
+ let!(:other_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: admin)
+ end
+
+ it 'lets an administrator see every call in the account' do
+ result = perform(admin)
+ expect(result[:count]).to eq(2)
+ expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id)
+ end
+
+ it 'lets an agent with report_manage see every call in the account' do
+ report_manager = create(:user, account: account, role: :agent)
+ custom_role = create(:custom_role, account: account, permissions: ['report_manage'])
+ account.account_users.find_by(user_id: report_manager.id).update!(custom_role: custom_role)
+
+ result = perform(report_manager)
+ expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id)
+ end
+
+ it 'limits a regular agent to calls they accepted in accessible conversations' do
+ result = perform(agent)
+ expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id)
+ end
+
+ it 'limits a custom-role agent without report_manage to their own accepted calls' do
+ scoped_agent = create(:user, account: account, role: :agent)
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_manage'])
+ account.account_users.find_by(user_id: scoped_agent.id).update!(custom_role: custom_role)
+ create(:inbox_member, user: scoped_agent, inbox: inbox)
+ scoped_call = create(:call, account: account, inbox: inbox, conversation: conversation,
+ contact: conversation.contact, accepted_by_agent: scoped_agent)
+
+ result = perform(scoped_agent)
+ expect(result[:calls].map(&:id)).to contain_exactly(scoped_call.id)
+ end
+ end
+
+ describe 'filters' do
+ let(:inbox2) { create(:inbox, account: account) }
+ let(:conversation2) { create(:conversation, account: account, inbox: inbox2) }
+ let(:agent2) { create(:user, account: account, role: :agent) }
+ let!(:ringing) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
+ status: 'ringing', direction: :incoming, accepted_by_agent: agent)
+ end
+ let!(:in_progress) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
+ status: 'in_progress', direction: :incoming, accepted_by_agent: agent)
+ end
+ let!(:completed) do
+ create(:call, account: account, inbox: inbox2, conversation: conversation2, contact: conversation2.contact,
+ status: 'completed', direction: :outgoing, accepted_by_agent: agent2, created_at: 10.days.ago)
+ end
+
+ it 'filters by status using the display value' do
+ expect(perform(admin, status: 'in-progress')[:calls].map(&:id)).to contain_exactly(in_progress.id)
+ end
+
+ it 'filters by direction using the display label' do
+ expect(perform(admin, direction: 'outbound')[:calls].map(&:id)).to contain_exactly(completed.id)
+ end
+
+ it 'filters by inbox' do
+ expect(perform(admin, inbox_id: inbox2.id)[:calls].map(&:id)).to contain_exactly(completed.id)
+ end
+
+ it 'filters by agent' do
+ expect(perform(admin, agent_id: agent2.id)[:calls].map(&:id)).to contain_exactly(completed.id)
+ end
+
+ it 'filters by created_at date range' do
+ params = { since: 2.days.ago.to_i.to_s, until: 1.hour.from_now.to_i.to_s }
+ expect(perform(admin, params)[:calls].map(&:id)).to contain_exactly(ringing.id, in_progress.id)
+ end
+ end
+
+ describe 'account scoping' do
+ it 'never returns calls from another account' do
+ other_account = create(:account)
+ other_conversation = create(:conversation, account: other_account)
+ create(:call, account: other_account, inbox: other_conversation.inbox, conversation: other_conversation,
+ contact: other_conversation.contact)
+
+ expect(perform(admin)[:count]).to eq(0)
+ end
+ end
+end
diff --git a/spec/enterprise/finders/conversation_finder_spec.rb b/spec/enterprise/finders/conversation_finder_spec.rb
new file mode 100644
index 000000000..f415ac473
--- /dev/null
+++ b/spec/enterprise/finders/conversation_finder_spec.rb
@@ -0,0 +1,41 @@
+require 'rails_helper'
+
+RSpec.describe ConversationFinder do
+ describe '#perform_meta_only' do
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:other_agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+
+ before do
+ Current.account = account
+ create(:inbox_member, user: agent, inbox: inbox)
+ account.account_users.find_by(user: agent).update!(
+ role: :agent,
+ custom_role: create(:custom_role, account: account, permissions: %w[conversation_participating_manage])
+ )
+ end
+
+ it 'counts participant-filtered conversations once when assigned conversations have multiple participants' do
+ assigned_conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
+ participating_conversation = create(:conversation, account: account, inbox: inbox, assignee: other_agent)
+ create(:conversation, account: account, inbox: inbox, assignee: other_agent)
+
+ 2.times do
+ participant = create(:user, account: account, role: :agent)
+ create(:inbox_member, user: participant, inbox: inbox)
+ create(:conversation_participant, account: account, conversation: assigned_conversation, user: participant)
+ end
+ create(:conversation_participant, account: account, conversation: participating_conversation, user: agent)
+
+ result = described_class.new(agent, { status: 'open' }).perform_meta_only
+
+ expect(result[:count]).to eq({
+ mine_count: 1,
+ assigned_count: 2,
+ unassigned_count: 0,
+ all_count: 2
+ })
+ end
+ end
+end
diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb
index fb970f726..9186874b7 100644
--- a/spec/enterprise/lib/captain/base_task_service_spec.rb
+++ b/spec/enterprise/lib/captain/base_task_service_spec.rb
@@ -5,6 +5,13 @@ RSpec.describe Captain::BaseTaskService, type: :model do
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:perform_result) { { message: 'Test response' } }
+ let(:exhausted_usage_limits) do
+ {
+ agents: ChatwootApp.max_limit,
+ inboxes: ChatwootApp.max_limit,
+ captain: { responses: { current_available: 0 } }
+ }
+ end
# Create a concrete test service class with enterprise module prepended
let(:test_service_class) do
@@ -38,9 +45,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when usage limit is exceeded' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'returns usage limit exceeded error' do
@@ -125,9 +130,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'returns usage limit exceeded error for services that do not opt into BYOK' do
@@ -162,9 +165,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'bypasses the 429 gate and returns the underlying result' do
@@ -249,9 +250,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'bypasses the 429 gate and returns the underlying result' do
diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
index 8b059acc3..9cdfc822c 100644
--- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
+++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
@@ -68,6 +68,110 @@ RSpec.describe Captain::ConversationCompletionService do
end
end
+ context 'when building evaluation context' do
+ let(:captain_assistant) { create(:captain_assistant, account: account) }
+ let(:mock_response) do
+ instance_double(
+ RubyLLM::Message,
+ content: { 'complete' => false, 'reason' => 'Human follow-up is still pending' },
+ input_tokens: 100,
+ output_tokens: 20
+ )
+ end
+
+ it 'includes conversation status and speaker labels' do
+ conversation.update!(status: :pending, waiting_since: 2.hours.ago)
+ create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'I need help with a refund')
+ create(
+ :message,
+ conversation: conversation,
+ inbox: inbox,
+ account: account,
+ message_type: :outgoing,
+ sender: captain_assistant,
+ content: 'I will transfer this to support for review.'
+ )
+
+ expect(mock_chat).to receive(:ask) do |content|
+ expect(content).to include(
+ 'Conversation status: pending',
+ 'Conversation transcript:',
+ 'Customer: I need help with a refund',
+ 'Captain: I will transfer this to support for review.'
+ )
+
+ mock_response
+ end
+
+ result = service.perform
+
+ expect(result[:complete]).to be false
+ end
+
+ it 'includes pending captain handoff evidence in the transcript' do
+ conversation.update!(status: :pending)
+ create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'Please cancel my order')
+ create(
+ :message,
+ conversation: conversation,
+ inbox: inbox,
+ account: account,
+ message_type: :outgoing,
+ sender: captain_assistant,
+ content: 'I will transfer this to a specialist and they will follow up here.'
+ )
+
+ expect(mock_chat).to receive(:ask) do |content|
+ expect(content).to include(
+ 'Conversation status: pending',
+ 'Captain: I will transfer this to a specialist and they will follow up here.'
+ )
+
+ mock_response
+ end
+
+ result = service.perform
+
+ expect(result[:complete]).to be false
+ end
+
+ it 'reuses computed message content while formatting the transcript' do
+ content_for_llm_calls_by_message_id = Hash.new(0)
+ allow_any_instance_of(Message).to receive(:content_for_llm).and_wrap_original do |method, *args| # rubocop:disable RSpec/AnyInstance
+ content_for_llm_calls_by_message_id[method.receiver.id] += 1
+ method.call(*args)
+ end
+
+ incoming_message = create(
+ :message,
+ :with_attachment,
+ conversation: conversation,
+ inbox: inbox,
+ account: account,
+ message_type: :incoming,
+ content: nil
+ )
+ outgoing_message = create(
+ :message,
+ conversation: conversation,
+ inbox: inbox,
+ account: account,
+ message_type: :outgoing,
+ sender: captain_assistant,
+ content: 'What do you need help with?'
+ )
+
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
+
+ service.perform
+
+ expect(content_for_llm_calls_by_message_id).to include(
+ incoming_message.id => 1,
+ outgoing_message.id => 1
+ )
+ end
+ end
+
context 'when conversation has no messages' do
it 'returns incomplete with appropriate reason' do
result = service.perform
@@ -166,9 +270,13 @@ RSpec.describe Captain::ConversationCompletionService do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(
+ {
+ agents: ChatwootApp.max_limit,
+ inboxes: ChatwootApp.max_limit,
+ captain: { responses: { current_available: 0 } }
+ }
+ )
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
allow(mock_chat).to receive(:ask).and_return(mock_response)
diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb
index c69a83256..7c57e217f 100644
--- a/spec/enterprise/models/account_spec.rb
+++ b/spec/enterprise/models/account_spec.rb
@@ -11,6 +11,27 @@ RSpec.describe Account, type: :model do
it { is_expected.to have_many(:custom_roles).dependent(:destroy_async) }
end
+ describe '#selected_feature_flags=' do
+ it 'keeps advanced assignment enabled when assignment v2 is selected for a business account' do
+ account = build(:account, custom_attributes: { 'plan_name' => 'Business' })
+
+ account.selected_feature_flags = [:feature_assignment_v2]
+
+ expect(account).to be_feature_assignment_v2
+ expect(account).to be_feature_advanced_assignment
+ end
+
+ it 'disables advanced assignment when assignment v2 is not selected' do
+ account = build(:account, custom_attributes: { 'plan_name' => 'Business' })
+ account.enable_features(:assignment_v2, :advanced_assignment)
+
+ account.selected_feature_flags = []
+
+ expect(account).not_to be_feature_assignment_v2
+ expect(account).not_to be_feature_advanced_assignment
+ end
+ end
+
describe 'sla_policies' do
let!(:account) { create(:account) }
let!(:sla_policy) { create(:sla_policy, account: account) }
@@ -222,6 +243,37 @@ RSpec.describe Account, type: :model do
end
end
+ describe 'default features' do
+ before do
+ InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
+ value: Featurable::FEATURE_LIST,
+ locked: true
+ )
+ end
+
+ it 'enables Captain V2 for new self-hosted enterprise accounts' do
+ allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
+
+ account = create(:account)
+
+ expect(account).to be_feature_enabled('captain_integration')
+ expect(account).to be_feature_enabled('captain_integration_v2')
+ expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
+ expect(account.captain_models).to be_nil
+ end
+
+ it 'marks new cloud accounts as eligible for the Captain V2 paid-plan default' do
+ allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(false)
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+
+ account = create(:account)
+
+ expect(account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE]).to be true
+ expect(account).not_to be_feature_enabled('captain_integration')
+ expect(account).not_to be_feature_enabled('captain_integration_v2')
+ end
+ end
+
describe 'captain document sync cadence' do
let(:account) { create(:account) }
diff --git a/spec/enterprise/models/account_user_spec.rb b/spec/enterprise/models/account_user_spec.rb
index fb572e86a..c79cfb4b6 100644
--- a/spec/enterprise/models/account_user_spec.rb
+++ b/spec/enterprise/models/account_user_spec.rb
@@ -29,6 +29,29 @@ RSpec.describe AccountUser, type: :model do
end
end
+ describe 'filtered unread count invalidation' do
+ it 'invalidates filtered counts when the custom role assignment changes' do
+ account = create(:account)
+ user = create(:user)
+ account_user = create(:account_user, account: account, user: user)
+ custom_role = create(:custom_role, account: account)
+ invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator, user_visibility_changed!: true)
+
+ allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).and_return(invalidator)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+
+ account_user.update!(custom_role_id: custom_role.id)
+
+ expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id)
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'account.cache_invalidated',
+ kind_of(Time),
+ account: account,
+ cache_keys: account.cache_keys
+ )
+ end
+ end
+
describe 'audit log' do
context 'when account user is created' do
it 'has associated audit log created' do
diff --git a/spec/enterprise/models/captain/agent_session_spec.rb b/spec/enterprise/models/captain/agent_session_spec.rb
new file mode 100644
index 000000000..b4306a11e
--- /dev/null
+++ b/spec/enterprise/models/captain/agent_session_spec.rb
@@ -0,0 +1,160 @@
+require 'rails_helper'
+
+RSpec.describe Captain::AgentSession, type: :model do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:account) }
+ it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
+ it { is_expected.to belong_to(:user).optional }
+ it { is_expected.to belong_to(:subject) }
+ it { is_expected.to belong_to(:result).optional }
+ end
+
+ describe 'enums' do
+ it { is_expected.to define_enum_for(:session_type).with_values(assistant: 0, copilot: 1).with_prefix(:session) }
+ end
+
+ describe '#subject' do
+ it 'returns the conversation for an assistant session' do
+ conversation = create(:conversation, account: account)
+ session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
+
+ expect(session.subject).to eq(conversation)
+ end
+
+ it 'returns the copilot thread for a copilot session' do
+ user = create(:user, account: account)
+ copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
+ session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, subject: copilot_thread)
+
+ expect(session.subject).to eq(copilot_thread)
+ end
+
+ it 'returns nil when the subject record no longer exists' do
+ conversation = create(:conversation, account: account)
+ session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
+ conversation.destroy
+
+ expect(session.reload.subject).to be_nil
+ end
+
+ it 'is not valid when the subject type does not match the session type' do
+ copilot_thread = create(:captain_copilot_thread, account: account, user: create(:user, account: account), assistant: assistant)
+ session = build(:captain_agent_session, account: account, assistant: assistant, subject: copilot_thread)
+
+ expect(session).not_to be_valid
+ expect(session.errors[:subject_type]).to be_present
+ end
+
+ it 'is not valid when the subject belongs to a different account' do
+ foreign_conversation = create(:conversation, account: create(:account))
+ session = build(:captain_agent_session, account: account, assistant: assistant, subject: foreign_conversation)
+
+ expect(session).not_to be_valid
+ expect(session.errors[:subject]).to be_present
+ end
+ end
+
+ describe '#result' do
+ it 'returns the message for an assistant session' do
+ conversation = create(:conversation, account: account)
+ message = create(:message, account: account, conversation: conversation)
+ session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: message)
+
+ expect(session.result).to eq(message)
+ end
+
+ it 'returns the copilot message for a copilot session' do
+ user = create(:user, account: account)
+ copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
+ copilot_message = create(:captain_copilot_message, account: account, copilot_thread: copilot_thread)
+ session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user,
+ subject: copilot_thread, result: copilot_message)
+
+ expect(session.result).to eq(copilot_message)
+ end
+
+ it 'returns nil when result_id is nil' do
+ session = create(:captain_agent_session, account: account, assistant: assistant)
+
+ expect(session.result).to be_nil
+ end
+
+ it 'is not valid when the result belongs to a different account' do
+ conversation = create(:conversation, account: account)
+ foreign_message = create(:message, account: create(:account))
+ session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: foreign_message)
+
+ expect(session).not_to be_valid
+ expect(session.errors[:result]).to be_present
+ end
+
+ it 'is not valid when result_id/result_type are set directly for a different account' do
+ conversation = create(:conversation, account: account)
+ foreign_message = create(:message, account: create(:account))
+ session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
+ result_id: foreign_message.id, result_type: 'Message')
+
+ expect(session).not_to be_valid
+ expect(session.errors[:result]).to be_present
+ end
+
+ it 'is not valid when result_id/result_type are set directly for a stale id' do
+ conversation = create(:conversation, account: account)
+ session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
+ result_id: 0, result_type: 'Message')
+
+ expect(session).not_to be_valid
+ expect(session.errors[:result]).to be_present
+ end
+ end
+
+ describe 'account' do
+ it 'is derived from the assistant when created via the assistant association' do
+ conversation = create(:conversation, account: account)
+ session = assistant.agent_sessions.create!(subject: conversation, session_type: :assistant)
+
+ expect(session.account).to eq(account)
+ end
+
+ it 'overrides a mismatched explicit account with the assistant account' do
+ conversation = create(:conversation, account: account)
+ session = build(:captain_agent_session, account: create(:account), assistant: assistant, subject: conversation)
+
+ expect(session).to be_valid
+ expect(session.account).to eq(account)
+ end
+ end
+
+ describe 'defaults' do
+ it 'defaults faq_ids, document_ids, scenario_ids and run_context' do
+ session = create(:captain_agent_session, account: account, assistant: assistant)
+
+ expect(session.faq_ids).to eq([])
+ expect(session.document_ids).to eq([])
+ expect(session.scenario_ids).to eq([])
+ expect(session.run_context).to eq({})
+ end
+ end
+
+ describe 'factory' do
+ it 'builds a valid assistant session' do
+ session = create(:captain_agent_session, account: account, assistant: assistant)
+
+ expect(session).to be_valid
+ expect(session).to be_session_assistant
+ expect(session.subject).to be_a(Conversation)
+ end
+
+ it 'builds a valid copilot session' do
+ session = create(:captain_agent_session, :copilot, account: account, assistant: assistant)
+
+ expect(session).to be_valid
+ expect(session).to be_session_copilot
+ expect(session.subject).to be_a(CopilotThread)
+ expect(session.user).to be_present
+ end
+ end
+end
diff --git a/spec/enterprise/models/concerns/agentable_spec.rb b/spec/enterprise/models/concerns/agentable_spec.rb
index d179393c9..f2145ff85 100644
--- a/spec/enterprise/models/concerns/agentable_spec.rb
+++ b/spec/enterprise/models/concerns/agentable_spec.rb
@@ -179,6 +179,14 @@ RSpec.describe Concerns::Agentable do
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-nano')
end
+ it 'returns the Captain V2 default when Captain V2 is enabled' do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
+ account.enable_features!('captain_integration_v2')
+
+ expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
+ expect(account.reload.captain_models).to be_nil
+ end
+
it 'returns the assistant feature default model when account is nil' do
agent = dummy_class.new(account: nil)
diff --git a/spec/enterprise/models/custom_role_spec.rb b/spec/enterprise/models/custom_role_spec.rb
index f63f3c2dd..5ee3353b3 100644
--- a/spec/enterprise/models/custom_role_spec.rb
+++ b/spec/enterprise/models/custom_role_spec.rb
@@ -9,4 +9,49 @@ RSpec.describe CustomRole, type: :model do
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
end
+
+ describe 'filtered unread count invalidation' do
+ let(:account) { create(:account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_manage']) }
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user) }
+ let(:invalidator) { instance_double(Conversations::UnreadCounts::FilteredCountInvalidator, users_visibility_changed!: true) }
+
+ before do
+ create(:account_user, account: account, user: user, custom_role: custom_role)
+ create(:account_user, account: account, user: other_user, custom_role: custom_role)
+ allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ end
+
+ it 'invalidates filtered counts for assigned users when permissions change' do
+ custom_role.update!(permissions: ['conversation_participating_manage'])
+
+ expect(invalidator).to have_received(:users_visibility_changed!).with(user_ids: contain_exactly(user.id, other_user.id))
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'account.cache_invalidated',
+ kind_of(Time),
+ account: account,
+ cache_keys: account.cache_keys
+ )
+ end
+
+ it 'does not invalidate filtered counts when permissions are unchanged' do
+ custom_role.update!(name: 'Support manager')
+
+ expect(invalidator).not_to have_received(:users_visibility_changed!)
+ end
+
+ it 'invalidates filtered counts for assigned users when the role is deleted' do
+ custom_role.destroy!
+
+ expect(invalidator).to have_received(:users_visibility_changed!).with(user_ids: contain_exactly(user.id, other_user.id))
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'account.cache_invalidated',
+ kind_of(Time),
+ account: account,
+ cache_keys: account.cache_keys
+ )
+ end
+ end
end
diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb
index cfcbdd573..1dce2833b 100644
--- a/spec/enterprise/models/inbox_spec.rb
+++ b/spec/enterprise/models/inbox_spec.rb
@@ -134,6 +134,29 @@ RSpec.describe Inbox do
end
end
+ describe 'validations' do
+ describe 'account inbox limit' do
+ let(:account) { create(:account, limits: { inboxes: 1 }) }
+
+ before do
+ create(:inbox, account: account)
+ end
+
+ it 'prevents saving inboxes beyond the account limit' do
+ new_inbox = build(:inbox, account: account)
+
+ expect { new_inbox.save! }.to raise_error(CustomExceptions::Inbox::LimitExceeded, 'Account limit exceeded. Upgrade to a higher plan')
+ end
+
+ it 'does not block updates to existing inboxes when the account is at the limit' do
+ inbox = account.inboxes.first
+ inbox.name = 'Updated Inbox'
+
+ expect(inbox).to be_valid
+ end
+ end
+ end
+
describe 'audit log' do
context 'when inbox is created' do
it 'has associated audit log created' do
diff --git a/spec/enterprise/policies/captain/assistant_policy_spec.rb b/spec/enterprise/policies/captain/assistant_policy_spec.rb
index e3c62846f..e04b680e4 100644
--- a/spec/enterprise/policies/captain/assistant_policy_spec.rb
+++ b/spec/enterprise/policies/captain/assistant_policy_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
end
end
- permissions :tools?, :create?, :update?, :destroy?, :sync? do
+ permissions :tools?, :create?, :update?, :destroy?, :sync?, :drilldown? do
context 'when administrator' do
it { expect(assistant_policy).to permit(administrator_context, assistant) }
end
diff --git a/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb b/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb
new file mode 100644
index 000000000..0e2f420ac
--- /dev/null
+++ b/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb
@@ -0,0 +1,116 @@
+require 'rails_helper'
+
+RSpec.describe Captain::AssistantMigration::DraftApplier do
+ let(:account) { create(:account) }
+ let(:assistant) do
+ create(
+ :captain_assistant,
+ account: account,
+ config: { 'product_name' => 'Test Product', 'instructions' => 'Legacy V1 custom instructions.' },
+ response_guidelines: [],
+ guardrails: []
+ )
+ end
+ let(:scenario_candidate) do
+ {
+ 'title' => 'Billing Investigation',
+ 'description' => 'Use when a customer reports an account-specific billing issue.',
+ 'instruction' => 'Collect the invoice number and summarize the issue before escalating.',
+ 'response_guideline' => 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.',
+ 'tool_ids' => []
+ }
+ end
+ let(:faq_document_candidate) do
+ {
+ 'question' => 'When is support available?',
+ 'answer' => 'Support is available Monday to Friday.'
+ }
+ end
+ let(:draft) do
+ {
+ business_product_context: ['Support assistant for Test Product.'],
+ response_guidelines: ['Be concise.'],
+ guardrails: ['Do not guess.'],
+ conversation_messages: {},
+ scenario_candidates: [scenario_candidate],
+ faq_document_candidates: [faq_document_candidate],
+ needs_review: ['Pricing details are missing because factual details are absent from the source instructions.']
+ }
+ end
+
+ describe '#perform' do
+ it 'reports staged scenario candidates in dry run without writing to the assistant' do
+ result = described_class.new(assistant: assistant, draft: draft, dry_run: true).perform
+
+ expect(result.dig(:changes, :config, :to, 'assistant_migration', 'scenario_candidates')).to eq([scenario_candidate])
+ expect(result.dig(:changes, :response_guidelines, :to)).to include(
+ 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.'
+ )
+ expect(assistant.reload.config).not_to have_key('assistant_migration')
+ expect(assistant.scenarios.count).to eq(0)
+ end
+
+ it 'stores scenario candidates in assistant config and flattens them into response guidelines' do
+ described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
+
+ assistant.reload
+ expect(assistant.config.dig('assistant_migration', 'scenario_candidates')).to eq([scenario_candidate])
+ expect(assistant.config.dig('assistant_migration', 'faq_document_candidates')).to contain_exactly(faq_document_candidate)
+ expect(assistant.config.dig('assistant_migration', 'needs_review')).to contain_exactly(
+ 'Pricing details are missing because factual details are absent from the source instructions.'
+ )
+ expect(assistant.response_guidelines).to include(
+ 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.'
+ )
+ expect(assistant.response_guidelines).not_to include(faq_document_candidate['answer'])
+ expect(assistant.scenarios.count).to eq(0)
+ end
+
+ it 'rejects stale drafts whose FAQ candidates use the old string format' do
+ stale_draft = draft.merge(faq_document_candidates: ['Support is available Monday to Friday.'])
+
+ expect do
+ described_class.new(assistant: assistant, draft: stale_draft, dry_run: false).perform
+ end.to raise_error(ArgumentError, 'FAQ document candidates must be question and answer objects')
+
+ expect(assistant.reload.config).not_to have_key('assistant_migration')
+ end
+
+ it 'preserves original values in migration config before applying classifier output' do
+ assistant.update!(
+ description: 'Existing assistant description.',
+ response_guidelines: ['Use plain language.'],
+ guardrails: ['Do not disclose internal notes.']
+ )
+
+ described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
+
+ assistant.reload
+ expect(assistant.description).to eq('Support assistant for Test Product.')
+ expect(assistant.response_guidelines).to include('Be concise.')
+ expect(assistant.guardrails).to eq(['Do not guess.'])
+ expect(assistant.config.dig('assistant_migration', 'original_values')).to include(
+ 'name' => assistant.name,
+ 'description' => 'Existing assistant description.',
+ 'config' => { 'product_name' => 'Test Product', 'instructions' => 'Legacy V1 custom instructions.' },
+ 'response_guidelines' => ['Use plain language.'],
+ 'guardrails' => ['Do not disclose internal notes.']
+ )
+ end
+
+ it 'rejects an oversized assistant description from a stale draft' do
+ long_context = 'This assistant supports a very broad product surface with many long details. ' * 10
+ original_description = assistant.description
+
+ expect do
+ described_class.new(
+ assistant: assistant,
+ draft: draft.merge(business_product_context: [long_context]),
+ dry_run: false
+ ).perform
+ end.to raise_error(ArgumentError, 'Assistant description exceeds 500 characters')
+
+ expect(assistant.reload.description).to eq(original_description)
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
index 004d7027b..b06717c6d 100644
--- a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
+++ b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
@@ -33,23 +33,109 @@ RSpec.describe Captain::Llm::ConversationFaqService do
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
end
- it 'uses the document FAQ generation feature model' do
+ it 'uses the conversation FAQ generation feature model' do
expect(RubyLLM).to receive(:chat).with(
- model: Llm::Models.default_model_for('document_faq_generation')
+ model: Llm::Models.default_model_for('conversation_faq_generation')
).and_return(mock_chat)
described_class.new(captain_assistant, conversation).generate_and_deduplicate
end
+ it 'uses the conversation FAQ default ahead of the legacy global installation model' do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-mini')
+
+ expect(RubyLLM).to receive(:chat).with(
+ model: Llm::Models.default_model_for('conversation_faq_generation')
+ ).and_return(mock_chat)
+
+ described_class.new(captain_assistant, conversation).generate_and_deduplicate
+ end
+
+ it 'keeps account conversation FAQ model overrides ahead of the feature default' do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1')
+ conversation.account.update!(captain_models: { 'conversation_faq_generation' => 'gpt-4.1-mini' })
+
+ expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-mini').and_return(mock_chat)
+
+ described_class.new(captain_assistant, conversation).generate_and_deduplicate
+ end
+
it 'resolves the feature model from the conversation account' do
expect(Llm::FeatureRouter).to receive(:resolve).with(
- feature: 'document_faq_generation',
+ feature: 'conversation_faq_generation',
account: conversation.account
).and_call_original
described_class.new(captain_assistant, conversation).generate_and_deduplicate
end
+ it 'sends only customer and human support agent messages to the LLM' do
+ create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
+ sender: create(:contact, account: conversation.account), message_type: :incoming,
+ content: 'Customer question')
+ create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
+ content: 'Bot answer that should not become knowledge')
+ create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
+ sender: create(:user, account: conversation.account), message_type: :outgoing,
+ content: 'Human answer')
+ create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
+ sender: create(:user, account: conversation.account), message_type: :outgoing,
+ private: true, content: 'Private note')
+ create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
+ message_type: :activity, content: 'Activity message')
+
+ service.generate_and_deduplicate
+
+ expected_content = satisfy do |content|
+ content.include?('User: Customer question') &&
+ content.include?('Support Agent: Human answer') &&
+ content.exclude?('Bot answer that should not become knowledge') &&
+ content.exclude?('Private note') &&
+ content.exclude?('Activity message')
+ end
+ expect(mock_chat).to have_received(:ask).with(expected_content)
+ end
+
+ it 'keeps external echo outgoing replies from native channels in the LLM transcript' do
+ create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
+ sender: create(:contact, account: conversation.account), message_type: :incoming,
+ content: 'Customer asks in a native channel')
+ create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
+ sender: nil, message_type: :outgoing, content: 'Human replied from the native app',
+ content_attributes: { external_echo: true })
+
+ service.generate_and_deduplicate
+
+ expected_content = satisfy do |content|
+ content.include?('User: Customer asks in a native channel') &&
+ content.include?('Support Agent: Human replied from the native app')
+ end
+ expect(mock_chat).to have_received(:ask).with(expected_content)
+ end
+
+ it 'uses the human-only conversation transcript for instrumentation' do
+ create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
+ sender: create(:contact, account: conversation.account), message_type: :incoming,
+ content: 'Customer asks something')
+ create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
+ content: 'Bot-only answer')
+ create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
+ sender: create(:user, account: conversation.account), message_type: :outgoing,
+ content: 'Agent gives a public answer')
+
+ expect(service).to receive(:instrument_llm_call) do |params, &block|
+ user_message = params[:messages].find { |message| message[:role] == 'user' }[:content]
+
+ expect(user_message).to include('User: Customer asks something')
+ expect(user_message).to include('Support Agent: Agent gives a public answer')
+ expect(user_message).not_to include('Bot-only answer')
+
+ block.call
+ end
+
+ service.generate_and_deduplicate
+ end
+
it 'creates new FAQs for valid conversation content' do
expect do
service.generate_and_deduplicate
diff --git a/spec/enterprise/services/conversations/unread_counts/filtered_counter_spec.rb b/spec/enterprise/services/conversations/unread_counts/filtered_counter_spec.rb
new file mode 100644
index 000000000..81c9ae4d0
--- /dev/null
+++ b/spec/enterprise/services/conversations/unread_counts/filtered_counter_spec.rb
@@ -0,0 +1,58 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::FilteredCounter do
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:other_agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:account_user) { account.account_users.find_by(user: agent) }
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
+
+ before do
+ create(:inbox_member, user: agent, inbox: inbox)
+ account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_participating_manage']))
+ end
+
+ after do
+ redis_keys.each { |key| Redis::Alfred.delete(key) }
+ end
+
+ it 'counts participating conversations inside the permission-filtered accessible set' do
+ assigned_participating = create_unread_conversation(account: account, inbox: inbox, assignee: agent)
+ unassigned_participating = create_unread_conversation(account: account, inbox: inbox)
+ assigned_to_other_participating = create_unread_conversation(account: account, inbox: inbox, assignee: other_agent)
+ assigned_not_participating = create_unread_conversation(account: account, inbox: inbox, assignee: agent)
+ create(:conversation_participant, account: account, conversation: assigned_participating, user: agent)
+ create(:conversation_participant, account: account, conversation: unassigned_participating, user: agent)
+ create(:conversation_participant, account: account, conversation: assigned_to_other_participating, user: agent)
+
+ result = described_class.new(account: account, user: agent).perform
+
+ expect(result[:participating_count]).to eq(3)
+ expect(result[:mentions_count]).to eq(0)
+ expect(result[:folders]).to eq({})
+ expect(assigned_not_participating.assignee).to eq(agent)
+ end
+
+ def redis_keys
+ [store.conversation_version_key(account.id)] + built_in_filter_keys + folder_index_keys
+ end
+
+ def built_in_filter_keys
+ [
+ store.built_in_filter_version_key(account.id, agent.id),
+ store.built_in_filter_counts_key(account.id, agent.id),
+ store.built_in_filter_build_lock_key(account.id, agent.id),
+ store.built_in_filter_refresh_throttle_key(account.id, agent.id)
+ ]
+ end
+
+ def folder_index_keys
+ [
+ store.folder_index_version_key(account.id, agent.id),
+ store.folder_index_key(account.id, agent.id),
+ store.folder_index_build_lock_key(account.id, agent.id),
+ store.folder_index_refresh_throttle_key(account.id, agent.id)
+ ]
+ end
+end
diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
index 3223efa86..5116e1ec3 100644
--- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
+++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
@@ -175,6 +175,7 @@ describe Enterprise::Billing::HandleStripeEventService do
described_class::STARTUP_PLAN_FEATURES.each do |feature|
account.enable_features(feature)
end
+ account.enable_features('captain_integration_v2')
account.enable_features(*described_class::BUSINESS_PLAN_FEATURES)
account.enable_features(*described_class::ENTERPRISE_PLAN_FEATURES)
account.save!
@@ -193,6 +194,7 @@ describe Enterprise::Billing::HandleStripeEventService do
all_features.each do |feature|
expect(account).not_to be_feature_enabled(feature)
end
+ expect(account).not_to be_feature_enabled('captain_integration_v2')
end
end
@@ -218,6 +220,29 @@ describe Enterprise::Billing::HandleStripeEventService do
expect(account).not_to be_feature_enabled(feature)
end
end
+
+ it 'does not enable Captain V2 for existing paid accounts during reconciliation' do
+ allow(subscription).to receive(:[]).with('plan')
+ .and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
+
+ stripe_event_service.new.perform(event: event)
+
+ expect(account.reload).not_to be_feature_enabled('captain_integration_v2')
+ end
+
+ it 'enables Captain V2 for new cloud accounts marked as default eligible' do
+ account.update!(
+ internal_attributes: account.internal_attributes.merge(
+ Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE => true
+ )
+ )
+ allow(subscription).to receive(:[]).with('plan')
+ .and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
+
+ stripe_event_service.new.perform(event: event)
+
+ expect(account.reload).to be_feature_enabled('captain_integration_v2')
+ end
end
context 'with Business plan' do
diff --git a/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb b/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb
new file mode 100644
index 000000000..64be87ff4
--- /dev/null
+++ b/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb
@@ -0,0 +1,53 @@
+require 'rails_helper'
+
+describe Enterprise::Billing::ReconcilePlanFeaturesService do
+ let(:account) { create(:account) }
+
+ before do
+ create(:installation_config, {
+ name: 'CHATWOOT_CLOUD_PLANS',
+ value: [
+ { 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
+ { 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] }
+ ]
+ })
+ end
+
+ describe '#perform' do
+ context 'with api_and_webhooks feature' do
+ it 'enables the feature for a paid plan with an active subscription' do
+ account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'active' })
+
+ described_class.new(account: account).perform
+
+ expect(account.reload).to be_feature_enabled('api_and_webhooks')
+ end
+
+ it 'enables the feature for a paid plan on trial' do
+ account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'trialing' })
+
+ described_class.new(account: account).perform
+
+ expect(account.reload).to be_feature_enabled('api_and_webhooks')
+ end
+
+ it 'disables the feature on the default plan' do
+ account.enable_features!('api_and_webhooks')
+ account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'active' })
+
+ described_class.new(account: account).perform
+
+ expect(account.reload).not_to be_feature_enabled('api_and_webhooks')
+ end
+
+ it 'keeps the feature enabled when manually managed' do
+ account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'trialing' })
+ Internal::Accounts::InternalAttributesService.new(account).manually_managed_features = ['api_and_webhooks']
+
+ described_class.new(account: account).perform
+
+ expect(account.reload).to be_feature_enabled('api_and_webhooks')
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/enterprise/conversations/permission_filter_service_spec.rb b/spec/enterprise/services/enterprise/conversations/permission_filter_service_spec.rb
index 0cfec97eb..08d900b6e 100644
--- a/spec/enterprise/services/enterprise/conversations/permission_filter_service_spec.rb
+++ b/spec/enterprise/services/enterprise/conversations/permission_filter_service_spec.rb
@@ -86,7 +86,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
end
context 'when user has conversation_participating_manage permission' do
- it 'returns only conversations assigned to the agent' do
+ it 'returns conversations assigned to the agent or where the agent is a participant' do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
@@ -105,7 +105,9 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create some conversations
other_conversation = create(:conversation, account: test_account, inbox: test_inbox)
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
+ participating_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
+ create(:conversation_participant, account: test_account, conversation: participating_conversation, user: test_agent)
# Run the test
result = Conversations::PermissionFilterService.new(
@@ -114,10 +116,10 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
test_account
).perform
- # Should only see conversations assigned to this agent
- expect(result.count).to eq(1)
- expect(result.first.assignee).to eq(test_agent)
+ # Should only see conversations assigned to this agent or where the agent participates
+ expect(result.count).to eq(2)
expect(result).to include(assigned_conversation)
+ expect(result).to include(participating_conversation)
expect(result).not_to include(other_conversation)
expect(result).not_to include(other_inbox_conversation)
end
diff --git a/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb b/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb
index 63a318415..76e9e3602 100644
--- a/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb
+++ b/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb
@@ -11,14 +11,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'disables the premium features for accounts' do
account = create(:account)
- account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
+ account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2')
account_with_captain = create(:account)
- account_with_captain.enable_features!('captain_integration')
+ account_with_captain.enable_features!('captain_integration', 'captain_integration_v2')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
- expect(account.reload.enabled_features.keys).not_to include('captain_integration', 'disable_branding', 'audit_logs')
- expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration')
+ expect(account.reload.enabled_features.keys).not_to include(
+ 'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs'
+ )
+ expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration', 'captain_integration_v2')
expect(disable_branding_account.reload.enabled_features.keys).not_to include('disable_branding')
end
@@ -56,14 +58,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'does not disable the premium features for accounts' do
account = create(:account)
- account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
+ account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2')
account_with_captain = create(:account)
- account_with_captain.enable_features!('captain_integration')
+ account_with_captain.enable_features!('captain_integration', 'captain_integration_v2')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
- expect(account.reload.enabled_features.keys).to include('captain_integration', 'disable_branding', 'audit_logs')
- expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration')
+ expect(account.reload.enabled_features.keys).to include(
+ 'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs'
+ )
+ expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration', 'captain_integration_v2')
expect(disable_branding_account.reload.enabled_features.keys).to include('disable_branding')
end
diff --git a/spec/enterprise/services/llm/base_ai_service_spec.rb b/spec/enterprise/services/llm/base_ai_service_spec.rb
index f18752485..d4c07d336 100644
--- a/spec/enterprise/services/llm/base_ai_service_spec.rb
+++ b/spec/enterprise/services/llm/base_ai_service_spec.rb
@@ -30,6 +30,14 @@ RSpec.describe Llm::BaseAiService do
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-4.1-nano')
end
+ it 'uses the Captain V2 assistant default ahead of the installation model' do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
+ account.enable_features!('captain_integration_v2')
+
+ expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
+ expect(account.reload.captain_models).to be_nil
+ end
+
it 'uses the feature default when feature context has no account override or installation model' do
expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::Models.default_model_for('assistant'))
end
diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
index 265ce6c33..4881e8cf1 100644
--- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb
+++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
@@ -12,7 +12,13 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' }
# Mock usage limits for transcription to be available
- allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
+ allow(account).to receive(:usage_limits).and_return(
+ {
+ agents: ChatwootApp.max_limit,
+ inboxes: ChatwootApp.max_limit,
+ captain: { responses: { current_available: 100 } }
+ }
+ )
end
describe '#perform' do
diff --git a/spec/factories/captain/agent_session.rb b/spec/factories/captain/agent_session.rb
new file mode 100644
index 000000000..a7b369b7d
--- /dev/null
+++ b/spec/factories/captain/agent_session.rb
@@ -0,0 +1,14 @@
+FactoryBot.define do
+ factory :captain_agent_session, class: 'Captain::AgentSession' do
+ account
+ association :assistant, factory: :captain_assistant
+ session_type { :assistant }
+ subject { create(:conversation, account: account) }
+
+ trait :copilot do
+ session_type { :copilot }
+ user
+ subject { create(:captain_copilot_thread, account: account, user: user) }
+ end
+ end
+end
diff --git a/spec/factories/data_import.rb b/spec/factories/data_import.rb
index ff7561f31..ad361d237 100644
--- a/spec/factories/data_import.rb
+++ b/spec/factories/data_import.rb
@@ -3,5 +3,14 @@ FactoryBot.define do
data_type { 'contacts' }
import_file { Rack::Test::UploadedFile.new(Rails.root.join('spec/assets/contacts.csv'), 'text/csv') }
account
+
+ trait :intercom do
+ data_type { 'intercom' }
+ source_type { 'api' }
+ source_provider { 'intercom' }
+ import_types { %w[contacts conversations] }
+ access_token { 'intercom-token' }
+ import_file { nil }
+ end
end
end
diff --git a/spec/finders/data_import_error_finder_spec.rb b/spec/finders/data_import_error_finder_spec.rb
new file mode 100644
index 000000000..6d546a194
--- /dev/null
+++ b/spec/finders/data_import_error_finder_spec.rb
@@ -0,0 +1,26 @@
+require 'rails_helper'
+
+RSpec.describe DataImportErrorFinder do
+ let(:data_import) { create(:data_import, :intercom) }
+
+ it 'returns only the latest five non-skip errors' do
+ 6.times do |index|
+ data_import.import_errors.create!(
+ error_code: 'Intercom::Error',
+ source_object_id: "error_#{index}",
+ details: { kind: 'run_error' },
+ created_at: Time.zone.at(index)
+ )
+ end
+ data_import.import_errors.create!(
+ error_code: 'Intercom::Skipped',
+ source_object_id: 'skipped_error',
+ details: { kind: 'skipped' },
+ created_at: Time.zone.at(10)
+ )
+
+ errors = described_class.new(data_import).import_errors
+
+ expect(errors.pluck(:source_object_id)).to eq(%w[error_5 error_4 error_3 error_2 error_1])
+ end
+end
diff --git a/spec/finders/data_import_skip_log_finder_spec.rb b/spec/finders/data_import_skip_log_finder_spec.rb
new file mode 100644
index 000000000..473f869f0
--- /dev/null
+++ b/spec/finders/data_import_skip_log_finder_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+RSpec.describe DataImportSkipLogFinder do
+ let(:data_import) { create(:data_import, :intercom) }
+
+ before do
+ 6.times do |index|
+ data_import.import_errors.create!(
+ error_code: 'Intercom::Skipped',
+ source_object_type: 'message',
+ source_object_id: "message_#{index}",
+ details: { kind: 'skipped' },
+ created_at: Time.zone.at(index)
+ )
+ end
+ data_import.import_errors.create!(
+ error_code: 'Intercom::Skipped',
+ source_object_type: 'contact',
+ source_object_id: 'contact_1',
+ details: { kind: 'skipped' }
+ )
+ end
+
+ it 'filters skip logs and returns only the latest five', :aggregate_failures do
+ finder = described_class.new(data_import, skip_logs_type: 'message')
+
+ expect(finder.skip_logs.pluck(:source_object_id)).to eq(%w[message_5 message_4 message_3 message_2 message_1])
+ expect(finder.selected_source_object_type).to eq('message')
+ expect(finder.counts_by_type).to include('message' => 6, 'contact' => 1)
+ end
+
+ it 'ignores unsupported source object filters' do
+ finder = described_class.new(data_import, skip_logs_type: 'company')
+
+ expect(finder.selected_source_object_type).to be_nil
+ expect(finder.skip_logs.size).to eq(5)
+ end
+end
diff --git a/spec/jobs/agents/destroy_job_spec.rb b/spec/jobs/agents/destroy_job_spec.rb
index cf6bb2ffa..7c1c16e0c 100644
--- a/spec/jobs/agents/destroy_job_spec.rb
+++ b/spec/jobs/agents/destroy_job_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe Agents::DestroyJob do
let(:user) { create(:user, account: account) }
let(:team1) { create(:team, account: account) }
let!(:inbox) { create(:inbox, account: account) }
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
before do
create(:team_member, team: team1, user: user)
@@ -30,5 +31,13 @@ RSpec.describe Agents::DestroyJob do
expect(user.notification_settings.length).to eq 0
expect(user.assigned_conversations.where(account: account).length).to eq 0
end
+
+ it 'invalidates saved filter snapshots when assigned conversations are unassigned' do
+ account.enable_features!(:unread_count_for_filters)
+
+ expect do
+ described_class.perform_now(account, user)
+ end.to change { store.conversation_version(account.id) }.by(1)
+ end
end
end
diff --git a/spec/jobs/data_imports/intercom/import_jobs_spec.rb b/spec/jobs/data_imports/intercom/import_jobs_spec.rb
new file mode 100644
index 000000000..52b72e59d
--- /dev/null
+++ b/spec/jobs/data_imports/intercom/import_jobs_spec.rb
@@ -0,0 +1,218 @@
+require 'rails_helper'
+
+RSpec.describe DataImports::Intercom::ImportJob do
+ let(:account) { create(:account) }
+ let(:data_import) do
+ create(
+ :data_import, :intercom,
+ account: account,
+ import_types: %w[contacts conversations]
+ )
+ end
+ let(:importer) { instance_double(DataImports::Intercom::Importer) }
+ let(:run_id) { 'intercom-run-1' }
+
+ before do
+ account.enable_features!('data_import')
+ data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => run_id })
+ allow(DataImports::Intercom::Importer).to receive(:new).with(data_import: data_import, run_id: run_id).and_return(importer)
+ end
+
+ describe DataImports::Intercom::BaseJob do
+ it 'checks rate limit retry before the generic client retry' do
+ expect(described_class.rescue_handlers.last.first).to eq('DataImports::Intercom::Client::RateLimitError')
+ end
+ end
+
+ describe DataImports::Intercom::ImportJob do
+ it 'starts the import and enqueues the first contacts page' do
+ allow(importer).to receive_messages(start!: true, import_contacts?: true, contacts_completed?: false, cursor_for: 'contact-cursor')
+
+ expect do
+ described_class.perform_now(data_import, run_id)
+ end.to have_enqueued_job(DataImports::Intercom::ContactsPageJob).with(data_import, 'contact-cursor', run_id).on_queue('low')
+
+ expect(importer).to have_received(:start!)
+ end
+
+ it 'resumes at conversations when contacts are already completed' do
+ allow(importer).to receive_messages(
+ start!: true,
+ import_contacts?: true,
+ contacts_completed?: true,
+ import_conversations?: true,
+ conversations_completed?: false
+ )
+ allow(importer).to receive(:cursor_for).with('conversations').and_return('conversation-cursor')
+
+ expect do
+ described_class.perform_now(data_import, run_id)
+ end.to have_enqueued_job(DataImports::Intercom::ConversationsPageJob).with(data_import, 'conversation-cursor', run_id)
+ end
+
+ it 'finishes immediately when every requested stage is already complete' do
+ allow(importer).to receive_messages(
+ start!: true,
+ import_contacts?: true,
+ contacts_completed?: true,
+ import_conversations?: true,
+ conversations_completed?: true,
+ finish!: true
+ )
+
+ expect do
+ described_class.perform_now(data_import, run_id)
+ end.not_to have_enqueued_job
+
+ expect(importer).to have_received(:finish!)
+ end
+
+ it 'skips stale import jobs from an earlier run' do
+ data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
+
+ expect(DataImports::Intercom::Importer).not_to receive(:new)
+
+ described_class.perform_now(data_import, 'old-run')
+ end
+ end
+
+ describe DataImports::Intercom::ContactsPageJob do
+ it 'hands off to conversations when a retry finds contacts already completed' do
+ allow(importer).to receive_messages(
+ contacts_completed?: true,
+ import_conversations?: true,
+ conversations_completed?: false
+ )
+ allow(importer).to receive(:cursor_for).with('conversations').and_return('conversation-cursor')
+ expect(importer).not_to receive(:import_contacts_page)
+
+ expect do
+ described_class.perform_now(data_import, 'completed-contact-cursor', run_id)
+ end.to have_enqueued_job(DataImports::Intercom::ConversationsPageJob).with(data_import, 'conversation-cursor', run_id)
+ end
+
+ it 'imports one contacts page and enqueues the next contacts page' do
+ result = DataImports::Intercom::Importer::PageResult.new(next_cursor: 'next-contact-cursor')
+ allow(importer).to receive_messages(contacts_completed?: false)
+ allow(importer).to receive(:import_contacts_page).with(starting_after: 'current-contact-cursor').and_return(result)
+
+ expect do
+ described_class.perform_now(data_import, 'current-contact-cursor', run_id)
+ end.to have_enqueued_job(described_class).with(data_import, 'next-contact-cursor', run_id)
+ end
+
+ it 'hands off to conversations after the final contacts page' do
+ result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil)
+ allow(importer).to receive_messages(
+ contacts_completed?: false,
+ import_conversations?: true,
+ conversations_completed?: false
+ )
+ allow(importer).to receive(:import_contacts_page).with(starting_after: nil).and_return(result)
+ allow(importer).to receive(:cursor_for).with('conversations').and_return(nil)
+
+ expect do
+ described_class.perform_now(data_import, nil, run_id)
+ end.to have_enqueued_job(DataImports::Intercom::ConversationsPageJob).with(data_import, nil, run_id)
+ end
+
+ it 'finishes after the final contacts page when conversations are not requested' do
+ result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil)
+ allow(importer).to receive_messages(contacts_completed?: false, import_conversations?: false, finish!: true)
+ allow(importer).to receive(:import_contacts_page).with(starting_after: nil).and_return(result)
+
+ expect do
+ described_class.perform_now(data_import, nil, run_id)
+ end.not_to have_enqueued_job
+
+ expect(importer).to have_received(:finish!)
+ end
+
+ it 'skips stale page jobs from an earlier run' do
+ data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
+
+ expect(DataImports::Intercom::Importer).not_to receive(:new)
+
+ described_class.perform_now(data_import, 'current-contact-cursor', 'old-run')
+ end
+
+ it 'skips failed page jobs from backend retries' do
+ data_import.update!(status: :failed)
+
+ expect(DataImports::Intercom::Importer).not_to receive(:new)
+
+ described_class.perform_now(data_import, 'current-contact-cursor', run_id)
+ end
+
+ it 'does not enqueue another stage when the page import becomes stale' do
+ result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil)
+ allow(importer).to receive_messages(contacts_completed?: false, finish!: true)
+ allow(importer).to receive(:import_contacts_page).with(starting_after: 'current-contact-cursor') do
+ data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
+ result
+ end
+
+ expect do
+ described_class.perform_now(data_import, 'current-contact-cursor', run_id)
+ end.not_to have_enqueued_job
+
+ expect(importer).not_to have_received(:finish!)
+ end
+ end
+
+ describe DataImports::Intercom::ConversationsPageJob do
+ it 'finishes when a retry finds conversations already completed' do
+ allow(importer).to receive_messages(conversations_completed?: true, finish!: true)
+ expect(importer).not_to receive(:import_conversations_page)
+
+ described_class.perform_now(data_import, 'completed-conversation-cursor', run_id)
+
+ expect(importer).to have_received(:finish!)
+ end
+
+ it 'imports one conversations page and enqueues the next conversations page' do
+ result = DataImports::Intercom::Importer::PageResult.new(next_cursor: 'next-conversation-cursor')
+ allow(importer).to receive_messages(conversations_completed?: false)
+ allow(importer).to receive(:import_conversations_page).with(starting_after: 'current-conversation-cursor').and_return(result)
+
+ expect do
+ described_class.perform_now(data_import, 'current-conversation-cursor', run_id)
+ end.to have_enqueued_job(described_class).with(data_import, 'next-conversation-cursor', run_id)
+ end
+
+ it 'finishes after the final conversations page' do
+ result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil)
+ allow(importer).to receive_messages(conversations_completed?: false, finish!: true)
+ allow(importer).to receive(:import_conversations_page).with(starting_after: nil).and_return(result)
+
+ expect do
+ described_class.perform_now(data_import, nil, run_id)
+ end.not_to have_enqueued_job
+
+ expect(importer).to have_received(:finish!)
+ end
+
+ it 'skips stale page jobs from an earlier run' do
+ data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
+
+ expect(DataImports::Intercom::Importer).not_to receive(:new)
+
+ described_class.perform_now(data_import, 'current-conversation-cursor', 'old-run')
+ end
+
+ it 'does not finish when the page import becomes stale' do
+ result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil)
+ allow(importer).to receive_messages(conversations_completed?: false, finish!: true)
+ allow(importer).to receive(:import_conversations_page).with(starting_after: 'current-conversation-cursor') do
+ data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
+ result
+ end
+
+ expect do
+ described_class.perform_now(data_import, 'current-conversation-cursor', run_id)
+ end.not_to have_enqueued_job
+
+ expect(importer).not_to have_received(:finish!)
+ end
+ end
+end
diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb
index b24a5c49c..2cb24ce04 100644
--- a/spec/lib/captain/base_task_service_spec.rb
+++ b/spec/lib/captain/base_task_service_spec.rb
@@ -385,7 +385,10 @@ RSpec.describe Captain::BaseTaskService do
describe '#prompt_from_file' do
it 'reads prompt from file' do
- allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content'))
+ service
+ prompt_path = instance_double(Pathname, read: 'Test prompt content')
+ allow(Rails.root).to receive(:join).with('lib/integrations/openai/openai_prompts', 'test.liquid').and_return(prompt_path)
+
expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content')
end
end
diff --git a/spec/lib/captain/reply_suggestion_service_spec.rb b/spec/lib/captain/reply_suggestion_service_spec.rb
index 608db43a2..9b5cbfa9e 100644
--- a/spec/lib/captain/reply_suggestion_service_spec.rb
+++ b/spec/lib/captain/reply_suggestion_service_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe Captain::ReplySuggestionService do
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
create(:message, conversation: conversation, message_type: :incoming, content: 'I need help')
+ allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_response = instance_double(RubyLLM::Message, content: 'Sure, I can help!', input_tokens: 50, output_tokens: 20)
diff --git a/spec/lib/config_loader_spec.rb b/spec/lib/config_loader_spec.rb
index 60b689872..4c5fd4fbd 100644
--- a/spec/lib/config_loader_spec.rb
+++ b/spec/lib/config_loader_spec.rb
@@ -42,5 +42,27 @@ describe ConfigLoader do
expect(InstallationConfig.find_by(name: 'WHO').value).to eq('covid 19')
end
end
+
+ it 'preserves feature flag column metadata in account level defaults' do
+ Dir.mktmpdir do |config_path|
+ File.write("#{config_path}/installation_config.yml", <<~YAML)
+ - name: TEST_CONFIG
+ value: test
+ locked: true
+ YAML
+ File.write("#{config_path}/features.yml", <<~YAML)
+ - name: extension_feature
+ display_name: Extension Feature
+ enabled: false
+ column: feature_flags_ext_1
+ YAML
+
+ described_class.new.process(config_path: config_path)
+
+ expect(InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').value).to include(
+ a_hash_including('name' => 'extension_feature', 'column' => 'feature_flags_ext_1')
+ )
+ end
+ end
end
end
diff --git a/spec/lib/llm/feature_router_spec.rb b/spec/lib/llm/feature_router_spec.rb
index e0eb4afa0..ea2b9f91f 100644
--- a/spec/lib/llm/feature_router_spec.rb
+++ b/spec/lib/llm/feature_router_spec.rb
@@ -30,6 +30,32 @@ RSpec.describe Llm::FeatureRouter do
)
end
+ it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do
+ account.enable_features!('captain_integration_v2')
+
+ resolved = described_class.resolve(feature: 'assistant', account: account)
+
+ expect(resolved).to include(
+ feature: 'assistant',
+ provider: 'openai',
+ model: 'gpt-5.2',
+ source: :default
+ )
+ expect(account.reload.captain_models).to be_nil
+ end
+
+ it 'keeps account model overrides ahead of the Captain V2 default' do
+ account.enable_features!('captain_integration_v2')
+ account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
+
+ resolved = described_class.resolve(feature: 'assistant', account: account)
+
+ expect(resolved).to include(
+ model: 'gpt-5.1',
+ source: :account_override
+ )
+ end
+
it 'falls back to the feature default when the account override is invalid' do
account.captain_models = { 'editor' => 'invalid-model' }
diff --git a/spec/lib/llm/models_spec.rb b/spec/lib/llm/models_spec.rb
index f93df20fb..5692bee9c 100644
--- a/spec/lib/llm/models_spec.rb
+++ b/spec/lib/llm/models_spec.rb
@@ -25,6 +25,11 @@ RSpec.describe Llm::Models do
expect(missing_models).to be_empty, "#{feature_key} references missing models: #{missing_models.join(', ')}"
end
end
+
+ it 'routes document and conversation FAQ generation independently' do
+ expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-4.1-mini')
+ expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.2')
+ end
end
describe '.models' do
diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb
index cdb9a93cb..db387c5dc 100644
--- a/spec/listeners/action_cable_listener_spec.rb
+++ b/spec/listeners/action_cable_listener_spec.rb
@@ -13,6 +13,30 @@ describe ActionCableListener do
Current.account = nil
end
+ describe '#account_cache_invalidated' do
+ let!(:event) do
+ Events::Base.new(
+ :'account.cache_invalidated',
+ Time.zone.now,
+ account: account,
+ cache_keys: account.cache_keys
+ )
+ end
+
+ it 'sends cache invalidation to account agents and admins' do
+ expect(ActionCableBroadcastJob).to receive(:perform_later).with(
+ a_collection_containing_exactly(agent.pubsub_token, admin.pubsub_token),
+ 'account.cache_invalidated',
+ {
+ cache_keys: account.cache_keys,
+ account_id: account.id
+ }
+ )
+
+ listener.account_cache_invalidated(event)
+ end
+ end
+
describe '#message_created' do
let(:event_name) { :'message.created' }
let!(:message) do
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 56bd41f7c..00b464f73 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -50,6 +50,21 @@ RSpec.describe Account do
end
end
+ describe 'captain defaults for new accounts' do
+ it 'does not store Captain model overrides or enable premium Captain features' do
+ InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
+ value: Featurable::FEATURE_LIST,
+ locked: true
+ )
+
+ account = create(:account)
+
+ expect(account).not_to be_feature_enabled('captain_integration')
+ expect(account).not_to be_feature_enabled('captain_integration_v2')
+ expect(account.captain_models).to be_nil
+ end
+ end
+
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
@@ -88,6 +103,38 @@ RSpec.describe Account do
end
end
+ describe 'feature flag columns' do
+ let(:account) { described_class.new(name: 'Test Account') }
+
+ it 'configures the account feature flag extension column' do
+ expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1')
+ expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(feature_whatsapp_manual_transfer: 1, feature_data_import: 1 << 1,
+ feature_api_and_webhooks: 1 << 2)
+ expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1)
+ expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2)
+ end
+
+ it 'keeps existing feature flags on the original column' do
+ expect(described_class.flag_mapping['feature_flags'][:feature_inbound_emails]).to eq(1)
+ expect(described_class.flag_mapping['feature_flags'][:feature_advanced_assignment]).to eq(1 << 62)
+ end
+
+ it 'keeps bulk selected feature assignment compatible with existing feature names' do
+ account.selected_feature_flags = [:feature_ip_lookup, :feature_assignment_v2, :feature_advanced_assignment, :feature_data_import]
+
+ expect(account).to be_feature_ip_lookup
+ expect(account).to be_feature_assignment_v2
+ expect(account).to be_feature_advanced_assignment
+ expect(account).to be_feature_data_import
+ expect(account.selected_feature_flags).to contain_exactly(
+ :feature_ip_lookup,
+ :feature_assignment_v2,
+ :feature_advanced_assignment,
+ :feature_data_import
+ )
+ end
+ end
+
describe 'inbound_email_domain' do
let(:account) { create(:account) }
@@ -337,6 +384,10 @@ RSpec.describe Account do
let(:account) { create(:account) }
describe 'with no saved preferences' do
+ before do
+ account.update!(captain_models: nil)
+ end
+
it 'returns defaults from llm.yml' do
prefs = account.captain_preferences
@@ -346,6 +397,13 @@ RSpec.describe Account do
expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature))
end
end
+
+ it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do
+ account.enable_features!('captain_integration_v2')
+
+ expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
+ expect(account.reload.captain_models).to be_nil
+ end
end
describe 'with saved model preferences' do
diff --git a/spec/models/account_user_spec.rb b/spec/models/account_user_spec.rb
index e5a560fe9..394654db4 100644
--- a/spec/models/account_user_spec.rb
+++ b/spec/models/account_user_spec.rb
@@ -42,4 +42,43 @@ RSpec.describe AccountUser do
expect(user.assigned_conversations.count).to eq(0)
end
end
+
+ describe 'filtered unread count invalidation' do
+ let(:account) { create(:account) }
+ let(:user) { create(:user) }
+ let(:invalidator) { instance_double(Conversations::UnreadCounts::FilteredCountInvalidator, user_visibility_changed!: true) }
+
+ before do
+ allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).and_return(invalidator)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ end
+
+ it 'invalidates filtered counts when the user is added to an account' do
+ create(:account_user, account: account, user: user)
+
+ expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id)
+ end
+
+ it 'invalidates filtered counts when the user role changes' do
+ account_user = create(:account_user, account: account, user: user)
+
+ account_user.update!(role: :administrator)
+
+ expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id).twice
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'account.cache_invalidated',
+ kind_of(Time),
+ account: account,
+ cache_keys: account.cache_keys
+ )
+ end
+
+ it 'invalidates filtered counts when the user is removed from an account' do
+ account_user = create(:account_user, account: account, user: user)
+
+ account_user.destroy!
+
+ expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id).twice
+ end
+ end
end
diff --git a/spec/models/campaign_spec.rb b/spec/models/campaign_spec.rb
index 2be6bd588..e4bdd05e4 100644
--- a/spec/models/campaign_spec.rb
+++ b/spec/models/campaign_spec.rb
@@ -3,11 +3,48 @@
require 'rails_helper'
RSpec.describe Campaign do
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
+
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:inbox) }
end
+ describe '#destroy' do
+ let(:account) { create(:account) }
+ let(:campaign) { create(:campaign, account: account) }
+
+ before do
+ campaign
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ end
+
+ after do
+ Redis::Alfred.delete(store.conversation_version_key(account.id))
+ end
+
+ it 'invalidates and refreshes filtered counts when conversations are detached from a deleted campaign' do
+ account.enable_features!(:unread_count_for_filters)
+
+ expect do
+ campaign.destroy!
+ end.to change { store.conversation_version(account.id) }.by(1)
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'account.cache_invalidated',
+ kind_of(Time),
+ account: account,
+ cache_keys: account.cache_keys
+ )
+ end
+
+ it 'does not notify filtered count refreshes when the feature is disabled' do
+ campaign.destroy!
+
+ expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
+ end
+ end
+
describe '.before_create' do
let(:account) { create(:account) }
let(:website_channel) { create(:channel_widget, account: account) }
diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
index cd4fa3a21..10a192004 100644
--- a/spec/models/channel/whatsapp_spec.rb
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -42,8 +42,18 @@ RSpec.describe Channel::Whatsapp do
body: { data: [{
id: '123456789', name: 'test_template'
}] }.to_json)
+ stub_request(:get, 'https://graph.facebook.com/v14.0//phone_numbers?fields=id&limit=100&access_token=test_key')
+ .to_return(status: 200, body: { data: [{ id: 'random_id' }] }.to_json, headers: { 'Content-Type' => 'application/json' })
expect(channel.save).to be(true)
end
+
+ it 'validates false when phone number id is wrong' do
+ stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key')
+ .to_return(status: 200, body: { data: [] }.to_json)
+ stub_request(:get, 'https://graph.facebook.com/v14.0//phone_numbers?fields=id&limit=100&access_token=test_key')
+ .to_return(status: 200, body: { data: [{ id: 'another_phone_id' }] }.to_json, headers: { 'Content-Type' => 'application/json' })
+ expect(channel.save).to be(false)
+ end
end
describe 'webhook_verify_token' do
diff --git a/spec/models/concerns/captain_featurable_spec.rb b/spec/models/concerns/captain_featurable_spec.rb
index 7221ad055..0fb145142 100644
--- a/spec/models/concerns/captain_featurable_spec.rb
+++ b/spec/models/concerns/captain_featurable_spec.rb
@@ -58,15 +58,6 @@ RSpec.describe CaptainFeaturable do
end
describe 'model accessor methods' do
- context 'when no models are explicitly configured' do
- it 'returns default models for all features' do
- Llm::Models.feature_keys.each do |feature_key|
- expected_default = Llm::Models.default_model_for(feature_key)
- expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
- end
- end
- end
-
context 'when models are explicitly configured' do
before do
account.update!(captain_models: {
diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb
new file mode 100644
index 000000000..8f0b64b05
--- /dev/null
+++ b/spec/models/concerns/featurable_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Featurable do
+ describe '.feature_flag_mappings_for' do
+ it 'maps features to the default feature_flags column when column is omitted' do
+ mappings = described_class.feature_flag_mappings_for([
+ { 'name' => 'inbound_emails' },
+ { 'name' => 'ip_lookup' }
+ ])
+
+ expect(mappings['feature_flags']).to eq(
+ 1 => :feature_inbound_emails,
+ 2 => :feature_ip_lookup
+ )
+ expect(mappings['feature_flags_ext_1']).to eq({})
+ end
+
+ it 'maps extension flags to feature_flags_ext_1 with independent bit positions' do
+ mappings = described_class.feature_flag_mappings_for([
+ { 'name' => 'inbound_emails' },
+ { 'name' => 'ext_one', 'column' => 'feature_flags_ext_1' },
+ { 'name' => 'ext_two', 'column' => 'feature_flags_ext_1' }
+ ])
+
+ expect(mappings['feature_flags']).to eq(1 => :feature_inbound_emails)
+ expect(mappings['feature_flags_ext_1']).to eq(
+ 1 => :feature_ext_one,
+ 2 => :feature_ext_two
+ )
+ end
+
+ it 'raises when a feature references an unknown flag column' do
+ expect do
+ described_class.feature_flag_mappings_for([
+ { 'name' => 'unknown_column_feature', 'column' => 'feature_flags_3' }
+ ])
+ end.to raise_error(ArgumentError, /Unknown account feature flag column: feature_flags_3/)
+ end
+
+ it 'raises when a flag column has more than the supported number of features' do
+ features = Array.new(64) { |index| { 'name' => "feature_#{index}" } }
+
+ expect do
+ described_class.feature_flag_mappings_for(features)
+ end.to raise_error(ArgumentError, /feature_flags supports up to 63 features/)
+ end
+ end
+end
diff --git a/spec/models/conversation_participants_spec.rb b/spec/models/conversation_participants_spec.rb
index c078f69f6..f8801d461 100644
--- a/spec/models/conversation_participants_spec.rb
+++ b/spec/models/conversation_participants_spec.rb
@@ -29,4 +29,30 @@ RSpec.describe ConversationParticipant do
expect(participant.errors.messages[:user]).to eq(['must have inbox access'])
end
end
+
+ describe 'filtered unread count invalidation' do
+ let(:account) { create(:account) }
+ let(:conversation) { create(:conversation, account: account) }
+ let(:user) { create(:user, account: account) }
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
+
+ before do
+ account.enable_features!(:unread_count_for_filters)
+ create(:inbox_member, inbox: conversation.inbox, user: user)
+ end
+
+ it 'invalidates the participant built-in filter version when a participant is added' do
+ expect do
+ create(:conversation_participant, account: account, conversation: conversation, user: user)
+ end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
+ end
+
+ it 'invalidates the participant built-in filter version when a participant is removed' do
+ participant = create(:conversation_participant, account: account, conversation: conversation, user: user)
+
+ expect do
+ participant.destroy!
+ end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
+ end
+ end
end
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index 58d64ea94..43bbab56f 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -117,6 +117,7 @@ RSpec.describe Conversation do
end
let(:assignment_mailer) { instance_double(AssignmentMailer, deliver: true) }
let(:label) { create(:label, account: account) }
+ let(:filtered_store) { Conversations::UnreadCounts::FilteredCountStore }
before do
create(:inbox_member, user: old_assignee, inbox: conversation.inbox)
@@ -125,6 +126,10 @@ RSpec.describe Conversation do
Current.user = old_assignee
end
+ after do
+ Redis::Alfred.delete(filtered_store.conversation_version_key(account.id))
+ end
+
it 'sends conversation updated event if labels are updated' do
conversation.update(label_list: [label.title])
changed_attributes = conversation.previous_changes
@@ -139,6 +144,33 @@ RSpec.describe Conversation do
)
end
+ it 'invalidates filtered counts without sending conversation updated event if last activity time is updated' do
+ account.enable_features!(:unread_count_for_filters)
+
+ expect do
+ conversation.update!(last_activity_at: 1.hour.from_now)
+ end.to change { filtered_store.conversation_version(account.id) }.by(1)
+ expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
+ described_class::CONVERSATION_UPDATED,
+ kind_of(Time),
+ anything
+ )
+ end
+
+ it 'invalidates filtered counts without sending conversation updated event if campaign assignment is updated' do
+ account.enable_features!(:unread_count_for_filters)
+ campaign = create(:campaign, account: account, inbox: conversation.inbox)
+
+ expect do
+ conversation.update!(campaign: campaign)
+ end.to change { filtered_store.conversation_version(account.id) }.by(1)
+ expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
+ described_class::CONVERSATION_UPDATED,
+ kind_of(Time),
+ anything
+ )
+ end
+
it 'runs after_update callbacks' do
conversation.update(
status: :resolved,
@@ -174,20 +206,47 @@ RSpec.describe Conversation do
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true)
end
- it 'will run conversation_updated event for conversation_language in additional_attributes' do
- conversation.additional_attributes[:conversation_language] = 'es'
- conversation.save!
+ it 'will run conversation_updated event for conversation language changes' do
+ conversation.update!(additional_attributes: { 'conversation_language' => 'es' })
changed_attributes = conversation.previous_changes
+
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: false,
changed_attributes: changed_attributes, performed_by: nil)
end
- it 'will not run conversation_updated event for bowser_language in additional_attributes' do
- conversation.additional_attributes[:browser_language] = 'es'
+ it 'invalidates filtered counts without sending conversation_updated for filtered-only additional_attributes' do
+ account.enable_features!(:unread_count_for_filters)
+
+ expect do
+ conversation.update!(additional_attributes: { 'browser_language' => 'es' })
+ end.to change { filtered_store.conversation_version(account.id) }.by(1)
+ expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
+ described_class::CONVERSATION_UPDATED,
+ kind_of(Time),
+ anything
+ )
+ end
+
+ it 'invalidates filtered counts when filterable additional_attributes are removed' do
+ account.enable_features!(:unread_count_for_filters)
+ conversation.update!(additional_attributes: { 'referer' => 'https://www.chatwoot.com/' })
+
+ expect do
+ conversation.update!(additional_attributes: {})
+ end.to change { filtered_store.conversation_version(account.id) }.by(1)
+ expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
+ described_class::CONVERSATION_UPDATED,
+ kind_of(Time),
+ anything
+ )
+ end
+
+ it 'will not run conversation_updated event for non-filterable additional_attributes' do
+ conversation.additional_attributes[:source_id] = 'es'
conversation.save!
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
- .with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true)
+ .with(described_class::CONVERSATION_UPDATED, kind_of(Time), anything)
end
it 'creates conversation activities' do
diff --git a/spec/models/custom_attribute_definition_spec.rb b/spec/models/custom_attribute_definition_spec.rb
index c529fc609..8cf9065e0 100644
--- a/spec/models/custom_attribute_definition_spec.rb
+++ b/spec/models/custom_attribute_definition_spec.rb
@@ -68,5 +68,51 @@ RSpec.describe CustomAttributeDefinition do
expect(cad.attribute_display_name).to eq('Order Date')
end
end
+
+ describe 'filtered unread count invalidation' do
+ let(:invalidator) { instance_double(Conversations::UnreadCounts::FilteredCountInvalidator, custom_attribute_definition_changed!: true) }
+
+ before do
+ allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ end
+
+ it 'invalidates conversation filters when a conversation custom attribute definition changes' do
+ cad = create(:custom_attribute_definition, account: account, attribute_model: 'conversation_attribute')
+
+ cad.update!(attribute_display_name: 'Updated Order Date')
+
+ expect(invalidator).to have_received(:custom_attribute_definition_changed!).with(cad)
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'account.cache_invalidated',
+ kind_of(Time),
+ account: account,
+ cache_keys: account.cache_keys
+ )
+ end
+
+ it 'invalidates conversation filters when a conversation custom attribute definition is deleted' do
+ cad = create(:custom_attribute_definition, account: account, attribute_model: 'conversation_attribute')
+
+ cad.destroy!
+
+ expect(invalidator).to have_received(:custom_attribute_definition_changed!).with(cad)
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'account.cache_invalidated',
+ kind_of(Time),
+ account: account,
+ cache_keys: account.cache_keys
+ )
+ end
+
+ it 'ignores contact custom attribute definition changes' do
+ cad = create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute')
+
+ cad.update!(attribute_display_name: 'Updated Contact Field')
+
+ expect(invalidator).not_to have_received(:custom_attribute_definition_changed!)
+ expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
+ end
+ end
end
end
diff --git a/spec/models/custom_filter_spec.rb b/spec/models/custom_filter_spec.rb
new file mode 100644
index 000000000..ecb3f2baa
--- /dev/null
+++ b/spec/models/custom_filter_spec.rb
@@ -0,0 +1,58 @@
+require 'rails_helper'
+
+RSpec.describe CustomFilter do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
+
+ before do
+ account.enable_features!(:unread_count_for_filters)
+ end
+
+ describe 'filtered unread count invalidation' do
+ it 'invalidates the folder index and filter version when a conversation filter is created' do
+ custom_filter = nil
+
+ expect do
+ custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation)
+ end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1)
+ expect(store.filter_version(account_id: account.id, filter_id: custom_filter.id)).to eq(1)
+ end
+
+ it 'invalidates only the filter version when the query changes' do
+ custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation)
+ folder_index_version = store.folder_index_version(account_id: account.id, user_id: user.id)
+
+ expect do
+ custom_filter.update!(query: { payload: [{ attribute_key: 'status', values: ['resolved'] }] })
+ end.to(change { store.filter_version(account_id: account.id, filter_id: custom_filter.id) }.by(1))
+ expect(store.folder_index_version(account_id: account.id, user_id: user.id)).to eq(folder_index_version)
+ end
+
+ it 'does not invalidate counts when only the name changes' do
+ custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation)
+
+ expect do
+ custom_filter.update!(name: 'Renamed filter')
+ end.not_to(change { store.filter_version(account_id: account.id, filter_id: custom_filter.id) })
+ end
+
+ it 'invalidates the folder index and deletes the count when a conversation filter is destroyed' do
+ custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation)
+ store.write_filter_count!(
+ account_id: account.id,
+ filter_id: custom_filter.id,
+ user_id: user.id,
+ count: 3,
+ account_version: 0,
+ filter_version: 0,
+ owner_built_in_filter_version: 0
+ )
+
+ expect do
+ custom_filter.destroy!
+ end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1)
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+ end
+end
diff --git a/spec/models/data_import_spec.rb b/spec/models/data_import_spec.rb
index 2b7429cf1..fc31ebb1d 100644
--- a/spec/models/data_import_spec.rb
+++ b/spec/models/data_import_spec.rb
@@ -11,6 +11,18 @@ RSpec.describe DataImport do
end
end
+ describe 'access token encryption' do
+ it 'encrypts the Intercom access token at rest' do
+ skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
+
+ data_import = create(:data_import, :intercom, access_token: 'intercom-secret')
+ stored_value = data_import.reload.read_attribute_before_type_cast(:access_token).to_s
+
+ expect(stored_value).not_to include('intercom-secret')
+ expect(data_import.access_token).to eq('intercom-secret')
+ end
+ end
+
describe 'callbacks' do
let(:data_import) { build(:data_import) }
@@ -20,4 +32,36 @@ RSpec.describe DataImport do
end.to have_enqueued_job(DataImportJob).with(data_import).on_queue('low')
end
end
+
+ describe '#abandon!' do
+ let(:account) { create(:account) }
+ let(:data_import) do
+ create(
+ :data_import, :intercom,
+ account: account,
+ status: :processing
+ )
+ end
+
+ before do
+ account.enable_features!('data_import')
+ end
+
+ it 'abandons active Intercom imports', :aggregate_failures do
+ data_import.abandon!
+
+ expect(data_import).to be_abandoned
+ expect(data_import.abandoned_at).to be_present
+ end
+
+ it 'does not overwrite terminal status from a stale instance', :aggregate_failures do
+ stale_import = described_class.find(data_import.id)
+ data_import.update!(status: :completed, completed_at: 1.minute.ago)
+
+ stale_import.abandon!
+
+ expect(data_import.reload).to be_completed
+ expect(data_import.abandoned_at).to be_nil
+ end
+ end
end
diff --git a/spec/models/inbox_member_spec.rb b/spec/models/inbox_member_spec.rb
index b081c546c..67f662cac 100644
--- a/spec/models/inbox_member_spec.rb
+++ b/spec/models/inbox_member_spec.rb
@@ -18,4 +18,46 @@ RSpec.describe InboxMember do
end
end
end
+
+ describe 'filtered unread count invalidation' do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:user) { create(:user) }
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
+
+ before do
+ account.enable_features!(:unread_count_for_filters)
+ end
+
+ it 'invalidates the user built-in filter version when inbox access is added' do
+ expect do
+ create(:inbox_member, inbox: inbox, user: user)
+ end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
+ end
+
+ it 'invalidates the user built-in filter version when inbox access is removed' do
+ inbox_member = create(:inbox_member, inbox: inbox, user: user)
+
+ expect do
+ inbox_member.destroy!
+ end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
+ end
+
+ it 'invalidates the user built-in filter version when the parent inbox is removed' do
+ create(:inbox_member, inbox: inbox, user: user)
+
+ expect do
+ perform_enqueued_jobs { inbox.destroy! }
+ end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
+ end
+
+ it 'invalidates administrator built-in filter versions when the parent inbox is removed' do
+ admin = create(:user)
+ create(:account_user, account: account, user: admin, role: :administrator)
+
+ expect do
+ perform_enqueued_jobs { inbox.destroy! }
+ end.to change { store.built_in_filter_version(account_id: account.id, user_id: admin.id) }.by(1)
+ end
+ end
end
diff --git a/spec/models/inbox_spec.rb b/spec/models/inbox_spec.rb
index 92fddcd78..617a74388 100644
--- a/spec/models/inbox_spec.rb
+++ b/spec/models/inbox_spec.rb
@@ -41,6 +41,34 @@ RSpec.describe Inbox do
it_behaves_like 'avatarable'
end
+ describe 'account teardown' do
+ it 'destroys an orphaned inbox after its account has been deleted' do
+ account = create(:account)
+ inbox = create(:inbox, account: account)
+ account.delete
+
+ orphaned_inbox = described_class.find(inbox.id)
+
+ expect { orphaned_inbox.destroy! }.not_to raise_error
+ end
+ end
+
+ describe 'filtered unread count invalidation' do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
+
+ before do
+ account.enable_features!(:unread_count_for_filters)
+ end
+
+ it 'invalidates saved folder snapshots when destroyed' do
+ expect do
+ inbox.destroy!
+ end.to change { store.conversation_version(account.id) }.by(1)
+ end
+ end
+
describe '#add_members' do
let(:inbox) { FactoryBot.create(:inbox) }
diff --git a/spec/models/team_member_spec.rb b/spec/models/team_member_spec.rb
index f37f70425..d57c59996 100644
--- a/spec/models/team_member_spec.rb
+++ b/spec/models/team_member_spec.rb
@@ -1,8 +1,51 @@
require 'rails_helper'
RSpec.describe TeamMember do
+ include ActiveJob::TestHelper
+
describe 'associations' do
it { is_expected.to belong_to(:team) }
it { is_expected.to belong_to(:user) }
end
+
+ describe 'filtered unread count invalidation' do
+ let(:account) { create(:account) }
+ let(:team) { create(:team, account: account) }
+ let(:user) { create(:user) }
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
+
+ before do
+ account.enable_features!(:unread_count_for_filters)
+ end
+
+ it 'invalidates the user built-in filter version when team access is added' do
+ expect do
+ create(:team_member, team: team, user: user)
+ end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
+ end
+
+ it 'invalidates the user built-in filter version when team access is removed' do
+ team_member = create(:team_member, team: team, user: user)
+
+ expect do
+ team_member.destroy!
+ end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
+ end
+
+ it 'invalidates the user built-in filter version when the parent team is removed' do
+ create(:team_member, team: team, user: user)
+
+ expect do
+ perform_enqueued_jobs { team.destroy! }
+ end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
+ end
+
+ it 'invalidates saved filter snapshots when the parent team is removed' do
+ create(:conversation, account: account, team: team)
+
+ expect do
+ perform_enqueued_jobs { team.destroy! }
+ end.to change { store.conversation_version(account.id) }.by(1)
+ end
+ end
end
diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb
index cb55dba61..8272b5925 100644
--- a/spec/models/team_spec.rb
+++ b/spec/models/team_spec.rb
@@ -7,6 +7,31 @@ RSpec.describe Team do
it { is_expected.to have_many(:team_members) }
end
+ describe 'name normalization' do
+ let(:account) { create(:account) }
+
+ it 'downcases the name' do
+ team = create(:team, account: account, name: 'Customer Support')
+ expect(team.name).to eq('customer support')
+ end
+
+ it 'strips control characters and surrounding whitespace' do
+ team = create(:team, account: account, name: " Sales\n")
+ expect(team.name).to eq('sales')
+ end
+
+ it 'removes control characters embedded within the name' do
+ team = create(:team, account: account, name: "su\npport")
+ expect(team.name).to eq('support')
+ end
+
+ it 'is invalid when the name reduces to blank after sanitization' do
+ team = build(:team, account: account, name: "\t\n ")
+ expect(team).not_to be_valid
+ expect(team.errors[:name]).to include(I18n.t('errors.validations.presence'))
+ end
+ end
+
describe '#add_members' do
let(:team) { FactoryBot.create(:team) }
diff --git a/spec/requests/api/v1/accounts/data_imports_spec.rb b/spec/requests/api/v1/accounts/data_imports_spec.rb
new file mode 100644
index 000000000..82a298ef7
--- /dev/null
+++ b/spec/requests/api/v1/accounts/data_imports_spec.rb
@@ -0,0 +1,438 @@
+require 'rails_helper'
+
+RSpec.describe 'Data Imports API', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:validator) { instance_double(DataImports::Intercom::CredentialsValidator, perform: { 'contacts' => 12, 'conversations' => 8 }) }
+
+ before do
+ account.enable_features!('data_import')
+ allow(DataImports::Intercom::CredentialsValidator).to receive(:new).and_return(validator)
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/data_imports/validate_source' do
+ it 'validates the selected Intercom source and returns discovered totals' do
+ post validate_source_api_v1_account_data_imports_url(account_id: account.id),
+ params: {
+ source_provider: 'intercom', access_token: 'intercom-token', import_types: %w[contacts conversations]
+ },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body).to eq('valid' => true, 'totals' => { 'contacts' => 12, 'conversations' => 8 })
+ end
+
+ it 'returns a safe validation error' do
+ allow(validator).to receive(:perform).and_raise(DataImports::Intercom::Client::AuthenticationError, 'provider response')
+
+ post validate_source_api_v1_account_data_imports_url(account_id: account.id),
+ params: { source_provider: 'intercom', access_token: 'invalid', import_types: %w[contacts] },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body).to eq(
+ 'valid' => false,
+ 'message' => 'We could not validate this Intercom access key. Check the key and its permissions.'
+ )
+ end
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/data_imports' do
+ it 'returns unauthorized and does not enqueue imports when data import is disabled' do
+ account.disable_features!('data_import')
+
+ expect do
+ post api_v1_account_data_imports_url(account_id: account.id),
+ params: {
+ name: 'Migration run', source_provider: 'intercom', access_token: 'intercom-token',
+ import_types: %w[contacts conversations]
+ },
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(account.data_imports).to be_empty
+ end
+
+ it 'creates and enqueues an Intercom import', :aggregate_failures do
+ expect do
+ post api_v1_account_data_imports_url(account_id: account.id),
+ params: {
+ name: 'Migration run', source_provider: 'intercom', access_token: 'intercom-token',
+ import_types: %w[contacts conversations]
+ },
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.to have_enqueued_job(DataImports::Intercom::ImportJob)
+
+ expect(response).to have_http_status(:ok)
+ data_import = account.data_imports.last
+ expect(data_import).to have_attributes(
+ name: 'Migration run',
+ data_type: 'intercom',
+ source_type: 'api',
+ source_provider: 'intercom',
+ initiated_by_id: admin.id
+ )
+ expect(data_import.access_token).to eq('intercom-token')
+ expect(data_import.import_types).to eq(%w[contacts conversations])
+ expect(data_import.stats).to include(
+ 'contacts' => include('total' => 12),
+ 'conversations' => include('total' => 8)
+ )
+ expect(response.parsed_body['source_provider']).to eq('intercom')
+ expect(response.parsed_body).not_to have_key('access_token')
+ end
+
+ it 'rejects creation while another Intercom import is active' do
+ active_import = create(
+ :data_import, :intercom,
+ account: account,
+ status: :processing
+ )
+
+ expect do
+ post api_v1_account_data_imports_url(account_id: account.id),
+ params: {
+ name: 'Second run', source_provider: 'intercom', access_token: 'intercom-token',
+ import_types: %w[contacts conversations]
+ },
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['message']).to eq('Another data import is already in progress.')
+ expect(account.data_imports.where(data_type: 'intercom', source_provider: 'intercom').count).to eq(1)
+ expect(active_import.reload).to be_processing
+ end
+
+ it 'rejects unsupported import types instead of silently importing everything' do
+ allow(validator).to receive(:perform).and_raise(ArgumentError, 'Unsupported import types: companies')
+
+ expect do
+ post api_v1_account_data_imports_url(account_id: account.id),
+ params: {
+ name: 'Migration run', source_provider: 'intercom', access_token: 'intercom-token', import_types: %w[companies]
+ },
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['message']).to eq('Unsupported import types: companies')
+ expect(account.data_imports).to be_empty
+ end
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/data_imports/:id/start' do
+ let(:data_import) { create(:data_import, :intercom, account: account) }
+
+ it 'restarts abandoned imports' do
+ data_import.update!(
+ status: :abandoned,
+ abandoned_at: 1.hour.ago,
+ source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'previous-run' }
+ )
+ data_import.import_errors.create!(
+ error_code: 'StandardError',
+ message: 'old run error',
+ details: { kind: 'run_error' }
+ )
+ data_import.import_errors.create!(
+ error_code: DataImports::Intercom::Importer::ALREADY_IMPORTED_ERROR_CODE,
+ message: 'old skip log',
+ details: { kind: 'skipped' }
+ )
+
+ expect do
+ post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.to have_enqueued_job(DataImports::Intercom::ImportJob).with(data_import, a_kind_of(String))
+
+ expect(response).to have_http_status(:ok)
+ expect(data_import.reload).to be_pending
+ expect(data_import.abandoned_at).to be_nil
+ expect(data_import.started_at).to be_nil
+ expect(data_import.active_intercom_import_run_id).not_to eq('previous-run')
+ expect(data_import.import_errors.pluck(:error_code)).to eq([DataImports::Intercom::Importer::ALREADY_IMPORTED_ERROR_CODE])
+ end
+
+ it 'does not enqueue duplicate jobs for active imports' do
+ data_import.update!(status: :processing)
+
+ expect do
+ post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
+
+ expect(response).to have_http_status(:ok)
+ expect(data_import.reload).to be_processing
+ end
+
+ it 'returns the active Intercom import instead of restarting another import' do
+ data_import.update!(status: :abandoned, abandoned_at: 1.hour.ago)
+ active_import = create(
+ :data_import, :intercom,
+ account: account,
+ status: :processing
+ )
+
+ expect do
+ post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['id']).to eq(active_import.id)
+ expect(data_import.reload).to be_abandoned
+ end
+
+ it 'does not restart imports when the stored access key is unavailable' do
+ data_import.update!(status: :abandoned, abandoned_at: 1.hour.ago, access_token: nil)
+
+ expect do
+ post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['message']).to eq('The Intercom access key for this import is unavailable.')
+ expect(data_import.reload).to be_abandoned
+ end
+ end
+
+ describe 'POST /api/v1/accounts/:account_id/data_imports/:id/abandon' do
+ let(:data_import) { create(:data_import, :intercom, account: account) }
+
+ it 'abandons active imports' do
+ data_import.update!(status: :processing)
+
+ post abandon_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(data_import.reload).to be_abandoned
+ expect(data_import.abandoned_at).to be_present
+ end
+
+ it 'does not rewrite completed imports as abandoned' do
+ data_import.update!(status: :completed, completed_at: 1.hour.ago)
+
+ post abandon_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(data_import.reload).to be_completed
+ expect(data_import.abandoned_at).to be_nil
+ end
+
+ it 'does not abandon legacy contact imports' do
+ legacy_import = create(:data_import, account: account, status: :processing)
+
+ post abandon_api_v1_account_data_import_url(account_id: account.id, id: legacy_import.id),
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(legacy_import.reload).to be_processing
+ expect(legacy_import.abandoned_at).to be_nil
+ end
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/data_imports/:id' do
+ let(:data_import) do
+ create(
+ :data_import, :intercom,
+ account: account,
+ name: 'July Intercom migration',
+ initiated_by: admin
+ )
+ end
+
+ it 'returns import details with recent errors' do
+ data_import.import_errors.create!(
+ error_code: 'Intercom::RateLimited',
+ message: 'Rate limited',
+ source_object_type: 'conversation',
+ source_object_id: 'conversation_1'
+ )
+ data_import.import_errors.create!(
+ error_code: 'DataImports::Intercom::SkippedMessage',
+ message: 'Skipped blank message',
+ source_object_type: 'message',
+ source_object_id: 'conversation:conversation_1:part:blank_part',
+ details: { kind: 'skipped', reason: 'blank_or_unsupported_intercom_part' }
+ )
+
+ get api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body).to include(
+ 'id' => data_import.id,
+ 'name' => 'July Intercom migration',
+ 'source_provider' => 'intercom',
+ 'import_errors_count' => 1,
+ 'skip_logs_count' => 1
+ )
+ expect(response.parsed_body['import_errors'].first).to include(
+ 'error_code' => 'Intercom::RateLimited',
+ 'message' => 'Rate limited',
+ 'source_object_type' => 'conversation',
+ 'source_object_id' => 'conversation_1'
+ )
+ expect(response.parsed_body['skip_logs'].first).to include(
+ 'kind' => 'skipped',
+ 'error_code' => 'DataImports::Intercom::SkippedMessage',
+ 'source_object_type' => 'message',
+ 'source_object_id' => 'conversation:conversation_1:part:blank_part'
+ )
+ end
+
+ it 'returns the latest five skip logs' do
+ 16.times do |index|
+ data_import.import_errors.create!(
+ error_code: 'DataImports::Intercom::AlreadyImported',
+ message: 'Already imported in a previous import.',
+ source_object_type: 'message',
+ source_object_id: "message_#{index}",
+ details: { kind: 'skipped', reason: 'already_imported' },
+ created_at: Time.zone.at(index)
+ )
+ end
+
+ get api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['skip_logs'].pluck('source_object_id')).to eq(%w[message_15 message_14 message_13 message_12 message_11])
+ expect(response.parsed_body).not_to have_key('skip_logs_pagination')
+ end
+
+ it 'filters skip logs by source object type with counts for each type' do
+ 3.times do |index|
+ data_import.import_errors.create!(
+ error_code: 'DataImports::Intercom::AlreadyImported',
+ message: 'Already imported in a previous import.',
+ source_object_type: 'contact',
+ source_object_id: "contact_#{index}",
+ details: { kind: 'skipped', reason: 'already_imported' }
+ )
+ end
+ 2.times do |index|
+ data_import.import_errors.create!(
+ error_code: 'DataImports::Intercom::AlreadyImported',
+ message: 'Already imported in a previous import.',
+ source_object_type: 'message',
+ source_object_id: "message_#{index}",
+ details: { kind: 'skipped', reason: 'already_imported' }
+ )
+ end
+
+ get api_v1_account_data_import_url(account_id: account.id, id: data_import.id, skip_logs_type: 'contact'),
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['skip_logs'].pluck('source_object_type').uniq).to eq(['contact'])
+ expect(response.parsed_body['skip_logs_filters']).to include(
+ 'selected_source_object_type' => 'contact',
+ 'counts_by_type' => include('contact' => 3, 'message' => 2)
+ )
+ end
+
+ it 'returns the latest five error logs' do
+ 16.times do |index|
+ data_import.import_errors.create!(
+ error_code: 'Intercom::RateLimited',
+ message: 'Rate limited',
+ source_object_type: 'conversation',
+ source_object_id: "conversation_#{index}",
+ created_at: Time.zone.at(index)
+ )
+ end
+
+ get api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body['import_errors'].pluck('source_object_id')).to eq(
+ %w[conversation_15 conversation_14 conversation_13 conversation_12 conversation_11]
+ )
+ expect(response.parsed_body).not_to have_key('import_errors_pagination')
+ end
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/data_imports/:id/error_logs.csv' do
+ let(:data_import) do
+ create(
+ :data_import, :intercom,
+ account: account,
+ initiated_by: admin
+ )
+ end
+
+ it 'downloads all error logs as CSV' do
+ 6.times do |index|
+ data_import.import_errors.create!(
+ error_code: 'Intercom::RateLimited',
+ message: 'Rate limited',
+ source_object_type: 'conversation',
+ source_object_id: "conversation_#{index}",
+ details: { kind: 'run_error' }
+ )
+ end
+
+ get error_logs_api_v1_account_data_import_url(account_id: account.id, id: data_import.id, format: :csv),
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq('text/csv')
+ expect(response.body).to include('source_object_type,source_object_id')
+ expect(response.body).to include('conversation,conversation_0,Intercom::RateLimited,Rate limited')
+ expect(response.body).to include('conversation,conversation_5,Intercom::RateLimited,Rate limited')
+ expect(response.body.lines.size).to eq(7)
+ end
+ end
+
+ describe 'GET /api/v1/accounts/:account_id/data_imports/:id/skip_logs.csv' do
+ let(:data_import) do
+ create(
+ :data_import, :intercom,
+ account: account,
+ initiated_by: admin
+ )
+ end
+
+ it 'downloads skip logs as CSV' do
+ data_import.import_errors.create!(
+ error_code: 'DataImports::Intercom::SkippedMessage',
+ message: 'Skipped blank message',
+ source_object_type: 'message',
+ source_object_id: 'conversation:conversation_1:part:blank_part',
+ details: { kind: 'skipped', reason: 'blank_or_unsupported_intercom_part' }
+ )
+
+ get skip_logs_api_v1_account_data_import_url(account_id: account.id, id: data_import.id, format: :csv),
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq('text/csv')
+ expect(response.body).to include('source_object_type,source_object_id')
+ expect(response.body).to include('message,conversation:conversation_1:part:blank_part')
+ end
+ end
+end
diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb
index 75dfaa532..44bbf89b1 100644
--- a/spec/services/auto_assignment/assignment_service_spec.rb
+++ b/spec/services/auto_assignment/assignment_service_spec.rb
@@ -239,6 +239,24 @@ RSpec.describe AutoAssignment::AssignmentService do
expect(assigned_count).to eq(1)
expect(old_conversation.reload.assignee).to eq(agent)
end
+
+ context 'when the inbox has no assignment policy' do
+ before do
+ inbox.inbox_assignment_policy.destroy!
+ inbox.reload
+ end
+
+ it 'falls back to the default threshold and skips stale conversations' do
+ stale_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 8.days.ago)
+ recent_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 6.days.ago)
+
+ assigned_count = service.perform_bulk_assignment(limit: 10)
+
+ expect(assigned_count).to eq(1)
+ expect(stale_conversation.reload.assignee).to be_nil
+ expect(recent_conversation.reload.assignee).to eq(agent)
+ end
+ end
end
context 'with fair distribution' do
diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb
index fa054b330..eecbaec2e 100644
--- a/spec/services/conversations/filter_service_spec.rb
+++ b/spec/services/conversations/filter_service_spec.rb
@@ -231,6 +231,24 @@ describe Conversations::FilterService do
expect(result[:count][:all_count]).to be 2
end
+ it 'filters conversations by display_id substring' do
+ conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1)
+ create(:conversation, account: account, inbox: inbox, assignee: user_1)
+
+ params[:payload] = [{
+ attribute_key: 'display_id',
+ filter_operator: 'contains',
+ values: [conversation.display_id.to_s],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access]
+
+ result = filter_service.new(params, user_1, account).perform
+
+ expect(result[:count][:all_count]).to eq(1)
+ expect(result[:conversations].pluck(:id)).to contain_exactly(conversation.id)
+ end
+
it 'filters items with does not contain filter operator with values being an array' do
params[:payload] = [{
attribute_key: 'browser_language',
diff --git a/spec/services/conversations/unread_counts/counter_spec.rb b/spec/services/conversations/unread_counts/counter_spec.rb
index 723f2d437..50efd7d43 100644
--- a/spec/services/conversations/unread_counts/counter_spec.rb
+++ b/spec/services/conversations/unread_counts/counter_spec.rb
@@ -95,4 +95,22 @@ RSpec.describe Conversations::UnreadCounts::Counter do
teams: { visible_team.id.to_s => 1 }
)
end
+
+ it 'merges filtered counts when the filtered count feature is enabled' do
+ account.enable_features!(:unread_count_for_filters)
+ filtered_counter = instance_double(
+ Conversations::UnreadCounts::FilteredCounter,
+ perform: { mentions_count: 1, participating_count: 2, unattended_count: 3, folders: { '4' => 5 } }
+ )
+ allow(Conversations::UnreadCounts::FilteredCounter).to receive(:new).and_return(filtered_counter)
+
+ result = described_class.new(account: account, user: agent).perform
+
+ expect(result).to include(
+ mentions_count: 1,
+ participating_count: 2,
+ unattended_count: 3,
+ folders: { '4' => 5 }
+ )
+ end
end
diff --git a/spec/services/conversations/unread_counts/filtered_count_instrumentation_spec.rb b/spec/services/conversations/unread_counts/filtered_count_instrumentation_spec.rb
new file mode 100644
index 000000000..4fb6c6d36
--- /dev/null
+++ b/spec/services/conversations/unread_counts/filtered_count_instrumentation_spec.rb
@@ -0,0 +1,137 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::FilteredCountInstrumentation do
+ let(:new_relic_agent) do
+ Class.new do
+ def self.record_custom_event(*) end
+ def self.record_metric(*) end
+ end
+ end
+
+ before do
+ stub_const('NewRelic::Agent', new_relic_agent)
+ allow(new_relic_agent).to receive(:record_custom_event)
+ allow(new_relic_agent).to receive(:record_metric)
+ end
+
+ describe '.observe' do
+ it 'records duration metrics without custom events around successful operations' do
+ result = described_class.observe(:counter_perform, account_id: 1, snapshot_scope: :built_in_filter) { 'ok' }
+
+ expect(result).to eq('ok')
+ expect(new_relic_agent).not_to have_received(:record_custom_event)
+ expect(new_relic_agent).to have_received(:record_metric).with(
+ 'Custom/Conversations/UnreadCounts/Filtered/counter_perform/duration_ms',
+ kind_of(Float)
+ )
+ end
+
+ it 'records failed operations and re-raises the original error' do
+ error = StandardError.new('boom')
+
+ expect do
+ described_class.observe(:snapshot_build, account_id: 1) { raise error }
+ end.to raise_error(error)
+ expect(new_relic_agent).not_to have_received(:record_custom_event)
+ expect(new_relic_agent).to have_received(:record_metric).with(
+ 'Custom/Conversations/UnreadCounts/Filtered/snapshot_build/duration_ms',
+ kind_of(Float)
+ )
+ end
+ end
+
+ describe '.increment' do
+ it 'records count metrics without custom events for aggregated read-path operations' do
+ described_class.increment(:snapshot_state, account_id: 1, snapshot_status: :fresh)
+
+ expect(new_relic_agent).not_to have_received(:record_custom_event)
+ expect(new_relic_agent).to have_received(:record_metric).with(
+ 'Custom/Conversations/UnreadCounts/Filtered/snapshot_state/count',
+ 1
+ )
+ end
+
+ it 'keeps custom events for invalidation signals' do
+ described_class.increment(:invalidation, account_id: 1, invalidation_scope: :conversation)
+
+ expect(new_relic_agent).to have_received(:record_custom_event).with(
+ 'FilteredUnreadCounts',
+ hash_including(
+ account_id: 1,
+ invalidation_scope: 'conversation',
+ operation: 'invalidation'
+ )
+ )
+ expect(new_relic_agent).to have_received(:record_metric).with(
+ 'Custom/Conversations/UnreadCounts/Filtered/invalidation/count',
+ 1
+ )
+ end
+
+ it 'does not raise when New Relic is unavailable' do
+ allow(described_class).to receive(:new_relic_agent).and_return(nil)
+
+ expect { described_class.increment(:snapshot_state, account_id: 1) }.not_to raise_error
+ end
+ end
+
+ describe '.summarize_request' do
+ it 'records one custom event with aggregated request counters' do
+ result = described_class.summarize_request(account_id: 1) do
+ described_class.increment(:snapshot_state, account_id: 1, snapshot_scope: :built_in_filter, snapshot_status: :fresh)
+ described_class.increment(:snapshot_state, account_id: 1, snapshot_scope: :filter, snapshot_status: :missing)
+ described_class.increment(:refresh_claim, account_id: 1, snapshot_scope: :filter, claimed: true)
+ described_class.increment(:refresh_claim, account_id: 1, snapshot_scope: :filter, claimed: false)
+ described_class.increment(:build_lock, account_id: 1, snapshot_scope: :filter, acquired: true)
+ described_class.observe(:snapshot_build, account_id: 1, snapshot_scope: :filter) { 'built' }
+
+ 'ok'
+ end
+
+ expect(result).to eq('ok')
+ expect(new_relic_agent).to have_received(:record_custom_event).once.with(
+ 'FilteredUnreadCounts',
+ hash_including(
+ account_id: 1,
+ build_lock_acquired_count: 1,
+ duration_ms: kind_of(Float),
+ filter_build_lock_acquired_count: 1,
+ filter_refresh_claimed_count: 1,
+ filter_refresh_skipped_count: 1,
+ filter_snapshot_build_success_count: 1,
+ filter_snapshot_count: 1,
+ operation: 'request_summary',
+ refresh_claimed_count: 1,
+ refresh_skipped_count: 1,
+ snapshot_build_success_count: 1,
+ snapshot_fresh_count: 1,
+ snapshot_missing_count: 1,
+ snapshot_total_count: 2,
+ status: 'success'
+ )
+ )
+ expect(new_relic_agent).to have_received(:record_metric).with(
+ 'Custom/Conversations/UnreadCounts/Filtered/api_response/duration_ms',
+ kind_of(Float)
+ )
+ end
+
+ it 'records summary errors and re-raises the original error' do
+ error = StandardError.new('boom')
+
+ expect do
+ described_class.summarize_request(account_id: 1) { raise error }
+ end.to raise_error(error)
+
+ expect(new_relic_agent).to have_received(:record_custom_event).with(
+ 'FilteredUnreadCounts',
+ hash_including(
+ account_id: 1,
+ error_class: 'StandardError',
+ operation: 'request_summary',
+ status: 'error'
+ )
+ )
+ end
+ end
+end
diff --git a/spec/services/conversations/unread_counts/filtered_count_invalidator_spec.rb b/spec/services/conversations/unread_counts/filtered_count_invalidator_spec.rb
new file mode 100644
index 000000000..e0324c4b5
--- /dev/null
+++ b/spec/services/conversations/unread_counts/filtered_count_invalidator_spec.rb
@@ -0,0 +1,244 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::FilteredCountInvalidator do
+ subject(:invalidator) { described_class.new(account) }
+
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:other_user) { create(:user, account: account) }
+ let(:filter_id) { 123 }
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
+
+ after do
+ redis_keys.each { |key| Redis::Alfred.delete(key) }
+ end
+
+ describe '#conversation_changed!' do
+ it 'bumps the account conversation version when the feature is enabled' do
+ account.enable_features!(:unread_count_for_filters)
+
+ expect { invalidator.conversation_changed! }.to change { store.conversation_version(account.id) }.by(1)
+ end
+
+ it 'records invalidation instrumentation when the feature is enabled' do
+ account.enable_features!(:unread_count_for_filters)
+ allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:increment)
+
+ invalidator.conversation_changed!
+
+ expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with(
+ :invalidation,
+ account_id: account.id,
+ invalidation_scope: :conversation,
+ reason: :conversation_changed,
+ version: 1
+ )
+ end
+
+ it 'does not write Redis keys when the feature is disabled' do
+ expect { invalidator.conversation_changed! }.not_to(change { store.conversation_version(account.id) })
+ end
+ end
+
+ describe '#user_visibility_changed!' do
+ it 'bumps the user built-in filter version' do
+ account.enable_features!(:unread_count_for_filters)
+
+ expect do
+ invalidator.user_visibility_changed!(user_id: user.id)
+ end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
+ end
+ end
+
+ describe '#users_visibility_changed!' do
+ it 'pipelines built-in filter version bumps for multiple users' do
+ account.enable_features!(:unread_count_for_filters)
+ user_ids = [user.id, other_user.id]
+ allow(Redis::Alfred).to receive(:pipelined).and_call_original
+
+ expect do
+ invalidator.users_visibility_changed!(user_ids: user_ids + [user.id, nil])
+ end.to change { built_in_filter_version_for(user) }.by(1)
+ .and change { built_in_filter_version_for(other_user) }.by(1)
+
+ expect(Redis::Alfred).to have_received(:pipelined).once
+ end
+
+ it 'does not write Redis keys when no user ids are present' do
+ account.enable_features!(:unread_count_for_filters)
+
+ expect(invalidator.users_visibility_changed!(user_ids: [nil, ''])).to be(false)
+ end
+ end
+
+ describe '#custom_filter_created!' do
+ it 'bumps the folder index and saved filter versions for conversation filters' do
+ account.enable_features!(:unread_count_for_filters)
+ filter_version = store.filter_version(account_id: account.id, filter_id: filter_id)
+
+ expect do
+ invalidator.custom_filter_created!(conversation_filter)
+ end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1)
+ expect(store.filter_version(account_id: account.id, filter_id: filter_id)).to eq(filter_version + 1)
+ end
+
+ it 'ignores non-conversation filters' do
+ account.enable_features!(:unread_count_for_filters)
+
+ expect do
+ invalidator.custom_filter_created!(conversation_filter(is_conversation: false))
+ end.not_to(change { store.folder_index_version(account_id: account.id, user_id: user.id) })
+ end
+ end
+
+ describe '#custom_filter_updated!' do
+ it 'bumps only the filter version when the query changes' do
+ account.enable_features!(:unread_count_for_filters)
+ filter = conversation_filter(previous_changes: { 'query' => [{ status: 'open' }, { status: 'resolved' }] })
+ folder_index_version = store.folder_index_version(account_id: account.id, user_id: user.id)
+
+ expect do
+ invalidator.custom_filter_updated!(filter)
+ end.to change { store.filter_version(account_id: account.id, filter_id: filter_id) }.by(1)
+ expect(store.folder_index_version(account_id: account.id, user_id: user.id)).to eq(folder_index_version)
+ end
+
+ it 'ignores name-only updates' do
+ account.enable_features!(:unread_count_for_filters)
+ filter = conversation_filter(previous_changes: { 'name' => %w[Open Resolved] })
+
+ expect do
+ invalidator.custom_filter_updated!(filter)
+ end.not_to(change { store.filter_version(account_id: account.id, filter_id: filter_id) })
+ end
+
+ it 'bumps versions and deletes the saved count when the filter moves away from conversations' do
+ account.enable_features!(:unread_count_for_filters)
+ filter = conversation_filter(
+ is_conversation: false,
+ previous_changes: { 'filter_type' => %w[conversation contact] }
+ )
+ store.write_filter_count!(
+ account_id: account.id,
+ filter_id: filter_id,
+ user_id: user.id,
+ count: 4,
+ account_version: 0,
+ filter_version: 0,
+ owner_built_in_filter_version: 0
+ )
+ filter_version = store.filter_version(account_id: account.id, filter_id: filter_id)
+
+ expect do
+ invalidator.custom_filter_updated!(filter)
+ end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1)
+ expect(store.filter_version(account_id: account.id, filter_id: filter_id)).to eq(filter_version + 1)
+ expect(store.filter_count(account_id: account.id, filter_id: filter_id)).to be_nil
+ end
+ end
+
+ describe '#custom_filter_destroyed!' do
+ it 'bumps the folder index version and deletes the saved count' do
+ account.enable_features!(:unread_count_for_filters)
+ store.write_filter_count!(
+ account_id: account.id,
+ filter_id: filter_id,
+ user_id: user.id,
+ count: 2,
+ account_version: 0,
+ filter_version: 0,
+ owner_built_in_filter_version: 0
+ )
+
+ expect do
+ invalidator.custom_filter_destroyed!(conversation_filter)
+ end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1)
+ expect(store.filter_count(account_id: account.id, filter_id: filter_id)).to be_nil
+ end
+ end
+
+ describe '#custom_attribute_definition_changed!' do
+ it 'bumps affected conversation saved filter versions' do
+ account.enable_features!(:unread_count_for_filters)
+ definition = create(:custom_attribute_definition, account: account, attribute_key: 'plan', attribute_model: 'conversation_attribute')
+ matching_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan'))
+ blank_type_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan', ''))
+ contact_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan', 'contact_attribute'))
+ other_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('tier'))
+ versions = filter_versions(matching_filter, blank_type_filter, contact_filter, other_filter)
+
+ invalidator.custom_attribute_definition_changed!(definition)
+
+ expect(store.filter_version(account_id: account.id, filter_id: matching_filter.id)).to eq(versions[matching_filter.id] + 1)
+ expect(store.filter_version(account_id: account.id, filter_id: blank_type_filter.id)).to eq(versions[blank_type_filter.id] + 1)
+ expect(store.filter_version(account_id: account.id, filter_id: contact_filter.id)).to eq(versions[contact_filter.id])
+ expect(store.filter_version(account_id: account.id, filter_id: other_filter.id)).to eq(versions[other_filter.id])
+ end
+
+ it 'bumps filters referencing the previous attribute key when the key changes' do
+ definition = create(:custom_attribute_definition, account: account, attribute_key: 'plan', attribute_model: 'conversation_attribute')
+ matching_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan'))
+ version = store.filter_version(account_id: account.id, filter_id: matching_filter.id)
+
+ definition.update!(attribute_key: 'new_plan')
+ account.enable_features!(:unread_count_for_filters)
+
+ expect do
+ invalidator.custom_attribute_definition_changed!(definition)
+ end.to change { store.filter_version(account_id: account.id, filter_id: matching_filter.id) }.from(version).to(version + 1)
+ end
+ end
+
+ def conversation_filter(is_conversation: true, previous_changes: {})
+ instance_double(
+ CustomFilter,
+ id: filter_id,
+ user_id: user.id,
+ conversation?: is_conversation,
+ previous_changes: previous_changes
+ )
+ end
+
+ def custom_attribute_query(attribute_key, custom_attribute_type = 'conversation_attribute')
+ {
+ payload: [
+ {
+ attribute_key: attribute_key,
+ filter_operator: 'equal_to',
+ values: ['gold'],
+ custom_attribute_type: custom_attribute_type
+ }
+ ]
+ }
+ end
+
+ def filter_versions(*custom_filters)
+ custom_filters.to_h { |custom_filter| [custom_filter.id, store.filter_version(account_id: account.id, filter_id: custom_filter.id)] }
+ end
+
+ def built_in_filter_version_for(user)
+ store.built_in_filter_version(account_id: account.id, user_id: user.id)
+ end
+
+ def redis_keys
+ base_redis_keys + custom_filter_version_keys
+ end
+
+ def base_redis_keys
+ [
+ store.conversation_version_key(account.id),
+ *built_in_filter_version_keys,
+ store.folder_index_version_key(account.id, user.id),
+ store.filter_version_key(account.id, filter_id),
+ store.filter_count_key(account.id, filter_id)
+ ]
+ end
+
+ def built_in_filter_version_keys
+ [user.id, other_user.id].map { |user_id| store.built_in_filter_version_key(account.id, user_id) }
+ end
+
+ def custom_filter_version_keys
+ CustomFilter.where(account_id: account.id).pluck(:id).map { |id| store.filter_version_key(account.id, id) }
+ end
+end
diff --git a/spec/services/conversations/unread_counts/filtered_count_store_spec.rb b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb
new file mode 100644
index 000000000..3a7cd52d8
--- /dev/null
+++ b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb
@@ -0,0 +1,279 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
+ let(:account_id) { 1 }
+ let(:user_id) { 2 }
+ let(:filter_id) { 3 }
+ let(:built_at) { Time.zone.parse('2026-06-29 10:00:00 UTC') }
+
+ after do
+ redis_keys.each { |key| Redis::Alfred.delete(key) }
+ end
+
+ describe 'key builders' do
+ it 'builds V2 keys for built-in filters, folder indexes, and saved filters' do
+ expect(described_class.conversation_version_key(account_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::CONVERSATION_VERSION'
+ )
+ expect(described_class.built_in_filter_version_key(account_id, user_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::USER::2::BUILT_IN_FILTER_VERSION'
+ )
+ expect(described_class.built_in_filter_counts_key(account_id, user_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::USER::2::BUILT_IN_FILTER_COUNTS'
+ )
+ expect(described_class.folder_index_key(account_id, user_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::USER::2::FOLDER_INDEX'
+ )
+ expect(described_class.filter_count_key(account_id, filter_id)).to eq(
+ 'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::FILTER::3::COUNT'
+ )
+ end
+ end
+
+ describe 'version metadata' do
+ it 'defaults missing version keys to zero' do
+ expect(described_class.conversation_version(account_id)).to eq(0)
+ expect(described_class.built_in_filter_version(account_id: account_id, user_id: user_id)).to eq(0)
+ expect(described_class.folder_index_version(account_id: account_id, user_id: user_id)).to eq(0)
+ expect(described_class.filter_version(account_id: account_id, filter_id: filter_id)).to eq(0)
+ end
+
+ it 'increments independent version keys' do
+ expect(described_class.bump_conversation_version!(account_id)).to eq(1)
+ expect(described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)).to eq(1)
+ expect(described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id)).to eq(1)
+ expect(described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)).to eq(1)
+ end
+
+ it 'increments and expires version keys in one Redis transaction' do
+ key = described_class.conversation_version_key(account_id)
+ connection = instance_double(Redis)
+ transaction = instance_double(Redis::MultiConnection)
+
+ allow(Redis::Alfred).to receive(:with).and_yield(connection)
+ expect(connection).to receive(:multi).and_yield(transaction).and_return([1, true])
+ expect(transaction).to receive(:incr).with(key)
+ expect(transaction).to receive(:expire).with(key, Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL)
+
+ expect(described_class.bump_conversation_version!(account_id)).to eq(1)
+ end
+
+ it 'expires version keys after bumping them' do
+ described_class.bump_conversation_version!(account_id)
+ described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
+ described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id)
+ described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)
+
+ version_keys.each do |key|
+ expect(ttl_for(key)).to be_within(5).of(Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL)
+ end
+ end
+ end
+
+ describe 'built-in filter count snapshots' do
+ it 'round-trips counts and classifies fresh, stale, expired, and missing snapshots' do
+ account_version = described_class.bump_conversation_version!(account_id)
+ built_in_filter_version = described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
+
+ described_class.write_built_in_filter_counts!(
+ account_id: account_id,
+ user_id: user_id,
+ account_version: account_version,
+ built_in_filter_version: built_in_filter_version,
+ built_at: built_at,
+ counts: { mentions_count: 3, participating_count: 4, unattended_count: 5 },
+ meta: { permission_mode: 'base' }
+ )
+
+ snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id)
+ expect(snapshot[:counts]).to eq(mentions_count: 3, participating_count: 4, unattended_count: 5)
+ expect(snapshot[:meta]).to eq(permission_mode: 'base')
+ expect(ttl_for(described_class.built_in_filter_counts_key(account_id, user_id))).to be_within(5).of(
+ Conversations::UnreadCounts::FILTERED_COUNT_REDIS_TTL
+ )
+
+ expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 1.minute)).to be_fresh
+
+ described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
+ expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale
+ expect(
+ described_class.built_in_filter_counts_state(
+ account_id: account_id,
+ user_id: user_id,
+ now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL +
+ Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW + 1.second
+ )
+ ).to be_expired
+
+ Redis::Alfred.delete(described_class.built_in_filter_counts_key(account_id, user_id))
+ expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id)).to be_missing
+ end
+ end
+
+ describe 'folder index snapshots' do
+ it 'round-trips folder ids and classifies freshness against the folder index version' do
+ folder_index_version = described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id)
+
+ described_class.write_folder_index!(
+ account_id: account_id,
+ user_id: user_id,
+ folder_index_version: folder_index_version,
+ built_at: built_at,
+ filter_ids: %w[10 11]
+ )
+
+ expect(described_class.folder_index(account_id: account_id, user_id: user_id)[:filter_ids]).to eq([10, 11])
+ expect(described_class.folder_index_state(account_id: account_id, user_id: user_id, now: built_at + 1.minute)).to be_fresh
+
+ described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id)
+ expect(described_class.folder_index_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale
+ end
+ end
+
+ describe 'saved filter count snapshots' do
+ it 'round-trips counts and uses account, filter, and owner built-in filter versions for freshness' do
+ account_version = described_class.bump_conversation_version!(account_id)
+ filter_version = described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)
+ owner_built_in_filter_version = described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
+
+ described_class.write_filter_count!(
+ account_id: account_id,
+ filter_id: filter_id,
+ user_id: user_id,
+ count: 7,
+ account_version: account_version,
+ filter_version: filter_version,
+ owner_built_in_filter_version: owner_built_in_filter_version,
+ built_at: built_at,
+ meta: { status: 'ok', timed_out: false, invalid_filter: false }
+ )
+
+ snapshot = described_class.filter_count(account_id: account_id, filter_id: filter_id)
+ expect(snapshot[:count]).to eq(7)
+ expect(snapshot[:meta]).to eq(status: 'ok', timed_out: false, invalid_filter: false)
+ expect(described_class.filter_count_state(account_id: account_id, filter_id: filter_id, now: built_at + 1.minute)).to be_fresh
+
+ described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)
+ expect(described_class.filter_count_state(account_id: account_id, filter_id: filter_id, now: built_at + 2.minutes)).to be_stale
+
+ described_class.delete_filter_count!(account_id: account_id, filter_id: filter_id)
+ expect(described_class.filter_count(account_id: account_id, filter_id: filter_id)).to be_nil
+ end
+
+ it 'uses caller-provided versions when classifying snapshots' do
+ account_version = described_class.bump_conversation_version!(account_id)
+ filter_version = described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)
+ owner_built_in_filter_version = described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
+
+ described_class.write_filter_count!(
+ account_id: account_id,
+ filter_id: filter_id,
+ user_id: user_id,
+ count: 7,
+ account_version: account_version,
+ filter_version: filter_version,
+ owner_built_in_filter_version: owner_built_in_filter_version,
+ built_at: built_at
+ )
+
+ versions = {
+ account_version: account_version,
+ filter_version: filter_version,
+ owner_built_in_filter_version: owner_built_in_filter_version
+ }
+
+ expect(described_class).not_to receive(:conversation_version)
+ expect(described_class).not_to receive(:filter_version)
+ expect(described_class).not_to receive(:built_in_filter_version)
+
+ expect(
+ described_class.filter_count_state(
+ account_id: account_id,
+ filter_id: filter_id,
+ versions: versions,
+ now: built_at + 1.minute
+ )
+ ).to be_fresh
+ end
+ end
+
+ describe 'refresh throttles' do
+ it 'uses refresh_after and independent throttle keys to suppress duplicate rebuilds' do
+ described_class.write_built_in_filter_counts!(
+ account_id: account_id,
+ user_id: user_id,
+ account_version: 0,
+ built_in_filter_version: 0,
+ built_at: built_at,
+ counts: {}
+ )
+
+ snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id)
+ expect(
+ described_class.refresh_due?(
+ snapshot,
+ now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
+ )
+ ).to be(false)
+ expect(
+ described_class.refresh_due?(
+ snapshot,
+ now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
+ )
+ ).to be(true)
+
+ expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(true)
+ expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(false)
+ expect(described_class.claim_folder_index_refresh!(account_id: account_id, user_id: user_id)).to be(true)
+ expect(described_class.claim_filter_refresh!(account_id: account_id, filter_id: filter_id)).to be(true)
+ end
+ end
+
+ describe 'Redis access pattern' do
+ it 'does not scan Redis keys' do
+ expect(Redis::Alfred).not_to receive(:scan_each)
+
+ described_class.bump_conversation_version!(account_id)
+ described_class.write_folder_index!(account_id: account_id, user_id: user_id, folder_index_version: 0, filter_ids: [filter_id])
+ described_class.folder_index_state(account_id: account_id, user_id: user_id)
+ described_class.claim_filter_refresh!(account_id: account_id, filter_id: filter_id)
+ described_class.delete_filter_count!(account_id: account_id, filter_id: filter_id)
+ end
+ end
+
+ def ttl_for(key)
+ Redis::Alfred.ttl(key)
+ end
+
+ def redis_keys
+ version_keys + snapshot_keys + lock_and_throttle_keys
+ end
+
+ def version_keys
+ [
+ described_class.conversation_version_key(account_id),
+ described_class.built_in_filter_version_key(account_id, user_id),
+ described_class.folder_index_version_key(account_id, user_id),
+ described_class.filter_version_key(account_id, filter_id)
+ ]
+ end
+
+ def snapshot_keys
+ [
+ described_class.built_in_filter_counts_key(account_id, user_id),
+ described_class.folder_index_key(account_id, user_id),
+ described_class.filter_count_key(account_id, filter_id)
+ ]
+ end
+
+ def lock_and_throttle_keys
+ [
+ described_class.built_in_filter_build_lock_key(account_id, user_id),
+ described_class.built_in_filter_refresh_throttle_key(account_id, user_id),
+ described_class.folder_index_build_lock_key(account_id, user_id),
+ described_class.folder_index_refresh_throttle_key(account_id, user_id),
+ described_class.filter_build_lock_key(account_id, filter_id),
+ described_class.filter_refresh_throttle_key(account_id, filter_id)
+ ]
+ end
+end
diff --git a/spec/services/conversations/unread_counts/filtered_counter_spec.rb b/spec/services/conversations/unread_counts/filtered_counter_spec.rb
new file mode 100644
index 000000000..2904d1e4e
--- /dev/null
+++ b/spec/services/conversations/unread_counts/filtered_counter_spec.rb
@@ -0,0 +1,565 @@
+require 'rails_helper'
+
+RSpec.describe Conversations::UnreadCounts::FilteredCounter do
+ subject(:counter) { described_class.new(account: account, user: agent, now: now) }
+
+ let(:account) { create(:account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:visible_inbox) { create(:inbox, account: account) }
+ let(:hidden_inbox) { create(:inbox, account: account) }
+ let(:now) { Time.zone.parse('2026-06-29 10:00:00 UTC') }
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
+
+ before do
+ create(:inbox_member, user: agent, inbox: visible_inbox)
+ end
+
+ after do
+ redis_keys.each { |key| Redis::Alfred.delete(key) }
+ end
+
+ it 'builds built-in filter counts from unread open conversations visible to the user' do
+ mentioned = create_visible_unread_conversation
+ participating = create_visible_unread_conversation
+ create_visible_unread_conversation(unattended: true)
+ hidden_mention = create_unread_conversation(account: account, inbox: hidden_inbox)
+ resolved_mention = create_visible_unread_conversation(status: :resolved)
+ read_mention = create_visible_unread_conversation(agent_last_seen_at: 1.minute.from_now)
+
+ [mentioned, hidden_mention, resolved_mention, read_mention].each do |conversation|
+ create(:mention, account: account, conversation: conversation, user: agent)
+ end
+ create(:conversation_participant, account: account, conversation: participating, user: agent)
+
+ expect(counter.perform).to include(
+ mentions_count: 1,
+ participating_count: 1,
+ unattended_count: 1
+ )
+ end
+
+ it 'returns stale built-in counts until the refresh interval elapses' do
+ mentioned = create_visible_unread_conversation
+ create(:mention, account: account, conversation: mentioned, user: agent)
+
+ expect(counter.perform[:mentions_count]).to eq(1)
+
+ second_mention = create_visible_unread_conversation
+ create(:mention, account: account, conversation: second_mention, user: agent)
+ store.bump_conversation_version!(account.id)
+
+ expect(
+ described_class.new(
+ account: account,
+ user: agent,
+ now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
+ ).perform[:mentions_count]
+ ).to eq(1)
+
+ Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
+ expect(
+ described_class.new(
+ account: account,
+ user: agent,
+ now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
+ ).perform[:mentions_count]
+ ).to eq(2)
+ end
+
+ it 'returns stale built-in counts when a refresh build hits a database error' do
+ mentioned = create_visible_unread_conversation
+ create(:mention, account: account, conversation: mentioned, user: agent)
+
+ expect(counter.perform[:mentions_count]).to eq(1)
+
+ store.bump_conversation_version!(account.id)
+ Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
+ failing_counter = described_class.new(
+ account: account,
+ user: agent,
+ now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
+ )
+ allow(failing_counter).to receive(:built_in_counts_from_database).and_raise(ActiveRecord::StatementInvalid.new('statement timeout'))
+
+ expect(failing_counter.perform[:mentions_count]).to eq(1)
+ end
+
+ it 'tags built-in snapshots with versions captured before the DB read' do
+ race_counter = described_class.new(account: account, user: agent, now: now)
+ allow(race_counter).to receive(:built_in_counts_from_database) do
+ store.bump_conversation_version!(account.id)
+ { mentions_count: 1, participating_count: 0, unattended_count: 0 }
+ end
+
+ race_counter.perform
+
+ snapshot = store.built_in_filter_counts(account_id: account.id, user_id: agent.id)
+ expect(snapshot[:account_version]).to eq(0)
+ expect(store.built_in_filter_counts_state(account_id: account.id, user_id: agent.id, now: now)).to be_stale
+ end
+
+ it 'tags folder indexes with versions captured before the DB read' do
+ race_counter = described_class.new(account: account, user: agent, now: now)
+ allow(race_counter).to receive(:folder_filter_ids_from_database) do
+ store.bump_folder_index_version!(account_id: account.id, user_id: agent.id)
+ []
+ end
+
+ race_counter.send(:build_folder_index!, race_counter.send(:version_cache).folder_index)
+
+ snapshot = store.folder_index(account_id: account.id, user_id: agent.id)
+ expect(snapshot[:folder_index_version]).to eq(0)
+ expect(store.folder_index_state(account_id: account.id, user_id: agent.id, now: now)).to be_stale
+ end
+
+ it 'builds saved folder counts from unread conversations matching the saved filter query' do
+ resolved = create_visible_unread_conversation(status: :resolved)
+ create_visible_unread_conversation(status: :open)
+ hidden_resolved = create_unread_conversation(account: account, inbox: hidden_inbox)
+ hidden_resolved.update!(status: :resolved)
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'status', values: ['resolved'])
+ )
+
+ expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 1)
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(1)
+ expect(resolved.reload.status).to eq('resolved')
+ end
+
+ it 'caps inline saved filter builds per request' do
+ create_visible_unread_conversation(status: :open)
+ max_inline_filter_builds = Conversations::UnreadCounts::MAX_INLINE_FILTER_BUILDS
+ custom_filters = Array.new(max_inline_filter_builds + 1) do
+ create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'status', values: ['open'])
+ )
+ end
+ query_counter = instance_double(Conversations::UnreadCounts::FilterQueryCounter, perform: 1)
+ allow(Conversations::UnreadCounts::FilterQueryCounter).to receive(:new).and_return(query_counter)
+
+ result = counter.perform
+
+ expect(result[:folders].size).to eq(max_inline_filter_builds)
+ expect(Conversations::UnreadCounts::FilterQueryCounter).to have_received(:new).exactly(max_inline_filter_builds).times
+ expect(custom_filters.count { |custom_filter| store.filter_count(account_id: account.id, filter_id: custom_filter.id).present? }).to eq(
+ max_inline_filter_builds
+ )
+ end
+
+ it 'reuses shared versions while resolving multiple saved filters' do
+ create_visible_unread_conversation(status: :open)
+ 2.times do
+ create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'status', values: ['open'])
+ )
+ end
+ query_counter = instance_double(Conversations::UnreadCounts::FilterQueryCounter, perform: 1)
+ allow(Conversations::UnreadCounts::FilterQueryCounter).to receive(:new).and_return(query_counter)
+ expect(store).to receive(:conversation_version).with(account.id).once.and_call_original
+ expect(store).to receive(:built_in_filter_version).with(account_id: account.id, user_id: agent.id).once.and_call_original
+
+ expect(counter.perform[:folders].size).to eq(2)
+ end
+
+ it 'tags saved filter counts with versions captured before the DB read' do
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'status', values: ['open'])
+ )
+ race_counter = described_class.new(account: account, user: agent, now: now)
+ allow(race_counter).to receive(:filter_query_count) do
+ store.bump_filter_version!(account_id: account.id, filter_id: custom_filter.id)
+ 1
+ end
+
+ race_counter.send(:build_filter_count!, custom_filter.id, race_counter.send(:version_cache).filter(custom_filter.id))
+
+ snapshot = store.filter_count(account_id: account.id, filter_id: custom_filter.id)
+ expect(snapshot[:filter_version]).to eq(0)
+ expect(store.filter_count_state(account_id: account.id, filter_id: custom_filter.id, owner_user_id: agent.id, now: now)).to be_stale
+ end
+
+ it 'tags saved filter counts with versions captured before loading the filter row' do
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'status', values: ['open'])
+ )
+ filters = account.custom_filters
+ allow(account).to receive(:custom_filters).and_return(filters)
+ allow(filters).to receive(:find_by) do
+ store.bump_filter_version!(account_id: account.id, filter_id: custom_filter.id)
+ custom_filter
+ end
+
+ counter.send(:build_filter_count!, custom_filter.id, counter.send(:version_cache).filter(custom_filter.id))
+
+ snapshot = store.filter_count(account_id: account.id, filter_id: custom_filter.id)
+ expect(snapshot[:filter_version]).to eq(0)
+ expect(store.filter_count_state(account_id: account.id, filter_id: custom_filter.id, owner_user_id: agent.id, now: now)).to be_stale
+ end
+
+ it 'omits invalid saved folders without writing a badge count' do
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'unknown_attribute', values: ['value'])
+ )
+
+ expect(counter.perform[:folders]).to eq({})
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+
+ it 'records snapshot lifecycle instrumentation while calculating counts' do
+ allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:observe) do |_operation, _attributes, &block|
+ block.call
+ end
+ allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:increment)
+
+ counter.perform
+
+ expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:observe).with(:counter_perform, account_id: account.id)
+ expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:observe).with(
+ :snapshot_build,
+ account_id: account.id,
+ snapshot_scope: :built_in_filter
+ )
+ expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with(
+ :snapshot_state,
+ account_id: account.id,
+ snapshot_scope: :built_in_filter,
+ snapshot_status: :missing
+ )
+ expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with(
+ :refresh_claim,
+ account_id: account.id,
+ snapshot_scope: :built_in_filter,
+ claimed: true
+ )
+ end
+
+ it 'records acquired build locks when snapshot builds fail' do
+ error = StandardError.new('snapshot failed')
+ lock_manager = instance_double(Redis::LockManager)
+ resolver = Conversations::UnreadCounts::FilteredCountSnapshotResolver.new(
+ account: account,
+ now: now,
+ store: store,
+ lock_manager: lock_manager
+ )
+ state = Conversations::UnreadCounts::FilteredCountStore::SnapshotResult.new(status: :missing, payload: nil)
+
+ allow(lock_manager).to receive(:with_lock)
+ .with('lock-key', Conversations::UnreadCounts::FilteredCountSnapshotResolver::BUILD_LOCK_TTL)
+ .and_yield
+ .and_return(true)
+ allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:observe) do |_operation, _attributes, &block|
+ block.call
+ end
+ allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:increment)
+
+ expect do
+ resolver.resolve(scope: :built_in_filter, state: state, lock_key: 'lock-key', claim_refresh: -> { true }) { raise error }
+ end.to raise_error(error)
+ expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with(
+ :build_lock,
+ account_id: account.id,
+ snapshot_scope: :built_in_filter,
+ acquired: true
+ )
+ end
+
+ it 'omits saved folders with malformed query payloads' do
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'status', values: 'open')
+ )
+
+ expect(counter.perform[:folders]).to eq({})
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+
+ it 'omits saved folders with trailing query operators' do
+ query = filter_query(attribute_key: 'status', values: ['open'])
+ query[:payload].first[:query_operator] = 'AND'
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: query
+ )
+
+ expect(counter.perform[:folders]).to eq({})
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+
+ it 'omits saved folders with invalid typed values' do
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'team_id', values: ['abc'])
+ )
+
+ expect(counter.perform[:folders]).to eq({})
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+
+ it 'omits saved folders with invalid ID values' do
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'assignee_id', values: ['abc'])
+ )
+
+ expect(counter.perform[:folders]).to eq({})
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+
+ it 'counts saved folders with display_id substring filters' do
+ conversation = create_visible_unread_conversation
+ create_visible_unread_conversation
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'display_id', filter_operator: 'contains', values: [conversation.display_id.to_s])
+ )
+
+ expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 1)
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(1)
+ end
+
+ it 'counts saved folders with display_id text fragment filters' do
+ create_visible_unread_conversation
+ create_visible_unread_conversation
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'display_id', filter_operator: 'does_not_contain', values: ['abc'])
+ )
+
+ expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 2)
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(2)
+ end
+
+ it 'omits saved folders with invalid typed custom attribute values' do
+ create(
+ :custom_attribute_definition,
+ account: account,
+ attribute_model: :conversation_attribute,
+ attribute_key: 'budget',
+ attribute_display_type: :number
+ )
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'budget', values: ['abc'])
+ )
+
+ expect(counter.perform[:folders]).to eq({})
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+
+ it 'omits saved folders with text operators on typed custom attributes' do
+ create(
+ :custom_attribute_definition,
+ account: account,
+ attribute_model: :conversation_attribute,
+ attribute_key: 'budget',
+ attribute_display_type: :number
+ )
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'budget', filter_operator: 'contains', values: ['123'])
+ )
+
+ expect(counter.perform[:folders]).to eq({})
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+
+ it 'omits saved folders with invalid label values' do
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'labels', values: [1])
+ )
+
+ expect(counter.perform[:folders]).to eq({})
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+
+ it 'omits saved folders with invalid text values' do
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'mail_subject', values: [1])
+ )
+
+ expect(counter.perform[:folders]).to eq({})
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+
+ it 'omits saved folders with invalid date custom attribute values' do
+ create(
+ :custom_attribute_definition,
+ account: account,
+ attribute_model: :conversation_attribute,
+ attribute_key: 'renewal_on',
+ attribute_display_type: :date
+ )
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'renewal_on', values: ['not-a-date'])
+ )
+
+ expect(counter.perform[:folders]).to eq({})
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
+ end
+
+ it 'omits saved folders when stored custom attribute values cannot be cast' do
+ create(
+ :custom_attribute_definition,
+ account: account,
+ attribute_model: :conversation_attribute,
+ attribute_key: 'budget',
+ attribute_display_type: :number
+ )
+ query_counter = Conversations::UnreadCounts::FilterQueryCounter.new(
+ account: account,
+ user: agent,
+ query: filter_query(attribute_key: 'budget', filter_operator: 'is_present', values: [])
+ )
+ relation = instance_double(ActiveRecord::Relation)
+ cast_error = ActiveRecord::StatementInvalid.new('PG::InvalidTextRepresentation: invalid input syntax for type numeric')
+ allow(cast_error).to receive(:cause).and_return(PG::InvalidTextRepresentation.new('invalid input syntax for type numeric'))
+ allow(query_counter).to receive(:query_builder).and_return(relation)
+ allow(relation).to receive(:count).and_raise(cast_error)
+
+ expect(query_counter.perform).to be_nil
+ end
+
+ it 'counts saved folders with days_before date filters' do
+ old_conversation = create_visible_unread_conversation
+ old_conversation.update!(created_at: 8.days.ago)
+ create_visible_unread_conversation
+ custom_filter = create(
+ :custom_filter,
+ account: account,
+ user: agent,
+ filter_type: :conversation,
+ query: filter_query(attribute_key: 'created_at', filter_operator: 'days_before', values: [7])
+ )
+
+ expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 1)
+ expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(1)
+ end
+
+ def create_visible_unread_conversation(status: :open, agent_last_seen_at: 1.hour.ago, unattended: false)
+ conversation = create_unread_conversation(account: account, inbox: visible_inbox)
+ conversation.update!(
+ status: status,
+ agent_last_seen_at: agent_last_seen_at,
+ first_reply_created_at: unattended ? nil : Time.current,
+ waiting_since: unattended ? 5.minutes.ago : nil
+ )
+ conversation
+ end
+
+ def filter_query(attribute_key:, values:, filter_operator: 'equal_to')
+ {
+ payload: [{
+ attribute_key: attribute_key,
+ attribute_model: 'standard',
+ filter_operator: filter_operator,
+ values: values
+ }]
+ }
+ end
+
+ def redis_keys
+ version_keys + snapshot_keys + lock_and_throttle_keys
+ end
+
+ def filter_ids
+ CustomFilter.where(account_id: account.id).pluck(:id)
+ end
+
+ def version_keys
+ [
+ store.conversation_version_key(account.id),
+ store.built_in_filter_version_key(account.id, agent.id),
+ store.folder_index_version_key(account.id, agent.id)
+ ] + filter_ids.map { |filter_id| store.filter_version_key(account.id, filter_id) }
+ end
+
+ def snapshot_keys
+ [
+ store.built_in_filter_counts_key(account.id, agent.id),
+ store.folder_index_key(account.id, agent.id)
+ ] + filter_ids.map { |filter_id| store.filter_count_key(account.id, filter_id) }
+ end
+
+ def lock_and_throttle_keys
+ user_lock_and_throttle_keys + filter_lock_and_throttle_keys
+ end
+
+ def user_lock_and_throttle_keys
+ [
+ store.built_in_filter_build_lock_key(account.id, agent.id),
+ store.built_in_filter_refresh_throttle_key(account.id, agent.id),
+ store.folder_index_build_lock_key(account.id, agent.id),
+ store.folder_index_refresh_throttle_key(account.id, agent.id)
+ ]
+ end
+
+ def filter_lock_and_throttle_keys
+ filter_ids.flat_map do |filter_id|
+ [
+ store.filter_build_lock_key(account.id, filter_id),
+ store.filter_refresh_throttle_key(account.id, filter_id)
+ ]
+ end
+ end
+end
diff --git a/spec/services/conversations/unread_counts/listener_spec.rb b/spec/services/conversations/unread_counts/listener_spec.rb
index fbb0a0835..f13f70ce0 100644
--- a/spec/services/conversations/unread_counts/listener_spec.rb
+++ b/spec/services/conversations/unread_counts/listener_spec.rb
@@ -5,6 +5,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:notifier) { instance_double(Conversations::UnreadCounts::Notifier, perform: true) }
+ let(:filtered_store) { Conversations::UnreadCounts::FilteredCountStore }
before do
allow(Conversations::UnreadCounts::Notifier).to receive(:new).and_return(notifier)
@@ -21,6 +22,19 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
+ it 'refreshes unread count memberships before invalidating filtered counts when an incoming message is created' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming)
+ event = Events::Base.new('message.created', Time.zone.now, message: message)
+ invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
+
+ allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
+ expect(notifier).to receive(:perform).ordered.and_return(true)
+ expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
+
+ listener.message_created(event)
+ end
+
it 'ignores outgoing message creation' do
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing)
event = Events::Base.new('message.created', Time.zone.now, message: message)
@@ -41,6 +55,32 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
+ it 'invalidates filtered counts when any message is created' do
+ account.enable_features!(:unread_count_for_filters)
+ message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing)
+ event = Events::Base.new('message.created', Time.zone.now, message: message)
+
+ expect do
+ listener.message_created(event)
+ end.to change { filtered_store.conversation_version(account.id) }.by(1)
+ expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
+ end
+
+ it 'notifies clients when outgoing message activity changes filtered counts' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing)
+ event = Events::Base.new('message.created', Time.zone.now, message: message)
+
+ listener.message_created(event)
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation: conversation
+ )
+ end
+
it 'refreshes unread counts when conversation status changes' do
changed_attributes = { 'status' => %w[open resolved] }
event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -51,6 +91,45 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
+ it 'refreshes unread count memberships before invalidating filtered counts when conversation status changes' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ changed_attributes = { 'status' => %w[open resolved] }
+ event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
+ invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
+
+ allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
+ expect(notifier).to receive(:perform).ordered.and_return(true)
+ expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
+
+ listener.conversation_status_changed(event)
+ end
+
+ it 'invalidates filtered counts when conversation status changes' do
+ account.enable_features!(:unread_count_for_filters)
+ changed_attributes = { 'status' => %w[open resolved] }
+ event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
+
+ expect do
+ listener.conversation_status_changed(event)
+ end.to change { filtered_store.conversation_version(account.id) }.by(1)
+ end
+
+ it 'notifies clients when a status change only affects filtered counts' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ allow(notifier).to receive(:perform).and_return(false)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ changed_attributes = { 'status' => %w[pending resolved] }
+ event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
+
+ listener.conversation_status_changed(event)
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation: conversation
+ )
+ end
+
it 'refreshes unread counts when labels change' do
changed_attributes = { label_list: [%w[old], %w[new]] }
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -61,14 +140,61 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
- it 'ignores conversation updates unrelated to unread count dimensions' do
+ it 'does not invalidate filtered counts from conversation updated events' do
+ account.enable_features!(:unread_count_for_filters)
+ event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { priority: [nil, 'high'] })
+
+ expect do
+ listener.conversation_updated(event)
+ end.not_to(change { filtered_store.conversation_version(account.id) })
+ expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
+ end
+
+ it 'notifies clients when filtered conversation fields change' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { priority: [nil, 'high'] })
listener.conversation_updated(event)
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation: conversation
+ )
+ end
+
+ it 'ignores conversation updates unrelated to unread count dimensions' do
+ event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { identifier: %w[old new] })
+
+ listener.conversation_updated(event)
+
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
+ it 'invalidates filtered counts when the conversation contact changes' do
+ account.enable_features!(:unread_count_for_filters)
+ event = Events::Base.new('conversation.contact_changed', Time.zone.now, conversation: conversation)
+
+ expect do
+ listener.conversation_contact_changed(event)
+ end.to change { filtered_store.conversation_version(account.id) }.by(1)
+ end
+
+ it 'notifies clients when the conversation contact changes' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ event = Events::Base.new('conversation.contact_changed', Time.zone.now, conversation: conversation)
+
+ listener.conversation_contact_changed(event)
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation: conversation
+ )
+ end
+
it 'refreshes unread counts when assignee changes' do
changed_attributes = { assignee_id: [nil, 1] }
event = Events::Base.new('assignee.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -79,6 +205,45 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
+ it 'notifies clients when an assignee change only affects filtered counts' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ allow(notifier).to receive(:perform).and_return(false)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ changed_attributes = { assignee_id: [nil, 1] }
+ event = Events::Base.new('assignee.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
+
+ listener.assignee_changed(event)
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation: conversation
+ )
+ end
+
+ it 'refreshes unread count memberships before invalidating filtered counts when assignee changes' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ changed_attributes = { assignee_id: [nil, 1] }
+ event = Events::Base.new('assignee.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
+ invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
+
+ allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
+ expect(notifier).to receive(:perform).ordered.and_return(true)
+ expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
+
+ listener.assignee_changed(event)
+ end
+
+ it 'invalidates filtered counts when a user is mentioned' do
+ account.enable_features!(:unread_count_for_filters)
+ user = create(:user, account: account)
+ event = Events::Base.new('conversation.mentioned', Time.zone.now, conversation: conversation, user: user)
+
+ expect do
+ listener.conversation_mentioned(event)
+ end.to change { filtered_store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
+ end
+
it 'refreshes unread counts when team changes' do
changed_attributes = { team_id: [nil, 1] }
event = Events::Base.new('team.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -89,6 +254,47 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
+ it 'notifies clients when a team change only affects filtered counts' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ allow(notifier).to receive(:perform).and_return(false)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ changed_attributes = { team_id: [nil, 1] }
+ event = Events::Base.new('team.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
+
+ listener.team_changed(event)
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation: conversation
+ )
+ end
+
+ it 'invalidates filtered counts when a conversation is deleted' do
+ account.enable_features!(:unread_count_for_filters)
+ conversation_data = deleted_conversation_data(conversation)
+
+ expect do
+ listener.conversation_deleted(Events::Base.new('conversation.deleted', Time.zone.now, conversation_data: conversation_data))
+ end.to change { filtered_store.conversation_version(account.id) }.by(1)
+ end
+
+ it 'notifies clients when a deleted conversation only affects filtered counts' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ conversation_data = deleted_conversation_data(conversation)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+
+ listener.conversation_deleted(Events::Base.new('conversation.deleted', Time.zone.now, conversation_data: conversation_data))
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation_data: conversation_data.stringify_keys
+ )
+ ensure
+ store.clear_account!(account.id)
+ end
+
it 'removes unread count memberships when a conversation is deleted' do
account.enable_features!(:conversation_unread_counts)
label = create(:label, account: account)
@@ -131,6 +337,29 @@ RSpec.describe Conversations::UnreadCounts::Listener do
store.clear_account!(account.id)
end
+ it 'removes unread count memberships before invalidating filtered counts when a conversation is deleted' do
+ account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
+ conversation_data = deleted_conversation_data(conversation)
+ invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
+
+ store.mark_base_ready!(account.id)
+ store.add_base_membership(
+ account_id: account.id,
+ inbox_id: conversation.inbox_id,
+ label_ids: [],
+ conversation_id: conversation.id
+ )
+
+ allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
+ allow(Rails.configuration.dispatcher).to receive(:dispatch)
+ expect(store).to receive(:remove_base_membership).ordered.and_call_original
+ expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
+
+ listener.conversation_deleted(Events::Base.new('conversation.deleted', Time.zone.now, conversation_data: conversation_data))
+ ensure
+ store.clear_account!(account.id)
+ end
+
def deleted_conversation_data(conversation)
{
id: conversation.id,
diff --git a/spec/services/conversations/unread_counts/notifier_spec.rb b/spec/services/conversations/unread_counts/notifier_spec.rb
index 1b35d37f6..a8c46be0c 100644
--- a/spec/services/conversations/unread_counts/notifier_spec.rb
+++ b/spec/services/conversations/unread_counts/notifier_spec.rb
@@ -29,6 +29,18 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
+
+ it 'dispatches unread count changed event when filtered counts are enabled' do
+ conversation.account.enable_features!(:unread_count_for_filters)
+
+ described_class.new(conversation).perform
+
+ expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
+ 'conversation.unread_count_changed',
+ kind_of(Time),
+ conversation: conversation
+ )
+ end
end
context 'when conversation unread counts feature is disabled' do
diff --git a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
index 75abc8518..988fb0116 100644
--- a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
+++ b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
@@ -32,6 +32,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
before do
account.enable_features('crm_integration')
+ allow(GlobalConfig).to receive(:get).and_return({})
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
end
diff --git a/spec/services/data_imports/intercom/activity_content_builder_spec.rb b/spec/services/data_imports/intercom/activity_content_builder_spec.rb
new file mode 100644
index 000000000..f928c9154
--- /dev/null
+++ b/spec/services/data_imports/intercom/activity_content_builder_spec.rb
@@ -0,0 +1,51 @@
+require 'rails_helper'
+
+event_content = {
+ 'assignment' => 'Avery assigned the conversation to Support',
+ 'assign_and_reopen' => 'Avery assigned the conversation to Support and reopened it',
+ 'open' => 'Avery opened the conversation',
+ 'close' => 'Avery closed the conversation',
+ 'snoozed' => 'Avery snoozed the conversation',
+ 'participant_added' => 'Avery added Support as a participant',
+ 'participant_removed' => 'Avery removed Support as a participant',
+ 'conversation_attribute_updated_by_admin' => 'Avery updated conversation attributes',
+ 'conversation_attribute_updated_by_user' => 'Avery updated conversation attributes',
+ 'conversation_attribute_updated_by_workflow' => 'Avery updated conversation attributes',
+ 'ticket_attribute_updated_by_admin' => 'Avery updated ticket attributes',
+ 'ticket_state_updated_by_admin' => 'Avery updated the ticket state',
+ 'custom_action_started' => 'Avery started a custom action',
+ 'custom_action_finished' => 'Avery finished a custom action',
+ 'quick_reply' => 'Avery used a quick reply'
+}.freeze
+
+RSpec.describe DataImports::Intercom::ActivityContentBuilder do
+ event_content.each do |part_type, expected_content|
+ it "builds readable content for #{part_type}" do
+ part = {
+ 'part_type' => part_type,
+ 'author' => { 'type' => 'admin', 'name' => 'Avery' },
+ 'assigned_to' => { 'name' => 'Support' }
+ }
+
+ expect(described_class.new(part).perform).to eq(expected_content)
+ end
+ end
+
+ it 'uses a humanized fallback for unknown future event types' do
+ part = { 'part_type' => 'journey_stage_changed', 'author' => { 'type' => 'bot' } }
+
+ expect(described_class.new(part).perform).to eq('Intercom automation recorded journey stage changed')
+ end
+
+ it 'appends sanitized body context' do
+ part = {
+ 'part_type' => 'close',
+ 'author' => { 'type' => 'admin' },
+ 'body' => 'Customer confirmed resolution
'
+ }
+
+ expect(described_class.new(part).perform).to eq(
+ 'Intercom teammate closed the conversation: Customer confirmed resolution'
+ )
+ end
+end
diff --git a/spec/services/data_imports/intercom/client_spec.rb b/spec/services/data_imports/intercom/client_spec.rb
new file mode 100644
index 000000000..78498121a
--- /dev/null
+++ b/spec/services/data_imports/intercom/client_spec.rb
@@ -0,0 +1,16 @@
+require 'rails_helper'
+
+RSpec.describe DataImports::Intercom::Client do
+ let(:client) { described_class.new(access_token: 'intercom-token') }
+
+ describe '#list_contacts' do
+ it 'wraps transport failures in a retryable client error', :aggregate_failures do
+ allow(HTTParty).to receive(:get).and_raise(SocketError, 'getaddrinfo failed')
+
+ expect { client.list_contacts }.to raise_error(DataImports::Intercom::Client::Error) do |error|
+ expect(error.message).to eq('Intercom API request failed before receiving a response: getaddrinfo failed')
+ expect(error.body).to include(transport_error_class: 'SocketError')
+ end
+ end
+ end
+end
diff --git a/spec/services/data_imports/intercom/creation_service_spec.rb b/spec/services/data_imports/intercom/creation_service_spec.rb
new file mode 100644
index 000000000..4345b3715
--- /dev/null
+++ b/spec/services/data_imports/intercom/creation_service_spec.rb
@@ -0,0 +1,52 @@
+require 'rails_helper'
+
+RSpec.describe DataImports::Intercom::CreationService do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:validator) { instance_double(DataImports::Intercom::CredentialsValidator, perform: { 'contacts' => 12 }) }
+
+ before do
+ allow(DataImports::Intercom::CredentialsValidator).to receive(:new).and_return(validator)
+ end
+
+ it 'validates and creates an import with its credentials and totals', :aggregate_failures do
+ data_import = described_class.new(
+ account: account,
+ initiated_by: user,
+ source_params: {
+ name: 'Migration run',
+ source_provider: 'intercom',
+ access_token: ' intercom-token ',
+ import_types: %w[contacts]
+ }
+ ).perform
+
+ expect(data_import).to have_attributes(
+ name: 'Migration run',
+ source_type: 'api',
+ source_provider: 'intercom',
+ import_types: %w[contacts],
+ access_token: 'intercom-token',
+ initiated_by_id: user.id
+ )
+ expect(data_import.stats.dig('contacts', 'total')).to eq(12)
+ expect(data_import.active_intercom_import_run_id).to be_present
+ end
+
+ it 'returns no import without validating when another import is active' do
+ create(:data_import, :intercom, account: account, status: :processing)
+
+ data_import = described_class.new(
+ account: account,
+ initiated_by: user,
+ source_params: {
+ name: 'Second run',
+ source_provider: 'intercom',
+ access_token: 'intercom-token'
+ }
+ ).perform
+
+ expect(data_import).to be_nil
+ expect(validator).not_to have_received(:perform)
+ end
+end
diff --git a/spec/services/data_imports/intercom/credentials_validator_spec.rb b/spec/services/data_imports/intercom/credentials_validator_spec.rb
new file mode 100644
index 000000000..3d3f9698b
--- /dev/null
+++ b/spec/services/data_imports/intercom/credentials_validator_spec.rb
@@ -0,0 +1,54 @@
+require 'rails_helper'
+
+RSpec.describe DataImports::Intercom::CredentialsValidator do
+ let(:client) { instance_double(DataImports::Intercom::Client) }
+
+ before do
+ allow(DataImports::Intercom::Client).to receive(:new).with(access_token: 'intercom-token').and_return(client)
+ allow(client).to receive(:list_contacts)
+ allow(client).to receive(:list_conversations)
+ end
+
+ it 'validates and counts only contacts when conversations are not selected' do
+ allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 42)
+
+ totals = described_class.new(access_token: ' intercom-token ', import_types: %w[contacts]).perform
+
+ expect(totals).to eq('contacts' => 42)
+ expect(client).not_to have_received(:list_conversations)
+ end
+
+ it 'validates contact access and counts only conversations when contacts are not selected' do
+ allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 42)
+ allow(client).to receive(:list_conversations).with(per_page: 1).and_return('total_count' => 17)
+
+ totals = described_class.new(access_token: 'intercom-token', import_types: %w[conversations]).perform
+
+ expect(totals).to eq('conversations' => 17)
+ expect(client).to have_received(:list_contacts).with(per_page: 1)
+ end
+
+ it 'keeps an undiscovered total absent' do
+ allow(client).to receive(:list_contacts).with(per_page: 1).and_return('data' => [])
+
+ totals = described_class.new(access_token: 'intercom-token', import_types: %w[contacts]).perform
+
+ expect(totals).to be_empty
+ end
+
+ it 'preserves a known zero total' do
+ allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 0)
+
+ totals = described_class.new(access_token: 'intercom-token', import_types: %w[contacts]).perform
+
+ expect(totals).to eq('contacts' => 0)
+ end
+
+ it 'rejects an empty access key before calling Intercom' do
+ expect do
+ described_class.new(access_token: '', import_types: %w[contacts]).perform
+ end.to raise_error(ArgumentError, 'Intercom access key is required.')
+
+ expect(DataImports::Intercom::Client).not_to have_received(:new)
+ end
+end
diff --git a/spec/services/data_imports/intercom/importer_spec.rb b/spec/services/data_imports/intercom/importer_spec.rb
new file mode 100644
index 000000000..67c2ec576
--- /dev/null
+++ b/spec/services/data_imports/intercom/importer_spec.rb
@@ -0,0 +1,968 @@
+require 'rails_helper'
+
+RSpec.describe DataImports::Intercom::Importer do
+ let(:account) { create(:account) }
+ let(:data_import) do
+ create(
+ :data_import, :intercom,
+ account: account
+ )
+ end
+ let(:client) { instance_double(DataImports::Intercom::Client) }
+ let(:contact_payload) do
+ {
+ 'id' => 'contact_1',
+ 'external_id' => 'external_1',
+ 'email' => 'CUSTOMER@Example.com',
+ 'phone' => '15551234567',
+ 'name' => 'Customer One',
+ 'created_at' => 1_700_000_000,
+ 'updated_at' => 1_700_000_100
+ }
+ end
+ let(:conversation_payload) do
+ {
+ 'id' => 'conversation_1',
+ 'created_at' => 1_700_000_000,
+ 'updated_at' => 1_700_000_200,
+ 'state' => 'closed',
+ 'open' => false,
+ 'admin_assignee_id' => 123,
+ 'team_assignee_id' => 456,
+ 'contacts' => { 'contacts' => [{ 'id' => 'contact_1' }] },
+ 'source' => {
+ 'id' => 'source_1',
+ 'type' => 'email',
+ 'delivered_as' => 'customer_initiated',
+ 'subject' => 'Need help',
+ 'body' => 'Hello there
',
+ 'author' => { 'type' => 'user', 'id' => 'contact_1', 'email' => 'CUSTOMER@example.com' }
+ },
+ 'conversation_parts' => {
+ 'conversation_parts' => [
+ {
+ 'id' => 'part_1',
+ 'part_type' => 'comment',
+ 'body' => 'Admin reply
',
+ 'created_at' => 1_700_000_100,
+ 'updated_at' => 1_700_000_100,
+ 'author' => { 'type' => 'admin', 'id' => 'admin_1' },
+ 'attachments' => []
+ },
+ {
+ 'id' => 'part_2',
+ 'part_type' => 'note',
+ 'body' => 'Internal note',
+ 'created_at' => 1_700_000_150,
+ 'updated_at' => 1_700_000_150,
+ 'author' => { 'type' => 'admin', 'id' => 'admin_1' },
+ 'attachments' => []
+ }
+ ]
+ }
+ }
+ end
+
+ before do
+ account.enable_features!('data_import')
+ allow(DataImports::Intercom::Client).to receive(:new).with(access_token: 'intercom-token').and_return(client)
+ allow(client).to receive(:list_contacts).with(starting_after: nil).and_return(
+ 'data' => [contact_payload],
+ 'total_count' => 1,
+ 'pages' => { 'next' => nil }
+ )
+ allow(client).to receive(:list_conversations).with(starting_after: nil).and_return(
+ 'conversations' => [{ 'id' => 'conversation_1' }],
+ 'total_count' => 1,
+ 'pages' => { 'next' => nil }
+ )
+ allow(client).to receive(:retrieve_conversation).with('conversation_1').and_return(conversation_payload)
+ allow(client).to receive(:retrieve_contact).with('contact_1').and_return(contact_payload)
+ end
+
+ it 'imports contacts, conversations, messages, and source-bucket inboxes without normal message creation callbacks', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ contact = account.contacts.find_by!(email: 'customer@example.com')
+ expect(contact.name).to eq('Customer One')
+ expect(contact.phone_number).to eq('+15551234567')
+ expect(contact).to be_lead
+ expect(contact.custom_attributes).to include('intercom_contact_id' => 'contact_1')
+
+ inbox = account.inboxes.find_by!(name: 'Intercom Import - Email')
+ expect(inbox.channel.additional_attributes).to include('source_bucket' => 'email', 'import_placeholder' => true)
+
+ conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
+ expect(conversation).to have_attributes(
+ status: 'resolved',
+ inbox_id: inbox.id,
+ contact_id: contact.id
+ )
+ expect(conversation.additional_attributes.dig('source', 'routing_method')).to eq('source_bucket_api_inbox')
+
+ expect(conversation.messages.order(:created_at).pluck(:content)).to eq(["Need help\n\nHello there", 'Admin reply', 'Internal note'])
+ expect(conversation.messages.order(:created_at).map(&:message_type)).to eq(%w[incoming outgoing outgoing])
+ expect(conversation.messages.order(:created_at).last.private).to be(true)
+
+ expect(data_import.reload).to be_completed
+ expect(data_import.stats).to include(
+ 'contacts' => include('imported' => 1, 'skipped' => 0, 'total' => 1),
+ 'conversations' => include('imported' => 1, 'skipped' => 0, 'total' => 1),
+ 'messages' => include('imported' => 3, 'skipped' => 0, 'total' => 3),
+ 'errors' => { 'count' => 0 }
+ )
+ expect(data_import.processed_records).to eq(5)
+ expect(data_import.items.imported.count).to eq(2)
+ expect(DataImportMapping.where(data_import: data_import).count).to eq(5)
+ end
+
+ it 'imports historical records without dispatching record events or outbound side effects', :aggregate_failures do
+ dispatched_events = []
+ allow(Rails.configuration.dispatcher).to receive(:dispatch) do |event_name, *_args|
+ dispatched_events << event_name
+ end
+ clear_enqueued_jobs
+
+ described_class.new(data_import: data_import).perform
+
+ record_events = [
+ Events::Types::CONTACT_CREATED,
+ Events::Types::CONTACT_UPDATED,
+ Events::Types::CONVERSATION_CREATED,
+ Events::Types::CONVERSATION_UPDATED,
+ Events::Types::CONVERSATION_STATUS_CHANGED,
+ Events::Types::ASSIGNEE_CHANGED,
+ Events::Types::TEAM_CHANGED,
+ Events::Types::MESSAGE_CREATED,
+ Events::Types::FIRST_REPLY_CREATED,
+ Events::Types::REPLY_CREATED
+ ]
+ side_effect_jobs = [SendReplyJob, EventDispatcherJob, ActionCableBroadcastJob, WebhookJob, HookJob]
+
+ expect(dispatched_events & record_events).to be_empty
+ expect(enqueued_jobs.pluck(:job) & side_effect_jobs).to be_empty
+ expect(Notification.where(account: account)).to be_empty
+ end
+
+ context 'when Intercom contact activity timestamps are available' do
+ let(:contact_payload) do
+ super().merge('last_seen_at' => 1_700_000_050, 'last_replied_at' => 1_700_000_090)
+ end
+
+ it 'prefers last_seen_at for contact activity' do
+ described_class.new(data_import: data_import).import_contacts_page
+
+ contact = account.contacts.find_by!(email: 'customer@example.com')
+ expect(contact.last_activity_at).to eq(Time.zone.at(1_700_000_050))
+ end
+ end
+
+ context 'when Intercom contact last_seen_at is unavailable' do
+ let(:contact_payload) do
+ super().merge('last_seen_at' => nil, 'last_replied_at' => 1_700_000_090)
+ end
+
+ it 'falls back to last_replied_at for contact activity' do
+ described_class.new(data_import: data_import).import_contacts_page
+
+ contact = account.contacts.find_by!(email: 'customer@example.com')
+ expect(contact.last_activity_at).to eq(Time.zone.at(1_700_000_090))
+ end
+ end
+
+ it 'leaves contact activity blank when Intercom activity timestamps are unavailable' do
+ described_class.new(data_import: data_import).import_contacts_page
+
+ contact = account.contacts.find_by!(email: 'customer@example.com')
+ expect(contact.last_activity_at).to be_nil
+ end
+
+ it 'updates message totals by delta when a conversation page is retried' do
+ importer = described_class.new(data_import: data_import)
+
+ importer.import_conversations_page
+ importer.import_conversations_page
+
+ expect(data_import.reload.stats.dig('messages', 'total')).to eq(3)
+ item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1')
+ expect(item.metadata['message_total_contribution']).to eq(3)
+ end
+
+ it 'reconciles imported message stats from same-run mappings on retry' do
+ described_class.new(data_import: data_import).import_conversations_page
+ stats = data_import.reload.stats.deep_dup
+ stats['messages']['imported'] = 0
+ data_import.update!(stats: stats)
+
+ described_class.new(data_import: data_import).import_conversations_page
+
+ expect(data_import.reload.stats.dig('messages', 'imported')).to eq(3)
+ end
+
+ it 'indexes imported messages for advanced search' do
+ allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
+ reindexed_message_ids = []
+ original_reindex_for_search = Message.instance_method(:reindex_for_search)
+ Message.define_method(:reindex_for_search) { reindexed_message_ids << id }
+ Message.__send__(:private, :reindex_for_search)
+
+ described_class.new(data_import: data_import).perform
+
+ expect(reindexed_message_ids).to match_array(Message.where(account_id: account.id).pluck(:id))
+ ensure
+ Message.define_method(:reindex_for_search, original_reindex_for_search)
+ Message.__send__(:private, :reindex_for_search)
+ end
+
+ it 'keeps imported messages successful when search reindexing fails', :aggregate_failures do
+ allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
+ # rubocop:disable RSpec/AnyInstance
+ allow_any_instance_of(Message).to receive(:reindex_for_search).and_raise(StandardError, 'search unavailable')
+ # rubocop:enable RSpec/AnyInstance
+
+ described_class.new(data_import: data_import).perform
+
+ message = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:source:source_1')
+ mapping = data_import.mappings.find_by!(source_object_type: 'message', source_object_id: 'conversation:conversation_1:source:source_1')
+ expect(mapping.chatwoot_record).to eq(message)
+ expect(data_import.reload).to be_completed
+ expect(data_import.import_errors.exists?).to be(false)
+ expect(data_import.stats.dig('messages', 'imported')).to eq(3)
+ end
+
+ describe '#start!' do
+ it 'does not overwrite an import abandoned by another process', :aggregate_failures do
+ importer = described_class.new(data_import: data_import)
+
+ DataImport.find(data_import.id).update!(
+ status: :abandoned,
+ abandoned_at: Time.current
+ )
+
+ expect(importer.start!).to be_nil
+ expect(data_import.reload).to be_abandoned
+ expect(data_import.started_at).to be_nil
+ end
+ end
+
+ describe '#perform' do
+ it 'stops when the import was abandoned before processing starts' do
+ importer = described_class.new(data_import: data_import)
+ DataImport.find(data_import.id).update!(
+ status: :abandoned,
+ abandoned_at: Time.current
+ )
+
+ expect(client).not_to receive(:list_contacts)
+
+ importer.perform
+
+ expect(data_import.reload).to be_abandoned
+ end
+ end
+
+ describe '#import_conversations_page' do
+ it 'stops an in-flight page when a newer import run takes over', :aggregate_failures do
+ run_id = 'intercom-run-1'
+ data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => run_id })
+ allow(client).to receive(:list_conversations).with(starting_after: nil).and_return(
+ 'conversations' => [{ 'id' => 'conversation_1' }, { 'id' => 'conversation_2' }],
+ 'pages' => { 'next' => { 'starting_after' => 'next-conversation-cursor' } }
+ )
+ allow(client).to receive(:retrieve_conversation).with('conversation_1') do
+ data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
+ conversation_payload
+ end
+
+ result = described_class.new(data_import: data_import, run_id: run_id).import_conversations_page
+
+ expect(result).to be_done
+ expect(client).not_to have_received(:retrieve_conversation).with('conversation_2')
+ expect(account.conversations.where(identifier: 'intercom:conversation_1')).to be_empty
+ expect(account.contacts.where(email: 'customer@example.com')).to be_empty
+ expect(data_import.reload.cursor.dig('conversations', 'starting_after')).to be_nil
+ end
+
+ it 'rolls back a newly inserted conversation when mapping persistence fails', :aggregate_failures do
+ importer = described_class.new(data_import: data_import)
+ allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:|
+ raise StandardError, 'mapping failed' if object_type == 'conversation'
+
+ method.call(object_type, source_id, record, metadata: metadata)
+ end
+
+ importer.import_conversations_page
+
+ expect(account.conversations.where(identifier: 'intercom:conversation_1')).to be_empty
+ item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1')
+ expect(item).to be_failed
+ expect(item.last_error_message).to eq('mapping failed')
+ end
+
+ it 'rolls back a newly inserted contact when mapping persistence fails', :aggregate_failures do
+ sparse_contact = contact_payload.slice('id', 'name', 'created_at', 'updated_at')
+ allow(client).to receive(:retrieve_contact).with('contact_1').and_return(sparse_contact)
+ importer = described_class.new(data_import: data_import)
+ allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:|
+ raise StandardError, 'mapping failed' if object_type == 'contact'
+
+ method.call(object_type, source_id, record, metadata: metadata)
+ end
+
+ importer.import_conversations_page
+
+ expect(account.contacts.where(name: 'Customer One')).to be_empty
+ expect(data_import.mappings.where(source_object_type: 'contact', source_object_id: 'contact_1')).to be_empty
+ contact_item = data_import.items.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
+ expect(contact_item).to be_failed
+ expect(contact_item.last_error_message).to eq('mapping failed')
+ end
+
+ it 'rolls back a newly inserted message when mapping persistence fails', :aggregate_failures do
+ importer = described_class.new(data_import: data_import)
+ allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:|
+ raise StandardError, 'mapping failed' if object_type == 'message'
+
+ method.call(object_type, source_id, record, metadata: metadata)
+ end
+
+ importer.import_conversations_page
+
+ conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
+ expect(conversation.messages.where(source_id: 'intercom:conversation:conversation_1:source:source_1')).to be_empty
+ error = data_import.import_errors.find_by!(
+ source_object_type: 'message',
+ source_object_id: 'conversation:conversation_1:source:source_1'
+ )
+ expect(error).to have_attributes(error_code: 'StandardError', message: 'mapping failed')
+ end
+ end
+
+ describe '#finish!' do
+ it 'does not overwrite an import abandoned by another process' do
+ data_import.update!(status: :processing)
+ importer = described_class.new(data_import: data_import)
+
+ DataImport.find(data_import.id).update!(
+ status: :abandoned,
+ abandoned_at: Time.current
+ )
+
+ importer.finish!
+
+ expect(data_import.reload).to be_abandoned
+ expect(data_import.completed_at).to be_nil
+ end
+ end
+
+ describe '#fail!' do
+ it 'does not overwrite an import abandoned by another process', :aggregate_failures do
+ data_import.update!(status: :processing)
+ importer = described_class.new(data_import: data_import)
+
+ DataImport.find(data_import.id).update!(
+ status: :abandoned,
+ abandoned_at: Time.current
+ )
+
+ importer.fail!(StandardError.new('boom'))
+
+ expect(data_import.reload).to be_abandoned
+ expect(data_import.last_error_at).to be_nil
+ expect(data_import.import_errors.exists?).to be(false)
+ end
+ end
+
+ context 'when the Intercom records were imported by an earlier run' do
+ let(:next_data_import) do
+ create(
+ :data_import, :intercom,
+ account: account
+ )
+ end
+
+ it 'records the already mapped records as skipped for the current import run', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ described_class.new(data_import: next_data_import).perform
+
+ expect(next_data_import.reload.stats).to include(
+ 'contacts' => include('imported' => 0, 'skipped' => 1, 'total' => 1),
+ 'conversations' => include('imported' => 0, 'skipped' => 1, 'total' => 1),
+ 'messages' => include('imported' => 0, 'skipped' => 3, 'total' => 3),
+ 'errors' => { 'count' => 0 }
+ )
+ expect(next_data_import).to be_completed
+ expect(next_data_import.total_records).to eq(5)
+ expect(next_data_import.processed_records).to eq(0)
+ expect(next_data_import.items.skipped.count).to eq(2)
+ expect(next_data_import.import_errors.skip_logs.group(:source_object_type).count).to eq(
+ 'contact' => 1,
+ 'conversation' => 1,
+ 'message' => 3
+ )
+ expect(next_data_import.import_errors.skip_logs.pluck(:details).map { |details| details['reason'] }.uniq).to eq(['already_imported'])
+ end
+
+ it 'recreates messages when existing message mappings point to deleted records', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+ conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
+ Message.where(conversation_id: conversation.id).delete_all
+
+ described_class.new(data_import: next_data_import).perform
+
+ expect(conversation.reload.messages.pluck(:source_id)).to match_array(
+ %w[
+ intercom:conversation:conversation_1:source:source_1
+ intercom:conversation:conversation_1:part:part_1
+ intercom:conversation:conversation_1:part:part_2
+ ]
+ )
+ expect(next_data_import.reload.stats).to include(
+ 'contacts' => include('imported' => 0, 'skipped' => 1, 'total' => 1),
+ 'conversations' => include('imported' => 0, 'skipped' => 1, 'total' => 1),
+ 'messages' => include('imported' => 3, 'skipped' => 0, 'total' => 3),
+ 'errors' => { 'count' => 0 }
+ )
+ expect(next_data_import.import_errors.skip_logs.where(source_object_type: 'message')).to be_empty
+ message_mappings = DataImportMapping.where(account: account, source_provider: 'intercom', source_object_type: 'message')
+ expect(message_mappings.filter_map(&:chatwoot_record).count).to eq(3)
+ end
+
+ it 'updates conversation activity when a later import adds new messages to the mapped conversation', :aggregate_failures do
+ new_part = {
+ 'id' => 'part_3',
+ 'part_type' => 'comment',
+ 'body' => 'Follow-up reply
',
+ 'created_at' => 1_700_000_300,
+ 'updated_at' => 1_700_000_300,
+ 'author' => { 'type' => 'admin', 'id' => 'admin_1' },
+ 'attachments' => []
+ }
+ updated_conversation_payload = conversation_payload.deep_dup
+ updated_conversation_payload['updated_at'] = 1_700_000_300
+ updated_conversation_payload['conversation_parts']['conversation_parts'] << new_part
+ allow(client).to receive(:retrieve_conversation).with('conversation_1').and_return(
+ conversation_payload,
+ updated_conversation_payload
+ )
+
+ described_class.new(data_import: data_import).perform
+ conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
+
+ described_class.new(data_import: next_data_import).perform
+
+ expect(conversation.reload.last_activity_at).to eq(Time.zone.at(1_700_000_300))
+ expect(conversation.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:part_3').content).to eq('Follow-up reply')
+ end
+ end
+
+ context 'when a conversation references an already mapped contact' do
+ it 'reuses the mapped contact without hydrating the sparse reference' do
+ described_class.new(data_import: data_import).import_contacts_page
+
+ expect(client).not_to receive(:retrieve_contact)
+
+ described_class.new(data_import: data_import).import_conversations_page
+ end
+ end
+
+ context 'when a same-run contact mapping outlives its item progress' do
+ let!(:mapped_contact) { create(:contact, account: account) }
+
+ before do
+ DataImportMapping.create!(
+ account: account,
+ data_import: data_import,
+ source_provider: 'intercom',
+ source_object_type: 'contact',
+ source_object_id: 'contact_1',
+ chatwoot_record_type: 'Contact',
+ chatwoot_record_id: mapped_contact.id,
+ metadata: {}
+ )
+ data_import.items.create!(
+ source_provider: 'intercom',
+ source_object_type: 'contact',
+ source_object_id: 'contact_1',
+ status: :processing,
+ metadata: contact_payload
+ )
+ end
+
+ it 'repairs the item and imported count on retry', :aggregate_failures do
+ described_class.new(data_import: data_import).import_contacts_page
+
+ item = data_import.items.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
+ expect(item).to be_imported
+ expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: mapped_contact.id)
+ expect(data_import.reload.stats.dig('contacts', 'imported')).to eq(1)
+ end
+ end
+
+ context 'when an existing contact has the same email but a different external id' do
+ let(:contact_payload) do
+ super().merge('last_replied_at' => 1_700_000_090)
+ end
+ let!(:existing_contact) { create(:contact, account: account, email: 'customer@example.com', identifier: nil) }
+
+ it 'updates the existing contact instead of creating a duplicate', :aggregate_failures do
+ described_class.new(data_import: data_import).import_contacts_page
+
+ expect(existing_contact.reload.identifier).to eq('external_1')
+ expect(existing_contact.last_activity_at).to eq(Time.zone.at(1_700_000_090))
+ expect(account.contacts.where(email: 'customer@example.com').count).to eq(1)
+ item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
+ expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id)
+ end
+ end
+
+ context 'when an existing contact has the same phone but a different external id' do
+ let(:contact_payload) do
+ super().merge('email' => nil)
+ end
+ let!(:existing_contact) { create(:contact, account: account, phone_number: '+15551234567', identifier: nil) }
+
+ it 'updates the existing contact instead of creating a duplicate', :aggregate_failures do
+ described_class.new(data_import: data_import).import_contacts_page
+
+ expect(existing_contact.reload.identifier).to eq('external_1')
+ expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1)
+ item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
+ expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id)
+ end
+ end
+
+ context 'when an existing contact has the same phone but Intercom sends a new email' do
+ let!(:existing_contact) { create(:contact, account: account, phone_number: '+15551234567', identifier: nil) }
+
+ it 'falls through to the phone match after the email lookup misses', :aggregate_failures do
+ described_class.new(data_import: data_import).import_contacts_page
+
+ expect(existing_contact.reload.email).to eq('customer@example.com')
+ expect(existing_contact.identifier).to eq('external_1')
+ expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1)
+ item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
+ expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id)
+ end
+ end
+
+ context 'when an existing visitor contact matches the Intercom external id' do
+ let!(:existing_contact) { create(:contact, account: account, identifier: 'external_1') }
+
+ it 'promotes the contact to a lead when adding email or phone', :aggregate_failures do
+ expect(existing_contact).to be_visitor
+
+ described_class.new(data_import: data_import).import_contacts_page
+
+ expect(existing_contact.reload).to be_lead
+ expect(existing_contact.email).to eq('customer@example.com')
+ expect(existing_contact.phone_number).to eq('+15551234567')
+ end
+ end
+
+ context 'when an identifier match has contact details owned by another contact' do
+ let!(:existing_contact) { create(:contact, account: account, identifier: 'external_1') }
+ let!(:email_owner) { create(:contact, account: account, email: 'customer@example.com') }
+ let!(:phone_owner) { create(:contact, account: account, phone_number: '+15551234567') }
+
+ it 'does not copy the conflicting email or phone number', :aggregate_failures do
+ described_class.new(data_import: data_import).import_contacts_page
+
+ expect(existing_contact.reload.email).to be_nil
+ expect(existing_contact.phone_number).to be_nil
+ expect(existing_contact).to be_visitor
+ expect(email_owner.reload.email).to eq('customer@example.com')
+ expect(phone_owner.reload.phone_number).to eq('+15551234567')
+ expect(account.contacts.where(email: 'customer@example.com').count).to eq(1)
+ expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1)
+
+ item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
+ expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id)
+ end
+ end
+
+ context 'when Intercom rate limits a conversation detail request' do
+ before do
+ allow(client).to receive(:retrieve_conversation).with('conversation_1').and_raise(
+ DataImports::Intercom::Client::RateLimitError.new('rate limited', status: 429)
+ )
+ end
+
+ it 're-raises the provider error so the page job can retry', :aggregate_failures do
+ expect { described_class.new(data_import: data_import).import_conversations_page }
+ .to raise_error(DataImports::Intercom::Client::RateLimitError)
+
+ item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1')
+ expect(item).to be_processing
+ expect(data_import.import_errors.exists?).to be(false)
+ end
+ end
+
+ context 'when Intercom rate limits a contact hydration request' do
+ before do
+ allow(client).to receive(:retrieve_contact).with('contact_1').and_raise(
+ DataImports::Intercom::Client::RateLimitError.new('rate limited', status: 429)
+ )
+ end
+
+ it 're-raises the provider error instead of importing a sparse contact', :aggregate_failures do
+ expect { described_class.new(data_import: data_import).import_conversations_page }
+ .to raise_error(DataImports::Intercom::Client::RateLimitError)
+
+ expect(data_import.items.exists?(source_object_type: 'contact')).to be(false)
+ expect(data_import.import_errors.exists?).to be(false)
+ end
+ end
+
+ context 'when Intercom no longer has a sparse contact referenced by a conversation' do
+ before do
+ allow(client).to receive(:retrieve_contact).with('contact_1').and_raise(
+ DataImports::Intercom::Client::Error.new('not found', status: 404)
+ )
+ end
+
+ it 'falls back to the conversation contact reference', :aggregate_failures do
+ expect { described_class.new(data_import: data_import).import_conversations_page }.not_to raise_error
+
+ expect(data_import.items.imported.exists?(source_object_type: 'contact', source_object_id: 'contact_1')).to be(true)
+ expect(data_import.import_errors.exists?).to be(false)
+ end
+ end
+
+ context 'when the Intercom source message only has attachments' do
+ let(:conversation_payload) do
+ super().deep_merge(
+ 'source' => {
+ 'subject' => nil,
+ 'body' => nil,
+ 'attachments' => [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }]
+ },
+ 'conversation_parts' => {
+ 'conversation_parts' => []
+ }
+ )
+ end
+
+ it 'imports the source message attachment placeholder', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
+ expect(conversation.messages.pluck(:content)).to eq(['[Intercom attachment skipped: 1]'])
+ expect(conversation.messages.first.additional_attributes.dig('source', 'attachments')).to eq(
+ [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }]
+ )
+ expect(data_import.reload.stats.dig('messages', 'imported')).to eq(1)
+ end
+ end
+
+ context 'when the Intercom source message has text and attachments' do
+ let(:conversation_payload) do
+ super().deep_merge(
+ 'source' => {
+ 'attachments' => [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }]
+ }
+ )
+ end
+
+ it 'adds an attachment omission marker to the imported message', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ message = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:source:source_1')
+ expect(message.content).to eq("Need help\n\nHello there\n\n[Intercom attachment skipped: 1]")
+ expect(message.additional_attributes.dig('source', 'attachments')).to eq(
+ [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }]
+ )
+ expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(0)
+ end
+ end
+
+ context 'when Intercom omits the conversation source' do
+ let(:conversation_payload) do
+ super().merge(
+ 'source' => nil,
+ 'first_contact_reply' => {
+ 'type' => 'whatsapp',
+ 'created_at' => 1_700_000_000,
+ 'url' => nil
+ }
+ )
+ end
+
+ it 'routes the conversation from the first contact reply type', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ inbox = account.inboxes.find_by!(name: 'Intercom Import - WhatsApp')
+ conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
+
+ expect(conversation.inbox).to eq(inbox)
+ expect(conversation.additional_attributes.dig('source', 'source_type')).to eq('whatsapp')
+ end
+ end
+
+ context 'when an Intercom chat message part cannot be imported' do
+ let(:conversation_payload) do
+ super().deep_merge(
+ 'conversation_parts' => {
+ 'conversation_parts' => [
+ {
+ 'id' => 'blank_part',
+ 'part_type' => 'comment',
+ 'body' => nil,
+ 'created_at' => 1_700_000_175,
+ 'updated_at' => 1_700_000_175,
+ 'author' => { 'type' => 'admin', 'id' => 'admin_1' },
+ 'attachments' => []
+ }
+ ]
+ }
+ )
+ end
+
+ it 'records a skip log with the Intercom message source id', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ skip_log = data_import.import_errors.skip_logs.find_by!(source_object_type: 'message')
+ expect(skip_log).to have_attributes(
+ source_object_id: 'conversation:conversation_1:part:blank_part',
+ error_code: 'DataImports::Intercom::SkippedMessage',
+ message: 'Skipped Intercom comment event blank_part: no message body or attachments to import.'
+ )
+ expect(skip_log.details).to include(
+ 'kind' => 'skipped',
+ 'reason' => 'blank_or_unsupported_intercom_part',
+ 'reason_details' => 'no message body or attachments to import',
+ 'event_name' => 'comment',
+ 'event_type' => 'comment',
+ 'author_type' => 'admin'
+ )
+ expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(1)
+ end
+
+ it 'records the skip log again for a later import run', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+ next_data_import = create(
+ :data_import, :intercom,
+ account: account
+ )
+
+ described_class.new(data_import: next_data_import).perform
+
+ skip_log = next_data_import.import_errors.skip_logs.find_by!(
+ source_object_type: 'message',
+ source_object_id: 'conversation:conversation_1:part:blank_part',
+ error_code: 'DataImports::Intercom::SkippedMessage'
+ )
+ expect(skip_log).to have_attributes(
+ source_object_id: 'conversation:conversation_1:part:blank_part',
+ error_code: 'DataImports::Intercom::SkippedMessage'
+ )
+ expect(next_data_import.reload.stats.dig('messages', 'skipped')).to eq(2)
+ end
+
+ it 'reconciles a same-run skipped mapping and missing skip log on retry', :aggregate_failures do
+ described_class.new(data_import: data_import).import_conversations_page
+ data_import.import_errors.where(source_object_type: 'message').delete_all
+ stats = data_import.reload.stats.deep_dup
+ stats['messages']['skipped'] = 0
+ data_import.update!(stats: stats)
+
+ described_class.new(data_import: data_import).import_conversations_page
+
+ expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(1)
+ expect(data_import.import_errors.skip_logs.exists?(source_object_id: 'conversation:conversation_1:part:blank_part')).to be(true)
+ end
+
+ it 'repairs a previously skipped mapping when the part is now an activity', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+ previous_skip_log = data_import.import_errors.skip_logs.find_by!(source_object_id: 'conversation:conversation_1:part:blank_part')
+ conversation_payload.dig('conversation_parts', 'conversation_parts').first.merge!(
+ 'part_type' => 'assignment',
+ 'assigned_to' => { 'name' => 'Support' }
+ )
+ next_data_import = create(:data_import, :intercom, account: account)
+
+ described_class.new(data_import: next_data_import).perform
+
+ activity = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:blank_part')
+ mapping = DataImportMapping.find_by!(
+ account: account,
+ source_provider: 'intercom',
+ source_object_type: 'message',
+ source_object_id: 'conversation:conversation_1:part:blank_part'
+ )
+ expect(activity).to be_activity
+ expect(activity.content).to eq('Intercom teammate assigned the conversation to Support')
+ expect(mapping.chatwoot_record).to eq(activity)
+ expect(data_import.import_errors.skip_logs).to include(previous_skip_log)
+ expect(next_data_import.import_errors.skip_logs.where(source_object_id: mapping.source_object_id)).to be_empty
+ end
+ end
+
+ context 'when Intercom returns bodyless lifecycle events' do
+ let(:conversation_payload) do
+ super().deep_merge(
+ 'conversation_parts' => {
+ 'total_count' => 1,
+ 'conversation_parts' => [
+ {
+ 'id' => 'assignment_part',
+ 'part_type' => 'assignment',
+ 'body' => nil,
+ 'created_at' => 1_700_000_175,
+ 'author' => { 'type' => 'admin', 'name' => 'Avery' },
+ 'assigned_to' => { 'type' => 'team', 'name' => 'Support' },
+ 'state' => 'open',
+ 'tags' => { 'tags' => [{ 'name' => 'priority' }] },
+ 'event_details' => { 'source' => 'workflow' },
+ 'app_package_code' => 'workflow'
+ }
+ ]
+ }
+ )
+ end
+
+ it 'imports events as public activity messages with source metadata', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ activity = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:assignment_part')
+ expect(activity).to have_attributes(
+ message_type: 'activity',
+ content: 'Avery assigned the conversation to Support',
+ private: false,
+ sender: nil,
+ created_at: Time.zone.at(1_700_000_175)
+ )
+ expect(activity.additional_attributes['source']).to include(
+ 'part_type' => 'assignment',
+ 'assigned_to' => include('name' => 'Support'),
+ 'state' => 'open',
+ 'event_details' => include('source' => 'workflow'),
+ 'app_package_code' => 'workflow'
+ )
+ expect(data_import.reload.stats['messages']).to include('imported' => 2, 'skipped' => 0, 'total' => 2)
+ expect(data_import.import_errors.skip_logs).to be_empty
+ end
+ end
+
+ context 'when Intercom omits older conversation parts from the retrieved conversation' do
+ let(:conversation_payload) do
+ super().deep_merge(
+ 'conversation_parts' => {
+ 'total_count' => 503
+ },
+ 'statistics' => {
+ 'count_conversation_parts' => 503
+ }
+ )
+ end
+
+ it 'records an incomplete import error and completes with errors', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ error = data_import.import_errors.non_skip_logs.find_by!(
+ source_object_type: 'conversation',
+ source_object_id: 'conversation_1',
+ error_code: 'DataImports::Intercom::TruncatedConversationParts'
+ )
+ expect(error.message).to eq('Intercom returned 2 of 503 conversation parts.')
+ expect(error.details).to include(
+ 'kind' => 'incomplete',
+ 'imported_parts_count' => 2,
+ 'total_parts_count' => 503
+ )
+ expect(data_import.reload).to be_completed_with_errors
+ expect(data_import.stats.dig('errors', 'count')).to eq(1)
+ end
+ end
+
+ context 'when the conversation parts total matches the returned parts' do
+ let(:conversation_payload) do
+ super().deep_merge(
+ 'conversation_parts' => {
+ 'total_count' => 2
+ },
+ 'statistics' => {
+ 'count_conversation_parts' => 2
+ }
+ )
+ end
+
+ it 'does not record a truncated parts error', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ expect(data_import.import_errors.non_skip_logs).to be_empty
+ expect(data_import.reload).to be_completed
+ expect(data_import.stats.dig('errors', 'count')).to eq(0)
+ end
+ end
+
+ context 'when Intercom statistics count is higher than the conversation parts total' do
+ let(:conversation_payload) do
+ super().deep_merge(
+ 'source' => {},
+ 'conversation_parts' => {
+ 'total_count' => 2
+ },
+ 'statistics' => {
+ 'count_conversation_parts' => 3
+ }
+ )
+ end
+
+ it 'trusts the returned conversation parts total over the statistics counter', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ expect(data_import.import_errors.non_skip_logs).to be_empty
+ expect(data_import.reload).to be_completed
+ expect(data_import.stats.dig('errors', 'count')).to eq(0)
+ end
+ end
+
+ context 'when a specific Intercom message part fails to persist' do
+ let(:conversation_payload) do
+ super().deep_merge(
+ 'conversation_parts' => {
+ 'conversation_parts' => [
+ {
+ 'id' => 'bad_part',
+ 'part_type' => 'comment',
+ 'body' => 'Message that cannot be stored
',
+ 'created_at' => 1_700_000_175,
+ 'updated_at' => 1_700_000_175,
+ 'author' => { 'type' => 'admin', 'id' => 'admin_1' },
+ 'attachments' => []
+ }
+ ]
+ }
+ )
+ end
+
+ before do
+ allow(Message).to receive(:insert_all!).and_wrap_original do |method, records, **kwargs|
+ raise ActiveRecord::StatementInvalid, 'bad message' if records.first[:source_id] == 'intercom:conversation:conversation_1:part:bad_part'
+
+ method.call(records, **kwargs)
+ end
+ end
+
+ it 'records a skip log with the Intercom message part id', :aggregate_failures do
+ described_class.new(data_import: data_import).perform
+
+ skip_log = data_import.import_errors.skip_logs.find_by!(source_object_type: 'message')
+ expect(skip_log).to have_attributes(
+ source_object_id: 'conversation:conversation_1:part:bad_part',
+ error_code: 'ActiveRecord::StatementInvalid',
+ message: 'bad message'
+ )
+ expect(skip_log.details).to include(
+ 'kind' => 'failed',
+ 'conversation_id' => 'intercom:conversation_1'
+ )
+ expect(data_import.reload).to be_completed_with_errors
+ expect(data_import.stats.dig('errors', 'count')).to eq(1)
+ end
+ end
+end
diff --git a/spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb b/spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb
new file mode 100644
index 000000000..c1a7baab5
--- /dev/null
+++ b/spec/services/data_imports/intercom/placeholder_inbox_builder_spec.rb
@@ -0,0 +1,33 @@
+require 'rails_helper'
+
+RSpec.describe DataImports::Intercom::PlaceholderInboxBuilder do
+ let(:account) { create(:account) }
+
+ describe '#inbox_for' do
+ it 'creates a source-bucket API inbox for an Intercom conversation source' do
+ inbox = described_class.new(account: account).inbox_for('email')
+
+ expect(inbox.name).to eq('Intercom Import - Email')
+ expect(inbox.channel).to be_a(Channel::Api)
+ expect(inbox.enable_auto_assignment).to be(false)
+ expect(inbox.allow_messages_after_resolved).to be(false)
+ expect(inbox.channel.additional_attributes).to include(
+ 'source_provider' => 'intercom',
+ 'source_bucket' => 'email',
+ 'import_placeholder' => true,
+ 'agent_reply_time_window' => 1
+ )
+ end
+
+ it 'reuses an existing placeholder inbox for the same source bucket' do
+ builder = described_class.new(account: account)
+
+ first_inbox = builder.inbox_for('phone_call')
+ expect(account).not_to receive(:inboxes)
+ second_inbox = builder.inbox_for('phone_switch')
+
+ expect(second_inbox).to eq(first_inbox)
+ expect(Inbox.where(account: account, channel_type: 'Channel::Api').count).to eq(1)
+ end
+ end
+end
diff --git a/spec/services/data_imports/intercom/restart_service_spec.rb b/spec/services/data_imports/intercom/restart_service_spec.rb
new file mode 100644
index 000000000..5dbc8f5ab
--- /dev/null
+++ b/spec/services/data_imports/intercom/restart_service_spec.rb
@@ -0,0 +1,64 @@
+require 'rails_helper'
+
+RSpec.describe DataImports::Intercom::RestartService do
+ let(:account) { create(:account) }
+ let(:data_import) { create(:data_import, :intercom, account: account, status: :abandoned, abandoned_at: 1.hour.ago) }
+
+ it 'prepares a failed or abandoned import for another run', :aggregate_failures do
+ data_import.update!(
+ stats: {
+ 'contacts' => { 'imported' => 1, 'skipped' => 9, 'total' => 10 },
+ 'conversations' => { 'imported' => 2, 'skipped' => 8, 'total' => 10 },
+ 'messages' => { 'imported' => 3, 'skipped' => 7, 'total' => 10 },
+ 'errors' => { 'count' => 6 }
+ }
+ )
+ data_import.import_errors.create!(error_code: 'StandardError', message: 'old run error')
+ data_import.import_errors.create!(
+ error_code: 'ContactFailed',
+ message: 'old contact error',
+ source_object_type: 'contact',
+ details: { kind: 'failed' }
+ )
+ retained_skip_log = data_import.import_errors.create!(
+ error_code: DataImports::Intercom::Importer::ALREADY_IMPORTED_ERROR_CODE,
+ message: 'old skip log',
+ source_object_type: 'contact',
+ details: { kind: 'skipped' }
+ )
+ previous_run_id = data_import.assign_active_intercom_import_run_id
+ data_import.save!
+ service = described_class.new(account: account, data_import: data_import)
+
+ expect(service.perform).to eq(:enqueue)
+ expect(service.data_import).to be_pending
+ expect(service.data_import.abandoned_at).to be_nil
+ expect(service.data_import.started_at).to be_nil
+ expect(service.data_import.active_intercom_import_run_id).not_to eq(previous_run_id)
+ expect(service.data_import.import_errors).to contain_exactly(retained_skip_log)
+ expect(service.data_import.stats).to eq(
+ 'contacts' => { 'imported' => 1, 'skipped' => 1, 'total' => 10 },
+ 'conversations' => { 'imported' => 2, 'skipped' => 0, 'total' => 10 },
+ 'messages' => { 'imported' => 3, 'skipped' => 0, 'total' => 10 },
+ 'errors' => { 'count' => 0 }
+ )
+ end
+
+ it 'returns the active import instead of restarting another import', :aggregate_failures do
+ active_import = create(:data_import, :intercom, account: account, status: :processing)
+ service = described_class.new(account: account, data_import: data_import)
+
+ expect(service.perform).to eq(:render_show)
+ expect(service.data_import).to eq(active_import)
+ expect(data_import.reload).to be_abandoned
+ end
+
+ it 'does not restart when the stored access token is missing' do
+ data_import.update!(access_token: nil)
+
+ result = described_class.new(account: account, data_import: data_import).perform
+
+ expect(result).to eq(:access_token_missing)
+ expect(data_import.reload).to be_abandoned
+ end
+end
diff --git a/spec/services/data_imports/intercom/source_bucket_spec.rb b/spec/services/data_imports/intercom/source_bucket_spec.rb
new file mode 100644
index 000000000..b3db4294c
--- /dev/null
+++ b/spec/services/data_imports/intercom/source_bucket_spec.rb
@@ -0,0 +1,17 @@
+require 'rails_helper'
+
+RSpec.describe DataImports::Intercom::SourceBucket do
+ describe '.for' do
+ it 'maps Intercom source types to Chatwoot inbox buckets' do
+ expect(described_class.for('email')).to eq({ key: 'email', name: 'Email' })
+ expect(described_class.for('phone_switch')).to eq({ key: 'phone', name: 'Phone' })
+ expect(described_class.for('inapp')).to eq({ key: 'messenger', name: 'Messenger' })
+ expect(described_class.for('messenger')).to eq({ key: 'messenger', name: 'Messenger' })
+ expect(described_class.for('push')).to eq({ key: 'messenger', name: 'Messenger' })
+ end
+
+ it 'uses an unknown bucket for unsupported source types' do
+ expect(described_class.for('unsupported_source')).to eq({ key: 'unknown', name: 'Unknown' })
+ end
+ end
+end
diff --git a/spec/services/labels/destroy_service_spec.rb b/spec/services/labels/destroy_service_spec.rb
index 7d06b72d0..7f14273b3 100644
--- a/spec/services/labels/destroy_service_spec.rb
+++ b/spec/services/labels/destroy_service_spec.rb
@@ -6,6 +6,7 @@ describe Labels::DestroyService do
let(:label) { create(:label, account: account) }
let(:contact) { conversation.contact }
let(:label_deleted_at) { Time.zone.parse('2026-05-07 10:00:00 UTC') }
+ let(:store) { Conversations::UnreadCounts::FilteredCountStore }
before do
conversation.label_list.add(label.title)
@@ -74,6 +75,18 @@ describe Labels::DestroyService do
).perform
end
+ it 'invalidates filtered counts when conversation label associations are removed' do
+ account.enable_features!(:unread_count_for_filters)
+
+ expect do
+ described_class.new(
+ label_title: label.title,
+ account_id: account.id,
+ label_deleted_at: label_deleted_at
+ ).perform
+ end.to change { store.conversation_version(account.id) }.by(1)
+ end
+
it 'does not remove label associations created after the label was deleted' do
other_conversation = create(:conversation, account: account)
other_conversation.label_list.add(label.title)
diff --git a/spec/services/whatsapp/channel_creation_service_spec.rb b/spec/services/whatsapp/channel_creation_service_spec.rb
index 983af6c78..e7016f6a4 100644
--- a/spec/services/whatsapp/channel_creation_service_spec.rb
+++ b/spec/services/whatsapp/channel_creation_service_spec.rb
@@ -60,6 +60,17 @@ describe Whatsapp::ChannelCreationService do
expect(inbox.name).to eq('Test Business WhatsApp')
expect(inbox.account).to eq(account)
end
+
+ it 'does not leave an orphan channel when inbox creation fails' do
+ allow(Inbox).to receive(:create!).and_wrap_original do |method, *args|
+ method.call(*args)
+ raise ActiveRecord::RecordInvalid, Inbox.new
+ end
+
+ expect do
+ expect { service.perform }.to raise_error(ActiveRecord::RecordInvalid)
+ end.not_to change(Channel::Whatsapp, :count)
+ end
end
context 'when channel already exists for the phone number' do
diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb
index be94f3c44..a5bdeef0b 100644
--- a/spec/services/whatsapp/webhook_teardown_service_spec.rb
+++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb
@@ -51,18 +51,41 @@ RSpec.describe Whatsapp::WebhookTeardownService do
end
end
- context 'when channel is whatsapp_cloud but not embedded_signup' do
+ context 'when channel is whatsapp_cloud with manual setup' do
before do
+ allow(channel).to receive(:setup_webhooks).and_return(true)
+
channel.update!(
provider: 'whatsapp_cloud',
- provider_config: { 'source' => 'manual' }
+ provider_config: {
+ 'source' => 'manual',
+ 'phone_number_id' => 'manual_phone_id',
+ 'business_account_id' => 'manual_waba_id',
+ 'api_key' => 'manual_api_key'
+ }
)
end
- it 'does not attempt to unsubscribe webhook' do
- expect(Whatsapp::FacebookApiClient).not_to receive(:new)
+ it 'clears the phone number callback override' do
+ api_client = instance_double(Whatsapp::FacebookApiClient)
+ allow(Whatsapp::FacebookApiClient).to receive(:new).with('manual_api_key').and_return(api_client)
+ allow(api_client).to receive(:clear_phone_number_callback_override).with('manual_phone_id')
service.perform
+
+ expect(api_client).to have_received(:clear_phone_number_callback_override).with('manual_phone_id')
+ end
+
+ # The manual token belongs to the customer's own Meta app, so its WABA subscription is not ours to remove.
+ it 'does not unsubscribe the app from the WABA' do
+ api_client = instance_double(Whatsapp::FacebookApiClient)
+ allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
+ allow(api_client).to receive(:clear_phone_number_callback_override)
+ allow(api_client).to receive(:unsubscribe_app_from_waba)
+
+ service.perform
+
+ expect(api_client).not_to have_received(:unsubscribe_app_from_waba)
end
end