Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f0fac394 | ||
|
|
3fce56c98f | ||
|
|
e4649b4ce7 | ||
|
|
86da6925b7 | ||
|
|
14abb3707c | ||
|
|
83a407c530 | ||
|
|
dae0366420 |
+154
-101
@@ -1,123 +1,176 @@
|
||||
# TODO: logic is written tailored to contact import since its the only import available
|
||||
# let's break this logic and clean this up in future
|
||||
# let's break this logic and clean this up in future
|
||||
|
||||
class DataImportJob < ApplicationJob
|
||||
queue_as :low
|
||||
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
|
||||
class DataImportJob < ApplicationJob
|
||||
queue_as :low
|
||||
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
|
||||
|
||||
def perform(data_import)
|
||||
@data_import = data_import
|
||||
@contact_manager = DataImport::ContactManager.new(@data_import.account)
|
||||
begin
|
||||
process_import_file
|
||||
send_import_notification_to_admin
|
||||
rescue CSV::MalformedCSVError => e
|
||||
handle_csv_error(e)
|
||||
def perform(data_import)
|
||||
@data_import = data_import
|
||||
@contact_manager = DataImport::ContactManager.new(@data_import.account)
|
||||
begin
|
||||
process_import_file
|
||||
send_import_notification_to_admin
|
||||
rescue CSV::MalformedCSVError => e
|
||||
handle_csv_error(e)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
private
|
||||
|
||||
def process_import_file
|
||||
@data_import.update!(status: :processing)
|
||||
contacts, rejected_contacts = parse_csv_and_build_contacts
|
||||
def process_import_file
|
||||
@data_import.update!(status: :processing)
|
||||
identifiable_contacts, non_identifiable_contacts, rejected_contacts, total_csv_records = parse_csv_and_build_contacts
|
||||
|
||||
import_contacts(contacts)
|
||||
update_data_import_status(contacts.length, rejected_contacts.length)
|
||||
save_failed_records_csv(rejected_contacts)
|
||||
end
|
||||
all_valid_contacts = identifiable_contacts + non_identifiable_contacts
|
||||
|
||||
def parse_csv_and_build_contacts
|
||||
contacts = []
|
||||
rejected_contacts = []
|
||||
# Count contacts before import
|
||||
contacts_before = @data_import.account.contacts.count
|
||||
|
||||
with_import_file do |file|
|
||||
csv_reader(file).each do |row|
|
||||
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
|
||||
if current_contact.valid?
|
||||
contacts << current_contact
|
||||
else
|
||||
append_rejected_contact(row, current_contact, rejected_contacts)
|
||||
import_contacts(all_valid_contacts)
|
||||
|
||||
# Count contacts after import to get actual imported count
|
||||
contacts_after = @data_import.account.contacts.count
|
||||
actual_imported_count = contacts_after - contacts_before
|
||||
|
||||
# Calculate actual imported counts based on what we built vs what was imported
|
||||
actual_identifiable_count, actual_non_identifiable_count = calculate_actual_import_counts(
|
||||
identifiable_contacts, non_identifiable_contacts, actual_imported_count
|
||||
)
|
||||
|
||||
update_data_import_status(actual_identifiable_count, actual_non_identifiable_count, rejected_contacts.length, total_csv_records)
|
||||
save_failed_records_csv(rejected_contacts)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def parse_csv_and_build_contacts
|
||||
identifiable_contacts = []
|
||||
non_identifiable_contacts = []
|
||||
rejected_contacts = []
|
||||
total_csv_records = 0
|
||||
|
||||
with_import_file do |file|
|
||||
csv_reader(file).each do |row|
|
||||
total_csv_records += 1
|
||||
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
|
||||
if current_contact.valid?
|
||||
is_identifiable = @contact_manager.contact_identifiable?(current_contact)
|
||||
|
||||
if is_identifiable
|
||||
identifiable_contacts << current_contact
|
||||
else
|
||||
non_identifiable_contacts << current_contact
|
||||
end
|
||||
else
|
||||
append_rejected_contact(row, current_contact, rejected_contacts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
[identifiable_contacts, non_identifiable_contacts, rejected_contacts, total_csv_records]
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def append_rejected_contact(row, contact, rejected_contacts)
|
||||
row['errors'] = contact.errors.full_messages.join(', ')
|
||||
rejected_contacts << row
|
||||
end
|
||||
|
||||
def import_contacts(contacts)
|
||||
# Returns ActiveRecord::Import::Result with info about what was actually imported
|
||||
Contact.import(contacts, synchronize: contacts, on_duplicate_key_ignore: true, track_validation_failures: true, validate: true, batch_size: 1000)
|
||||
end
|
||||
|
||||
def calculate_actual_import_counts(identifiable_contacts, non_identifiable_contacts, actual_imported_count)
|
||||
Rails.logger.info "Actual imported: #{actual_imported_count}, expected identifiable: #{identifiable_contacts.length}, " \
|
||||
"expected non-identifiable: #{non_identifiable_contacts.length}"
|
||||
|
||||
expected_count = identifiable_contacts.length + non_identifiable_contacts.length
|
||||
|
||||
if actual_imported_count <= expected_count
|
||||
# Prioritize identifiable contacts in the count since they're more important
|
||||
actual_identifiable_count = [actual_imported_count, identifiable_contacts.length].min
|
||||
actual_non_identifiable_count = actual_imported_count - actual_identifiable_count
|
||||
else
|
||||
# This shouldn't happen, but fallback to expected counts
|
||||
actual_identifiable_count = identifiable_contacts.length
|
||||
actual_non_identifiable_count = non_identifiable_contacts.length
|
||||
end
|
||||
|
||||
Rails.logger.info "Final counts: identifiable=#{actual_identifiable_count}, non-identifiable=#{actual_non_identifiable_count}"
|
||||
|
||||
[actual_identifiable_count, actual_non_identifiable_count]
|
||||
end
|
||||
|
||||
def update_data_import_status(identifiable_records, non_identifiable_records, _rejected_records, total_csv_records)
|
||||
processed_records = identifiable_records + non_identifiable_records
|
||||
@data_import.update!(
|
||||
status: :completed,
|
||||
processed_records: processed_records,
|
||||
non_identifiable_records: non_identifiable_records,
|
||||
total_records: total_csv_records
|
||||
)
|
||||
end
|
||||
|
||||
def save_failed_records_csv(rejected_contacts)
|
||||
csv_data = generate_csv_data(rejected_contacts)
|
||||
return if csv_data.blank?
|
||||
|
||||
@data_import.failed_records.attach(io: StringIO.new(csv_data), filename: "#{Time.zone.today.strftime('%Y%m%d')}_contacts.csv",
|
||||
content_type: 'text/csv')
|
||||
send_import_notification_to_admin
|
||||
end
|
||||
|
||||
def generate_csv_data(rejected_contacts)
|
||||
headers = csv_headers
|
||||
headers << 'errors'
|
||||
return if rejected_contacts.blank?
|
||||
|
||||
CSV.generate do |csv|
|
||||
csv << headers
|
||||
rejected_contacts.each do |record|
|
||||
csv << record
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
[contacts, rejected_contacts]
|
||||
end
|
||||
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
|
||||
@data_import.update!(status: :failed)
|
||||
send_import_failed_notification_to_admin
|
||||
end
|
||||
|
||||
def append_rejected_contact(row, contact, rejected_contacts)
|
||||
row['errors'] = contact.errors.full_messages.join(', ')
|
||||
rejected_contacts << row
|
||||
end
|
||||
def send_import_notification_to_admin
|
||||
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
|
||||
end
|
||||
|
||||
def import_contacts(contacts)
|
||||
# <struct ActiveRecord::Import::Result failed_instances=[], num_inserts=1, ids=[444, 445], results=[]>
|
||||
Contact.import(contacts, synchronize: contacts, on_duplicate_key_ignore: true, track_validation_failures: true, validate: true, batch_size: 1000)
|
||||
end
|
||||
def send_import_failed_notification_to_admin
|
||||
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
|
||||
end
|
||||
|
||||
def update_data_import_status(processed_records, rejected_records)
|
||||
@data_import.update!(status: :completed, processed_records: processed_records, total_records: processed_records + rejected_records)
|
||||
end
|
||||
def csv_headers
|
||||
header_row = nil
|
||||
with_import_file do |file|
|
||||
header_row = csv_reader(file).first
|
||||
end
|
||||
header_row&.headers || []
|
||||
end
|
||||
|
||||
def save_failed_records_csv(rejected_contacts)
|
||||
csv_data = generate_csv_data(rejected_contacts)
|
||||
return if csv_data.blank?
|
||||
def csv_reader(file)
|
||||
file.rewind
|
||||
raw_data = file.read
|
||||
utf8_data = raw_data.force_encoding('UTF-8')
|
||||
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
|
||||
|
||||
@data_import.failed_records.attach(io: StringIO.new(csv_data), filename: "#{Time.zone.today.strftime('%Y%m%d')}_contacts.csv",
|
||||
content_type: 'text/csv')
|
||||
send_import_notification_to_admin
|
||||
end
|
||||
CSV.new(StringIO.new(clean_data), headers: true)
|
||||
end
|
||||
|
||||
def generate_csv_data(rejected_contacts)
|
||||
headers = csv_headers
|
||||
headers << 'errors'
|
||||
return if rejected_contacts.blank?
|
||||
def with_import_file
|
||||
temp_dir = Rails.root.join('tmp/imports')
|
||||
FileUtils.mkdir_p(temp_dir)
|
||||
|
||||
CSV.generate do |csv|
|
||||
csv << headers
|
||||
rejected_contacts.each do |record|
|
||||
csv << record
|
||||
@data_import.import_file.open(tmpdir: temp_dir) do |file|
|
||||
file.binmode
|
||||
yield file
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
|
||||
@data_import.update!(status: :failed)
|
||||
send_import_failed_notification_to_admin
|
||||
end
|
||||
|
||||
def send_import_notification_to_admin
|
||||
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
|
||||
end
|
||||
|
||||
def send_import_failed_notification_to_admin
|
||||
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
|
||||
end
|
||||
|
||||
def csv_headers
|
||||
header_row = nil
|
||||
with_import_file do |file|
|
||||
header_row = csv_reader(file).first
|
||||
end
|
||||
header_row&.headers || []
|
||||
end
|
||||
|
||||
def csv_reader(file)
|
||||
file.rewind
|
||||
raw_data = file.read
|
||||
utf8_data = raw_data.force_encoding('UTF-8')
|
||||
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
|
||||
|
||||
CSV.new(StringIO.new(clean_data), headers: true)
|
||||
end
|
||||
|
||||
def with_import_file
|
||||
temp_dir = Rails.root.join('tmp/imports')
|
||||
FileUtils.mkdir_p(temp_dir)
|
||||
|
||||
@data_import.import_file.open(tmpdir: temp_dir) do |file|
|
||||
file.binmode
|
||||
yield file
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -32,9 +32,16 @@ class AdministratorNotifications::AccountNotificationMailer < AdministratorNotif
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{resource.account.id}/contacts"
|
||||
end
|
||||
|
||||
# Calculate metrics with backward compatibility
|
||||
non_identifiable_count = resource.respond_to?(:non_identifiable_records) ? resource.non_identifiable_records : 0
|
||||
identifiable_count = resource.processed_records - non_identifiable_count
|
||||
failed_count = resource.total_records - resource.processed_records
|
||||
|
||||
meta = {
|
||||
'failed_contacts' => resource.total_records - resource.processed_records,
|
||||
'imported_contacts' => resource.processed_records
|
||||
'identifiable_contacts' => identifiable_count,
|
||||
'non_identifiable_contacts' => non_identifiable_count,
|
||||
'failed_contacts' => failed_count,
|
||||
'total_contacts' => resource.total_records
|
||||
}
|
||||
|
||||
send_notification(subject, action_url: action_url, meta: meta)
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
#
|
||||
# Table name: data_imports
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# data_type :string not null
|
||||
# processed_records :integer
|
||||
# processing_errors :text
|
||||
# status :integer default("pending"), not null
|
||||
# total_records :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# data_type :string not null
|
||||
# non_identifiable_records :integer default(0), not null
|
||||
# processed_records :integer
|
||||
# processing_errors :text
|
||||
# status :integer default("pending"), not null
|
||||
# total_records :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -9,6 +9,10 @@ class DataImport::ContactManager
|
||||
contact
|
||||
end
|
||||
|
||||
def contact_identifiable?(contact)
|
||||
contact.email.present? || contact.phone_number.present? || contact.identifier.present?
|
||||
end
|
||||
|
||||
def find_or_initialize_contact(params)
|
||||
contact = find_existing_contact(params)
|
||||
contact_params = params.slice(:email, :identifier, :phone_number)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
class MessageTemplates::Template::OutOfOffice
|
||||
pattr_initialize [:conversation!]
|
||||
|
||||
def self.perform_if_applicable(conversation)
|
||||
inbox = conversation.inbox
|
||||
return unless inbox.out_of_office?
|
||||
return if inbox.out_of_office_message.blank?
|
||||
|
||||
new(conversation: conversation).perform
|
||||
end
|
||||
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(out_of_office_message_params)
|
||||
|
||||
+15
-4
@@ -1,10 +1,21 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>Your contact import has been completed. Please check the contacts tab to view the imported contacts.</p>
|
||||
<p>Your contact import has been completed. Here's a summary of the results:</p>
|
||||
|
||||
<p>Number of records imported: {{meta['imported_contacts']}}</p>
|
||||
<p><strong>Contacts imported successfully:</strong> {{meta['identifiable_contacts']}}</p>
|
||||
<p><em>These contacts are visible in your contacts list and can be used for conversations.</em></p>
|
||||
|
||||
<p>Number of records failed: {{meta['failed_contacts']}}</p>
|
||||
{% if meta['non_identifiable_contacts'] > 0 %}
|
||||
<p><strong>Contacts created but not visible:</strong> {{meta['non_identifiable_contacts']}}</p>
|
||||
<p><em>These contacts were created successfully but don't have email, phone number, or identifier, so they won't appear in your contacts list until you add this information.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{% if meta['failed_contacts'] > 0 %}
|
||||
<p><strong>Contacts that failed to import:</strong> {{meta['failed_contacts']}}</p>
|
||||
<p><em>These contacts had validation errors and could not be imported.</em></p>
|
||||
{% endif %}
|
||||
|
||||
<p><strong>Total contacts processed:</strong> {{meta['total_contacts']}}</p>
|
||||
|
||||
{% if meta['failed_contacts'] == 0 %}
|
||||
<p>
|
||||
@@ -12,6 +23,6 @@
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
Click <a href="{{ action_url }}" target="_blank">here</a> to view failed records.
|
||||
Click <a href="{{ action_url }}" target="_blank">here</a> to download the failed records and see specific error messages.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddNonIdentifiableRecordsToDataImports < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :data_imports, :non_identifiable_records, :integer, default: 0, null: false
|
||||
end
|
||||
end
|
||||
+127
-1237
File diff suppressed because it is too large
Load Diff
@@ -87,10 +87,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
I18n.with_locale(@assistant.account.locale) do
|
||||
create_handoff_message
|
||||
@conversation.bot_handoff!
|
||||
send_out_of_office_message_if_applicable
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def send_out_of_office_message_if_applicable
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
|
||||
end
|
||||
|
||||
def create_handoff_message
|
||||
create_outgoing_message(
|
||||
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
|
||||
|
||||
@@ -9,6 +9,24 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
schedule_captain_response
|
||||
end
|
||||
|
||||
def should_send_greeting?
|
||||
return false if captain_handling_conversation?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def should_send_out_of_office_message?
|
||||
return false if captain_handling_conversation?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def should_send_email_collect?
|
||||
return false if captain_handling_conversation?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def schedule_captain_response
|
||||
@@ -46,5 +64,14 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
content: 'Transferring to another agent for further assistance.'
|
||||
)
|
||||
conversation.bot_handoff!
|
||||
send_out_of_office_message_after_handoff
|
||||
end
|
||||
|
||||
def send_out_of_office_message_after_handoff
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
|
||||
def captain_handling_conversation?
|
||||
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,6 +36,13 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
|
||||
# Trigger the bot handoff (sets status to open + dispatches events)
|
||||
conversation.bot_handoff!
|
||||
|
||||
# Send out of office message if applicable (since template messages were suppressed while Captain was handling)
|
||||
send_out_of_office_message_if_applicable(conversation)
|
||||
end
|
||||
|
||||
def send_out_of_office_message_if_applicable(conversation)
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
|
||||
# TODO: Future enhancement - Add team assignment capability
|
||||
|
||||
@@ -229,4 +229,106 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
expect(described_class::MAX_MESSAGE_LENGTH).to eq(10_000)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'out of office message after handoff' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) }
|
||||
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||
end
|
||||
|
||||
context 'when handoff occurs outside business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed. Please leave your email.'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
|
||||
end
|
||||
|
||||
it 'sends out of office message after handoff' do
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.to change { conversation.messages.template.count }.by(1)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
ooo_message = conversation.messages.template.last
|
||||
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handoff occurs within business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed.'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
open_all_day: true,
|
||||
closed_all_day: false
|
||||
)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
|
||||
end
|
||||
|
||||
it 'does not send out of office message after handoff' do
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handoff occurs due to error outside business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed.'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_raise(StandardError, 'API error')
|
||||
end
|
||||
|
||||
it 'sends out of office message after error-triggered handoff' do
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.to change { conversation.messages.template.count }.by(1)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
ooo_message = conversation.messages.template.last
|
||||
expect(ooo_message.content).to eq('We are currently closed.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no out of office message is configured' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: nil
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
|
||||
end
|
||||
|
||||
it 'does not send out of office message' do
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -163,4 +163,66 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'out of office message after handoff' do
|
||||
context 'when outside business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed. Please leave your email.'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends out of office message after handoff' do
|
||||
expect do
|
||||
tool.perform(tool_context, reason: 'Customer needs help')
|
||||
end.to change { conversation.messages.template.count }.by(1)
|
||||
|
||||
ooo_message = conversation.messages.template.last
|
||||
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when within business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed.'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
open_all_day: true,
|
||||
closed_all_day: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not send out of office message after handoff' do
|
||||
expect do
|
||||
tool.perform(tool_context, reason: 'Customer needs help')
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no out of office message is configured' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: nil
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not send out of office message' do
|
||||
expect do
|
||||
tool.perform(tool_context, reason: 'Customer needs help')
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe MessageTemplates::HookExecutionService do
|
||||
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: account, contact: contact, status: :pending) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
|
||||
end
|
||||
|
||||
context 'when captain assistant is configured' do
|
||||
context 'when within business hours' do
|
||||
before do
|
||||
inbox.update!(working_hours_enabled: true)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
open_all_day: true,
|
||||
closed_all_day: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'schedules captain response job for incoming messages on pending conversations' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when outside business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'schedules captain response job outside business hours (Captain always responds when configured)' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end
|
||||
|
||||
it 'performs captain handoff when quota is exceeded (OOO template will kick in after handoff)' do
|
||||
account.update!(
|
||||
limits: { 'captain_responses' => 100 },
|
||||
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
|
||||
)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'does not send out of office message when Captain is handling' do
|
||||
out_of_office_service = instance_double(MessageTemplates::Template::OutOfOffice)
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
|
||||
allow(out_of_office_service).to receive(:perform).and_return(true)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business hours are not enabled' do
|
||||
before do
|
||||
inbox.update!(working_hours_enabled: false)
|
||||
end
|
||||
|
||||
it 'schedules captain response job regardless of time' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain quota is exceeded within business hours' do
|
||||
before do
|
||||
inbox.update!(working_hours_enabled: true)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
open_all_day: true,
|
||||
closed_all_day: false
|
||||
)
|
||||
|
||||
account.update!(
|
||||
limits: { 'captain_responses' => 100 },
|
||||
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
|
||||
)
|
||||
end
|
||||
|
||||
it 'performs handoff within business hours when quota exceeded' do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no captain assistant is configured' do
|
||||
before do
|
||||
CaptainInbox.where(inbox: inbox).destroy_all
|
||||
end
|
||||
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is not pending' do
|
||||
before do
|
||||
conversation.update!(status: :open)
|
||||
end
|
||||
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is outgoing' do
|
||||
it 'does not schedule captain response job' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :outgoing)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when greeting and out of office messages with Captain enabled' do
|
||||
context 'when conversation is pending (Captain is handling)' do
|
||||
before do
|
||||
conversation.update!(status: :pending)
|
||||
end
|
||||
|
||||
it 'does not create greeting message in conversation' do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end.not_to(change { conversation.reload.messages.template.count })
|
||||
end
|
||||
|
||||
it 'does not create out of office message in conversation' do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed',
|
||||
enable_email_collect: false
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end.not_to(change { conversation.reload.messages.template.count })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is open (transferred to agent)' do
|
||||
before do
|
||||
conversation.update!(status: :open)
|
||||
end
|
||||
|
||||
it 'creates greeting message in conversation' do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
greeting_message = conversation.reload.messages.template.last
|
||||
expect(greeting_message.content).to eq('Hello! How can we help you?')
|
||||
end
|
||||
|
||||
it 'creates out of office message when outside business hours' do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed',
|
||||
enable_email_collect: false
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
out_of_office_message = conversation.reload.messages.template.last
|
||||
expect(out_of_office_message.content).to eq('We are currently closed')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Captain is not configured' do
|
||||
before do
|
||||
CaptainInbox.where(inbox: inbox).destroy_all
|
||||
end
|
||||
|
||||
it 'creates greeting message in conversation' do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
greeting_message = conversation.reload.messages.template.last
|
||||
expect(greeting_message.content).to eq('Hello! How can we help you?')
|
||||
end
|
||||
|
||||
it 'creates out of office message when outside business hours' do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed',
|
||||
enable_email_collect: false
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end.to change { conversation.reload.messages.template.count }.by(1)
|
||||
|
||||
out_of_office_message = conversation.reload.messages.template.last
|
||||
expect(out_of_office_message.content).to eq('We are currently closed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Captain quota is exceeded and handoff happens' do
|
||||
before do
|
||||
account.update!(
|
||||
limits: { 'captain_responses' => 100 },
|
||||
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
|
||||
)
|
||||
end
|
||||
|
||||
context 'when outside business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed. Please leave your email.',
|
||||
enable_email_collect: false
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends out of office message after handoff due to quota exceeded' do
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end.to change { conversation.messages.template.count }.by(1)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
ooo_message = conversation.messages.template.last
|
||||
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when within business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed.',
|
||||
enable_email_collect: false
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
open_all_day: true,
|
||||
closed_all_day: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not send out of office message after handoff' do
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -62,7 +62,8 @@ RSpec.describe DataImportJob do
|
||||
described_class.perform_now(invalid_data_import)
|
||||
expect(invalid_data_import.account.contacts.count).to eq(csv_length - 1)
|
||||
expect(invalid_data_import.reload.total_records).to eq(csv_length)
|
||||
expect(invalid_data_import.reload.processed_records).to eq(csv_length)
|
||||
# Only 2 contacts actually imported (duplicate email fails for the 3rd)
|
||||
expect(invalid_data_import.reload.processed_records).to eq(csv_length - 1)
|
||||
end
|
||||
|
||||
it 'will preserve emojis' do
|
||||
@@ -150,11 +151,48 @@ RSpec.describe DataImportJob do
|
||||
expect(phone_contact.reload.email).to be_nil
|
||||
expect(email_contact.reload.phone_number).to be_nil
|
||||
expect(existing_data_import.total_records).to eq(csv_length)
|
||||
# Only 2 contacts imported (1 has conflicting phone/email so fails)
|
||||
expect(existing_data_import.processed_records).to eq(csv_length - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non_identifiable_records tracking' do
|
||||
it 'tracks non-identifiable records correctly' do
|
||||
# Test that the new non_identifiable_records field is properly tracked
|
||||
described_class.perform_now(data_import)
|
||||
|
||||
data_import.reload
|
||||
expect(data_import.non_identifiable_records).to eq(0) # Default CSV has all identifiable contacts
|
||||
end
|
||||
end
|
||||
|
||||
context 'when all contacts have duplicate identifiers' do
|
||||
it 'imports only the first contact and reports failures accurately' do
|
||||
duplicate_data = [
|
||||
%w[id name email identifier phone_number],
|
||||
['1', 'First Contact', '', 'DUPLICATE-ID', '+918484848484'], # Will succeed
|
||||
['2', 'Second Contact', '', 'DUPLICATE-ID', '+918484848485'], # Will fail (duplicate identifier)
|
||||
['3', 'Third Contact', '', 'DUPLICATE-ID', '+918484848486'] # Will fail (duplicate identifier)
|
||||
]
|
||||
|
||||
duplicate_data_import = create(:data_import, import_file: generate_csv_file(duplicate_data))
|
||||
CSV.parse(duplicate_data_import.import_file.download, headers: true)
|
||||
|
||||
described_class.perform_now(duplicate_data_import)
|
||||
|
||||
# Only first contact should be imported
|
||||
expect(duplicate_data_import.account.contacts.count).to eq(1)
|
||||
expect(duplicate_data_import.account.contacts.first.name).to eq('First Contact')
|
||||
|
||||
# Check metrics
|
||||
duplicate_data_import.reload
|
||||
expect(duplicate_data_import.total_records).to eq(3)
|
||||
expect(duplicate_data_import.processed_records).to eq(1) # Only first contact imported
|
||||
expect(duplicate_data_import.non_identifiable_records).to eq(0) # First contact is identifiable
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the CSV file is invalid' do
|
||||
let(:invalid_csv_content) do
|
||||
"id,name,email,phone_number,company\n1,\"Clarice Uzzell,\"missing_quote,918080808080,Acmecorp\n2,Marieann Creegan,,+918080808081,Acmecorp"
|
||||
|
||||
@@ -11,6 +11,19 @@ RSpec.describe DataImport do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'fields' do
|
||||
let(:data_import) { create(:data_import) }
|
||||
|
||||
it 'has non_identifiable_records field with default value' do
|
||||
expect(data_import.non_identifiable_records).to eq(0)
|
||||
end
|
||||
|
||||
it 'allows setting non_identifiable_records' do
|
||||
data_import.update!(non_identifiable_records: 5)
|
||||
expect(data_import.reload.non_identifiable_records).to eq(5)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
let(:data_import) { build(:data_import) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user