diff --git a/.env.example b/.env.example index de671599c..3ff349ffc 100644 --- a/.env.example +++ b/.env.example @@ -256,6 +256,8 @@ AZURE_APP_SECRET= ## Change these values to fine tune performance # control the concurrency setting of sidekiq # SIDEKIQ_CONCURRENCY=10 +# Enable verbose logging each time a job is dequeued in Sidekiq +# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false # AI powered features diff --git a/Gemfile b/Gemfile index 18442e3b0..abbd3332f 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ gem 'telephone_number' gem 'time_diff' gem 'tzinfo-data' gem 'valid_email2' +gem 'email-provider-info' # compress javascript config.assets.js_compressor gem 'uglifier' ##-- used for single column multiple binary flags in notification settings/feature flagging --## diff --git a/Gemfile.lock b/Gemfile.lock index 2f4da34e3..99e75b33c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -270,6 +270,7 @@ GEM concurrent-ruby (~> 1.0) http (>= 3.0) ruby2_keywords + email-provider-info (0.0.1) email_reply_trimmer (0.1.13) erubi (1.13.0) et-orbi (1.2.11) @@ -1016,6 +1017,7 @@ DEPENDENCIES dotenv-rails (>= 3.0.0) down elastic-apm + email-provider-info email_reply_trimmer facebook-messenger factory_bot_rails (>= 6.4.3) diff --git a/app/controllers/api/v1/accounts/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/bulk_actions_controller.rb index 1b8babbc9..222c66714 100644 --- a/app/controllers/api/v1/accounts/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/bulk_actions_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll enqueue_conversation_job head :ok when 'Contact' + check_authorization_for_contact_action enqueue_contact_job head :ok else @@ -34,14 +35,34 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll ) end + def delete_contact_action? + params[:action_name] == 'delete' + end + + def check_authorization_for_contact_action + authorize(Contact, :destroy?) if delete_contact_action? + end + def conversation_params - params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []]) + # TODO: Align conversation payloads with the `{ action_name, action_attributes }` + # and then remove this method in favor of a common params method. + base = params.permit( + :snoozed_until, + fields: [:status, :assignee_id, :team_id] + ) + append_common_bulk_attributes(base) end def contact_params - params.require(:ids) - permitted = params.permit(:type, ids: [], labels: [add: []]) - permitted[:ids] = permitted[:ids].map(&:to_i) if permitted[:ids].present? - permitted + # TODO: remove this method in favor of a common params method. + # once legacy conversation payloads are migrated. + append_common_bulk_attributes({}) + end + + def append_common_bulk_attributes(base_params) + # NOTE: Conversation payloads historically diverged per action. Going forward we + # want all objects to share a common contract: `{ action_name, action_attributes }` + common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []]) + base_params.merge(common) end end diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue index 47b779b61..041f06410 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue @@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core'; import Button from 'dashboard/components-next/button/Button.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; defineProps({ selectedContact: { @@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => { diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue index aec21a976..b7014c42f 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue @@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue'; import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; const props = defineProps({ selectedContact: { @@ -174,27 +175,29 @@ const handleAvatarDelete = async () => { @click="updateContact" /> -
-
-
- {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} -
- - {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} - + +
+
+
+ {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} +
+ + {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} + +
+
-
- +
diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index a2f139bdc..67936fa59 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -21,6 +21,10 @@ const props = defineProps({ enableCannedResponses: { type: Boolean, default: true }, enabledMenuOptions: { type: Array, default: () => [] }, enableCaptainTools: { type: Boolean, default: false }, + signature: { type: String, default: '' }, + allowSignature: { type: Boolean, default: false }, + sendWithSignature: { type: Boolean, default: false }, + channelType: { type: String, default: '' }, }); const emit = defineEmits(['update:modelValue']); @@ -100,6 +104,10 @@ watch( :enable-canned-responses="enableCannedResponses" :enabled-menu-options="enabledMenuOptions" :enable-captain-tools="enableCaptainTools" + :signature="signature" + :allow-signature="allowSignature" + :send-with-signature="sendWithSignature" + :channel-type="channelType" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index 773ebe315..92c5850de 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -92,7 +92,6 @@ const setSignature = () => { const toggleMessageSignature = () => { setSignatureFlagForInbox(props.channelType, !sendWithSignature.value); - setSignature(); }; // Added this watch to dynamically set signature on target inbox change. diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 4d6d41dac..a02d6d495 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => { state.attachedFiles = []; }; -const removeTargetInbox = value => { - v$.value.$reset(); - // Remove the signature from message content - // Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp) - if (props.sendWithSignature) { - const signatureToRemove = inboxTypes.value.isEmailOrWebWidget - ? props.messageSignature - : extractTextFromMarkdown(props.messageSignature); +const removeSignatureFromMessage = () => { + // Always remove the signature from message content when inbox/contact is removed + // to ensure no leftover signature content remains + const signatureToRemove = inboxTypes.value.isEmailOrWebWidget + ? props.messageSignature + : extractTextFromMarkdown(props.messageSignature); + if (signatureToRemove) { state.message = removeSignature(state.message, signatureToRemove); } +}; + +const removeTargetInbox = value => { + v$.value.$reset(); + removeSignatureFromMessage(); emit('updateTargetInbox', value); state.attachedFiles = []; }; @@ -216,6 +220,7 @@ const removeTargetInbox = value => { const clearSelectedContact = () => { emit('clearSelectedContact'); state.attachedFiles = []; + removeSignatureFromMessage(); }; const onClickInsertEmoji = emoji => { @@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => { :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :has-errors="validationStates.isMessageInvalid" :has-attachments="state.attachedFiles.length > 0" + :channel-type="inboxChannelType" /> { " enable-variables :show-character-count="false" + :signature="messageSignature" + allow-signature + :send-with-signature="sendWithSignature" + :channel-type="channelType" /> diff --git a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue index b5ee2ae4c..c2b832388 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue @@ -13,6 +13,7 @@ import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/Con import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue'; import ContactsBulkActionBar from '../components/ContactsBulkActionBar.vue'; +import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import BulkActionsAPI from 'dashboard/api/bulkActions'; const DEFAULT_SORT_FIELD = 'last_activity_at'; @@ -64,7 +65,26 @@ const totalItems = computed(() => meta.value?.count); const selectedContactIds = ref([]); const isBulkActionLoading = ref(false); -const hasSelection = computed(() => selectedContactIds.value.length > 0); +const bulkDeleteDialogRef = ref(null); +const selectedCount = computed(() => selectedContactIds.value.length); +const bulkDeleteDialogTitle = computed(() => + selectedCount.value > 1 + ? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.TITLE') + : t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_TITLE') +); +const bulkDeleteDialogDescription = computed(() => + selectedCount.value > 1 + ? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.DESCRIPTION', { + count: selectedCount.value, + }) + : t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_DESCRIPTION') +); +const bulkDeleteDialogConfirmLabel = computed(() => + selectedCount.value > 1 + ? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_MULTIPLE') + : t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_SINGLE') +); +const hasSelection = computed(() => selectedCount.value > 0); const activeSegment = computed(() => { if (!activeSegmentId.value) return undefined; return segments.value.find(view => view.id === Number(activeSegmentId.value)); @@ -120,6 +140,11 @@ const clearSelection = () => { selectedContactIds.value = []; }; +const openBulkDeleteDialog = () => { + if (!selectedContactIds.value.length || isBulkActionLoading.value) return; + bulkDeleteDialogRef.value?.open?.(); +}; + const toggleSelectAll = shouldSelect => { selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : []; }; @@ -256,6 +281,29 @@ const assignLabels = async labels => { } }; +const deleteContacts = async () => { + if (!selectedContactIds.value.length) { + return; + } + + isBulkActionLoading.value = true; + try { + await BulkActionsAPI.create({ + type: 'Contact', + ids: selectedContactIds.value, + action_name: 'delete', + }); + useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_SUCCESS')); + clearSelection(); + await fetchContactsBasedOnContext(pageNumber.value); + bulkDeleteDialogRef.value?.close?.(); + } catch (error) { + useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_FAILED')); + } finally { + isBulkActionLoading.value = false; + } +}; + const handleSort = async ({ sort, order }) => { Object.assign(sortState, { activeSort: sort, activeOrdering: order }); @@ -297,6 +345,12 @@ watch( { deep: true } ); +watch(hasSelection, value => { + if (!value) { + bulkDeleteDialogRef.value?.close?.(); + } +}); + watch( () => uiSettings.value?.contacts_sort_by, newSortBy => { @@ -391,6 +445,7 @@ onMounted(async () => { @toggle-all="toggleSelectAll" @clear-selection="clearSelection" @assign-labels="assignLabels" + @delete-selected="openBulkDeleteDialog" /> { {{ emptyStateMessage }} -
+
+
diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 4ec63acc2..eb97a22e8 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -228,14 +228,17 @@ class Conversation < ApplicationRecord def determine_conversation_status self.status = :resolved and return if contact.blocked? - # Message template hooks aren't executed for conversations from campaigns - # So making these conversations open for agent visibility - return if campaign.present? + return handle_campaign_status if campaign.present? # TODO: make this an inbox config instead of assuming bot conversations should start as pending self.status = :pending if inbox.active_bot? end + def handle_campaign_status + # If campaign has no sender (bot-initiated) and inbox has active bot, let bot handle it + self.status = :pending if campaign.sender_id.nil? && inbox.active_bot? + end + def notify_conversation_creation dispatcher_dispatch(CONVERSATION_CREATED) end diff --git a/app/models/message.rb b/app/models/message.rb index 5f98493d0..2964d9286 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -153,15 +153,6 @@ class Message < ApplicationRecord merge_sender_attributes(data) end - def search_data - data = attributes.symbolize_keys - data[:conversation] = conversation.present? ? conversation_push_event_data : nil - data[:attachments] = attachments.map(&:push_event_data) if attachments.present? - data[:sender] = sender.push_event_data if sender - data[:inbox] = inbox - data - end - def conversation_push_event_data { assignee_id: conversation.assignee_id, @@ -259,6 +250,10 @@ class Message < ApplicationRecord true end + def search_data + Messages::SearchDataPresenter.new(self).search_data + end + private def prevent_message_flooding diff --git a/app/presenters/messages/search_data_presenter.rb b/app/presenters/messages/search_data_presenter.rb new file mode 100644 index 000000000..7d0638add --- /dev/null +++ b/app/presenters/messages/search_data_presenter.rb @@ -0,0 +1,58 @@ +class Messages::SearchDataPresenter < SimpleDelegator + def search_data + { + **searchable_content, + **message_attributes, + additional_attributes: additional_attributes_data, + conversation: conversation_data + } + end + + private + + def searchable_content + { + content: content, + attachments: attachment_data, + content_attributes: content_attributes_data + } + end + + def message_attributes + { + account_id: account_id, + inbox_id: inbox_id, + conversation_id: conversation_id, + message_type: message_type, + private: private, + created_at: created_at, + source_id: source_id, + sender_id: sender_id, + sender_type: sender_type + } + end + + def attachment_data + attachments.filter_map do |a| + { transcribed_text: a.meta&.dig('transcribed_text') } + end.presence + end + + def content_attributes_data + email_subject = content_attributes.dig(:email, :subject) + return {} if email_subject.blank? + + { email: { subject: email_subject } } + end + + def conversation_data + { id: conversation.display_id } + end + + def additional_attributes_data + { + campaign_id: additional_attributes&.dig('campaign_id'), + automation_rule_id: content_attributes&.dig('automation_rule_id') + } + end +end diff --git a/app/services/contacts/bulk_action_service.rb b/app/services/contacts/bulk_action_service.rb index c759e95a5..a0e11ad9a 100644 --- a/app/services/contacts/bulk_action_service.rb +++ b/app/services/contacts/bulk_action_service.rb @@ -6,6 +6,7 @@ class Contacts::BulkActionService end def perform + return delete_contacts if delete_requested? return assign_labels if labels_to_add.any? Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}") @@ -22,6 +23,13 @@ class Contacts::BulkActionService ).perform end + def delete_contacts + Contacts::BulkDeleteService.new( + account: @account, + contact_ids: ids + ).perform + end + def ids Array(@params[:ids]).compact end @@ -29,4 +37,8 @@ class Contacts::BulkActionService def labels_to_add @labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?) end + + def delete_requested? + @params[:action_name] == 'delete' + end end diff --git a/app/services/contacts/bulk_delete_service.rb b/app/services/contacts/bulk_delete_service.rb new file mode 100644 index 000000000..d197f17f6 --- /dev/null +++ b/app/services/contacts/bulk_delete_service.rb @@ -0,0 +1,18 @@ +class Contacts::BulkDeleteService + def initialize(account:, contact_ids: []) + @account = account + @contact_ids = Array(contact_ids).compact + end + + def perform + return if @contact_ids.blank? + + contacts.find_each(&:destroy!) + end + + private + + def contacts + @account.contacts.where(id: @contact_ids) + end +end diff --git a/app/services/llm_formatter/conversation_llm_formatter.rb b/app/services/llm_formatter/conversation_llm_formatter.rb index 8654e0adf..4e0bd7013 100644 --- a/app/services/llm_formatter/conversation_llm_formatter.rb +++ b/app/services/llm_formatter/conversation_llm_formatter.rb @@ -39,7 +39,14 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter end def format_message(message) - sender = message.message_type == 'incoming' ? 'User' : 'Support agent' + sender = case message.sender_type + when 'User' + 'Support Agent' + when 'Contact' + 'User' + else + 'Bot' + end sender = "[Private Note] #{sender}" if message.private? "#{sender}: #{message.content}\n" end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 04d605c2e..dd5c71a4d 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -18,8 +18,10 @@ end Sidekiq.configure_server do |config| config.redis = Redis::Config.app - config.server_middleware do |chain| - chain.add ChatwootDequeuedLogger + if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_SIDEKIQ_DEQUEUE_LOGGER', false)) + config.server_middleware do |chain| + chain.add ChatwootDequeuedLogger + end end # skip the default start stop logging diff --git a/db/migrate/20251021082242_add_unique_index_to_companies_domain.rb b/db/migrate/20251021082242_add_unique_index_to_companies_domain.rb new file mode 100644 index 000000000..2fb387242 --- /dev/null +++ b/db/migrate/20251021082242_add_unique_index_to_companies_domain.rb @@ -0,0 +1,16 @@ +class AddUniqueIndexToCompaniesDomain < ActiveRecord::Migration[7.1] + def up + remove_index :companies, name: 'index_companies_on_domain_and_account_id', if_exists: true + + add_index :companies, [:account_id, :domain], + unique: true, + name: 'index_companies_on_account_and_domain', + where: 'domain IS NOT NULL' + end + + def down + remove_index :companies, name: 'index_companies_on_account_and_domain', if_exists: true + add_index :companies, [:domain, :account_id], + name: 'index_companies_on_domain_and_account_id' + end +end diff --git a/db/schema.rb b/db/schema.rb index 022a0101e..45165d5c5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -577,8 +577,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_22_152158) do t.bigint "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)" t.index ["account_id"], name: "index_companies_on_account_id" - t.index ["domain", "account_id"], name: "index_companies_on_domain_and_account_id" t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id" end diff --git a/enterprise/app/jobs/migration/company_account_batch_job.rb b/enterprise/app/jobs/migration/company_account_batch_job.rb new file mode 100644 index 000000000..2e6c20f19 --- /dev/null +++ b/enterprise/app/jobs/migration/company_account_batch_job.rb @@ -0,0 +1,54 @@ +class Migration::CompanyAccountBatchJob < ApplicationJob + queue_as :low + + def perform(account) + account.contacts + .where.not(email: nil) + .find_in_batches(batch_size: 1000) do |contact_batch| + process_contact_batch(contact_batch, account) + end + end + + private + + def process_contact_batch(contacts, account) + contacts.each do |contact| + next unless should_process?(contact) + + company = find_or_create_company(contact, account) + # rubocop:disable Rails/SkipsModelValidations + contact.update_column(:company_id, company.id) if company + # rubocop:enable Rails/SkipsModelValidations + end + end + + def should_process?(contact) + return false if contact.company_id.present? + return false if contact.email.blank? + + Companies::BusinessEmailDetectorService.new(contact.email).perform + end + + def find_or_create_company(contact, account) + domain = extract_domain(contact.email) + company_name = derive_company_name(contact, domain) + + Company.find_or_create_by!(account: account, domain: domain) do |company| + company.name = company_name + end + rescue ActiveRecord::RecordNotUnique + # Race condition: Another job created it between our check and create + # just find the one that was created + + Company.find_by(account: account, domain: domain) + end + + def extract_domain(email) + email.split('@').last&.downcase + end + + def derive_company_name(contact, domain) + contact.additional_attributes&.dig('company_name').presence || + domain.split('.').first.tr('-_', ' ').titleize + end +end diff --git a/enterprise/app/jobs/migration/company_backfill_job.rb b/enterprise/app/jobs/migration/company_backfill_job.rb new file mode 100644 index 000000000..db9a3370f --- /dev/null +++ b/enterprise/app/jobs/migration/company_backfill_job.rb @@ -0,0 +1,17 @@ +class Migration::CompanyBackfillJob < ApplicationJob + queue_as :low + + def perform + Rails.logger.info 'Starting company backfill migration...' + account_count = 0 + Account.find_in_batches(batch_size: 100) do |accounts| + accounts.each do |account| + Rails.logger.info "Enqueuing company backfill for account #{account.id}" + Migration::CompanyAccountBatchJob.perform_later(account) + account_count += 1 + end + end + + Rails.logger.info "Company backfill migration complete. Enqueued jobs for #{account_count} accounts." + end +end diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb index 764cb2a9c..fde6cb122 100644 --- a/enterprise/app/models/company.rb +++ b/enterprise/app/models/company.rb @@ -12,9 +12,9 @@ # # Indexes # -# index_companies_on_account_id (account_id) -# index_companies_on_domain_and_account_id (domain,account_id) -# index_companies_on_name_and_account_id (name,account_id) +# index_companies_on_account_and_domain (account_id,domain) UNIQUE WHERE (domain IS NOT NULL) +# index_companies_on_account_id (account_id) +# index_companies_on_name_and_account_id (name,account_id) # class Company < ApplicationRecord include Avatarable @@ -24,6 +24,7 @@ class Company < ApplicationRecord with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/, message: I18n.t('errors.companies.domain.invalid') } + validates :domain, uniqueness: { scope: :account_id }, if: -> { domain.present? } validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT } belongs_to :account diff --git a/enterprise/app/models/enterprise/concerns/contact.rb b/enterprise/app/models/enterprise/concerns/contact.rb index 9139fc67e..09885a947 100644 --- a/enterprise/app/models/enterprise/concerns/contact.rb +++ b/enterprise/app/models/enterprise/concerns/contact.rb @@ -2,5 +2,30 @@ module Enterprise::Concerns::Contact extend ActiveSupport::Concern included do belongs_to :company, optional: true + + after_commit :associate_company_from_email, + on: [:create, :update], + if: :should_associate_company? + end + + private + + def should_associate_company? + # Only trigger if: + # 1. Contact has an email + # 2. Contact doesn't have a compan yet + # 3. Email was just set/changed + # 4. Email was previously nil (first time getting email) + email.present? && + company_id.nil? && + saved_change_to_email? && + saved_change_to_email.first.nil? + end + + def associate_company_from_email + Contacts::CompanyAssociationService.new.associate_company_from_email(self) + rescue StandardError => e + Rails.logger.error("Failed to associate company for contact #{id}: #{e.message}") + # Don't fail the contact save if the company association fails end end diff --git a/enterprise/app/services/companies/business_email_detector_service.rb b/enterprise/app/services/companies/business_email_detector_service.rb new file mode 100644 index 000000000..422ef41c8 --- /dev/null +++ b/enterprise/app/services/companies/business_email_detector_service.rb @@ -0,0 +1,19 @@ +class Companies::BusinessEmailDetectorService + attr_reader :email + + def initialize(email) + @email = email + end + + def perform + return false if email.blank? + + address = ValidEmail2::Address.new(email) + return false unless address.valid? + return false if address.disposable_domain? + + provider = EmailProviderInfo.call(email) + + provider.nil? + end +end diff --git a/enterprise/app/services/contacts/company_association_service.rb b/enterprise/app/services/contacts/company_association_service.rb new file mode 100644 index 000000000..f2e2ffdd2 --- /dev/null +++ b/enterprise/app/services/contacts/company_association_service.rb @@ -0,0 +1,46 @@ +class Contacts::CompanyAssociationService + def associate_company_from_email(contact) + return nil if skip_association?(contact) + + company = find_or_create_company(contact) + # rubocop:disable Rails/SkipsModelValidations + # Intentionally using update_column here to: + # 1. Avoid triggering callbacks + # 2. Improve performance (We're only setting company_id, no need for validation) + contact.update_column(:company_id, company.id) if company + # rubocop:enable Rails/SkipsModelValidations + company + end + + private + + def skip_association?(contact) + return true if contact.company_id.present? + return true if contact.email.blank? + + detector = Companies::BusinessEmailDetectorService.new(contact.email) + return true unless detector.perform + + false + end + + def find_or_create_company(contact) + domain = extract_domain(contact.email) + company_name = derive_company_name(contact, domain) + + Company.find_or_create_by!(account: contact.account, domain: domain) do |company| + company.name = company_name + end + rescue ActiveRecord::RecordNotUnique + # If another process created it first, just find that + Company.find_by(account: contact.account, domain: domain) + end + + def extract_domain(email) + email.split('@').last&.downcase + end + + def derive_company_name(contact, domain) + contact.additional_attributes&.dig('company_name') || domain.split('.').first.tr('-_', ' ').titleize + end +end diff --git a/lib/tasks/companies.rake b/lib/tasks/companies.rake new file mode 100644 index 000000000..11fb5dc10 --- /dev/null +++ b/lib/tasks/companies.rake @@ -0,0 +1,12 @@ +namespace :companies do + desc 'Backfill companies from existing contact email domains' + task backfill: :environment do + puts 'Starting company backfill migration...' + puts 'This will process all accounts and create companies from contact email domains.' + puts 'The job will run in the background via Sidekiq' + puts '' + Migration::CompanyBackfillJob.perform_later + puts 'Company backfill job has been enqueued.' + puts 'Monitor progress in logs or Sidekiq dashboard.' + end +end diff --git a/package.json b/package.json index 6fb7156cb..4660b44fa 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "dependencies": { "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.2.1", + "@chatwoot/prosemirror-schema": "1.2.3", "@chatwoot/utils": "^0.0.51", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22c662d89..e5629af3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.2.1 - version: 1.2.1 + specifier: 1.2.3 + version: 1.2.3 '@chatwoot/utils': specifier: ^0.0.51 version: 0.0.51 @@ -406,8 +406,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.2.1': - resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==} + '@chatwoot/prosemirror-schema@1.2.3': + resolution: {integrity: sha512-q/EfirVK9jt8FJAx3Gf6y3LoVadmYVLknbYvPrkUe81WO0f2mkZ/kY2UQgpUISVvOGEkCH4bkfYMp5UQ+Buz3g==} '@chatwoot/utils@0.0.51': resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==} @@ -4768,7 +4768,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.2.1': + '@chatwoot/prosemirror-schema@1.2.3': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.6.0 diff --git a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb index 9c53099fd..1ab490a96 100644 --- a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb @@ -195,37 +195,6 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer') expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer') end - - it 'enqueues contact bulk action job with permitted params' do - contact_one = create(:contact, account: account) - contact_two = create(:contact, account: account) - - previous_adapter = ActiveJob::Base.queue_adapter - ActiveJob::Base.queue_adapter = :test - - expect do - post "/api/v1/accounts/#{account.id}/bulk_actions", - headers: agent.create_new_auth_token, - params: { - type: 'Contact', - ids: [contact_one.id, contact_two.id], - labels: { add: %w[vip support] }, - extra: 'ignored' - } - end.to have_enqueued_job(Contacts::BulkActionJob).with( - account.id, - agent.id, - hash_including( - 'ids' => [contact_one.id, contact_two.id], - 'labels' => hash_including('add' => %w[vip support]) - ) - ) - - expect(response).to have_http_status(:success) - ensure - ActiveJob::Base.queue_adapter = previous_adapter - clear_enqueued_jobs - end end end @@ -256,4 +225,49 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do end end end + + describe 'POST /api/v1/accounts/{account.id}/bulk_actions (contacts)' do + context 'when it is an authenticated user' do + let!(:agent) { create(:user, account: account, role: :agent) } + + it 'enqueues Contacts::BulkActionJob with permitted params' do + contact_one = create(:contact, account: account) + contact_two = create(:contact, account: account) + + expect do + post "/api/v1/accounts/#{account.id}/bulk_actions", + headers: agent.create_new_auth_token, + params: { + type: 'Contact', + ids: [contact_one.id, contact_two.id], + labels: { add: %w[vip support] }, + extra: 'ignored' + } + end.to have_enqueued_job(Contacts::BulkActionJob).with( + account.id, + agent.id, + hash_including( + 'ids' => [contact_one.id.to_s, contact_two.id.to_s], + 'labels' => hash_including('add' => %w[vip support]) + ) + ) + + expect(response).to have_http_status(:success) + end + + it 'returns unauthorized for delete action when user is not admin' do + contact = create(:contact, account: account) + + post "/api/v1/accounts/#{account.id}/bulk_actions", + headers: agent.create_new_auth_token, + params: { + type: 'Contact', + ids: [contact.id], + action_name: 'delete' + } + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/enterprise/jobs/migration/company_account_batch_job_spec.rb b/spec/enterprise/jobs/migration/company_account_batch_job_spec.rb new file mode 100644 index 000000000..499b96287 --- /dev/null +++ b/spec/enterprise/jobs/migration/company_account_batch_job_spec.rb @@ -0,0 +1,133 @@ +require 'rails_helper' + +RSpec.describe Migration::CompanyAccountBatchJob, type: :job do + let(:account) { create(:account) } + + describe '#perform' do + before do + # Stub EmailProvideInfo to control behavior in tests + allow(EmailProviderInfo).to receive(:call) do |email| + domain = email.split('@').last&.downcase + case domain + when 'gmail.com', 'yahoo.com', 'hotmail.com', 'uol.com.br' + 'free_provider' # generic free provider name + end + end + end + + context 'when contact has business email' do + let!(:contact) { create(:contact, account: account, email: 'user@acme.com') } + + it 'creates a company and associates the contact' do + # Clean up companies created by Part 2's callback + Company.delete_all + # rubocop:disable Rails/SkipsModelValidations + contact.update_column(:company_id, nil) + # rubocop:enable Rails/SkipsModelValidations + + expect do + described_class.perform_now(account) + end.to change(Company, :count).by(1) + contact.reload + expect(contact.company).to be_present + expect(contact.company.domain).to eq('acme.com') + expect(contact.company.name).to eq('Acme') + end + end + + context 'when contact has free email' do + let!(:contact) { create(:contact, account: account, email: 'user@gmail.com') } + + it 'does not create a company' do + expect do + described_class.perform_now(account) + end.not_to change(Company, :count) + contact.reload + expect(contact.company_id).to be_nil + end + end + + context 'when contact has company_name in additional_attributes' do + let!(:contact) do + create(:contact, account: account, email: 'user@acme.com', additional_attributes: { 'company_name' => 'Acme Corporation' }) + end + + it 'uses the saved company name' do + described_class.perform_now(account) + contact.reload + expect(contact.company.name).to eq('Acme Corporation') + end + end + + context 'when contact already has a company' do + let!(:existing_company) { create(:company, account: account, domain: 'existing.com') } + let!(:contact) do + create(:contact, account: account, email: 'user@acme.com', company: existing_company) + end + + it 'does not change the existing company' do + described_class.perform_now(account) + contact.reload + expect(contact.company_id).to eq(existing_company.id) + end + end + + context 'when multiple contacts have the same domain' do + let!(:contact1) { create(:contact, account: account, email: 'user1@acme.com') } + let!(:contact2) { create(:contact, account: account, email: 'user2@acme.com') } + + it 'creates only one company for the domain' do + # Clean up companies created by Part 2's callback + Company.delete_all + # rubocop:disable Rails/SkipsModelValidations + contact1.update_column(:company_id, nil) + contact2.update_column(:company_id, nil) + # rubocop:enable Rails/SkipsModelValidations + + expect do + described_class.perform_now(account) + end.to change(Company, :count).by(1) + contact1.reload + contact2.reload + expect(contact1.company_id).to eq(contact2.company_id) + expect(contact1.company.domain).to eq('acme.com') + end + end + + context 'when contact has no email' do + let!(:contact) { create(:contact, account: account, email: nil) } + + it 'skips the contact' do + expect do + described_class.perform_now(account) + end.not_to change(Company, :count) + contact.reload + expect(contact.company_id).to be_nil + end + end + + context 'when processing large batch' do + before do + contacts_data = Array.new(2000) do |i| + { + account_id: account.id, + email: "user#{i}@company#{i % 100}.com", + name: "User #{i}", + created_at: Time.current, + updated_at: Time.current + } + end + # rubocop:disable Rails/SkipsModelValidations + Contact.insert_all(contacts_data) + # rubocop:enable Rails/SkipsModelValidations + end + + it 'processes all contacts in batches' do + expect do + described_class.perform_now(account) + end.to change(Company, :count).by(100) + expect(account.contacts.where.not(company_id: nil).count).to eq(2000) + end + end + end +end diff --git a/spec/enterprise/jobs/migration/company_backfill_job_spec.rb b/spec/enterprise/jobs/migration/company_backfill_job_spec.rb new file mode 100644 index 000000000..e60b84487 --- /dev/null +++ b/spec/enterprise/jobs/migration/company_backfill_job_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +RSpec.describe Migration::CompanyBackfillJob, type: :job do + describe '#perform' do + it 'enqueues the job' do + expect { described_class.perform_later } + .to have_enqueued_job(described_class) + .on_queue('low') + end + + context 'when accounts exist' do + let!(:account1) { create(:account) } + let!(:account2) { create(:account) } + + it 'enqueues CompanyAccountBatchJob for each account' do + expect do + described_class.perform_now + end.to have_enqueued_job(Migration::CompanyAccountBatchJob) + .with(account1) + .and have_enqueued_job(Migration::CompanyAccountBatchJob) + .with(account2) + end + end + + context 'when no accounts exist' do + it 'completes without error' do + expect { described_class.perform_now }.not_to raise_error + end + end + end +end diff --git a/spec/enterprise/models/contact_company_association_spec.rb b/spec/enterprise/models/contact_company_association_spec.rb new file mode 100644 index 000000000..065d5bc97 --- /dev/null +++ b/spec/enterprise/models/contact_company_association_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' + +RSpec.describe Contact, type: :model do + describe 'company auto-association' do + let(:account) { create(:account) } + + context 'when creating a new contact with business email' do + it 'automatically creates and associates a company' do + expect do + create(:contact, email: 'john@acme.com', account: account) + end.to change(Company, :count).by(1) + contact = described_class.last + expect(contact.company).to be_present + expect(contact.company.domain).to eq('acme.com') + end + + it 'does not create company for free email providers' do + expect do + create(:contact, email: 'john@gmail.com', account: account) + end.not_to change(Company, :count) + end + end + + context 'when updating a contact to add email for first time' do + it 'creates and associates company' do + contact = create(:contact, email: nil, account: account) + expect do + contact.update(email: 'john@acme.com') + end.to change(Company, :count).by(1) + contact.reload + expect(contact.company.domain).to eq('acme.com') + end + end + + context 'when updating a contact that already has a company' do + it 'does not change company when email changes' do + existing_company = create(:company, domain: 'oldcompany.com', account: account) + contact = create(:contact, email: 'john@oldcompany.com', company: existing_company, account: account) + + expect do + contact.update(email: 'john@new_company.com') + end.not_to change(Company, :count) + contact.reload + expect(contact.company).to eq(existing_company) + end + end + + context 'when multiple contacts share the same domain' do + it 'associates all contacts with the same company' do + contacts = ['john@acme.com', 'jane@acme.com', 'bob@acme.com'] + contacts.each do |contact| + create(:contact, email: contact, account: account) + end + + expect(Company.where(domain: 'acme.com', account: account).count).to eq(1) + company = Company.find_by(domain: 'acme.com', account: account) + expect(company.contacts.count).to eq(contacts.length) + end + end + end +end diff --git a/spec/enterprise/services/companies/business_email_detector_service_spec.rb b/spec/enterprise/services/companies/business_email_detector_service_spec.rb new file mode 100644 index 000000000..ceabfa905 --- /dev/null +++ b/spec/enterprise/services/companies/business_email_detector_service_spec.rb @@ -0,0 +1,99 @@ +require 'rails_helper' + +RSpec.describe Companies::BusinessEmailDetectorService, type: :service do + let(:service) { described_class.new(email) } + + describe '#perform' do + context 'when email is from a business domain' do + let(:email) { 'user@acme.com' } + let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) } + + before do + allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address) + allow(EmailProviderInfo).to receive(:call).with(email).and_return(nil) + end + + it 'returns true' do + expect(service.perform).to be(true) + end + end + + context 'when email is from gmail' do + let(:email) { 'user@gmail.com' } + let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) } + + before do + allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address) + allow(EmailProviderInfo).to receive(:call).with(email).and_return('gmail') + end + + it 'returns false' do + expect(service.perform).to be(false) + end + end + + context 'when email is from Brazilian free provider' do + let(:email) { 'user@uol.com.br' } + let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) } + + before do + allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address) + allow(EmailProviderInfo).to receive(:call).with(email).and_return('uol') + end + + it 'returns false' do + expect(service.perform).to be(false) + end + end + + context 'when email is disposable' do + let(:email) { 'user@mailinator.com' } + let(:disposable_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: true) } + + it 'returns false' do + allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address) + expect(service.perform).to be(false) + end + end + + context 'when email is invalid format' do + let(:email) { 'invalid-email' } + let(:invalid_email_address) { instance_double(ValidEmail2::Address, valid?: false) } + + it 'returns false' do + allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address) + expect(service.perform).to be(false) + end + end + + context 'when email is nil' do + let(:email) { nil } + + it 'remains false' do + expect(service.perform).to be(false) + end + end + + context 'when email is empty string' do + let(:email) { '' } + + it 'returns false' do + expect(service.perform).to be(false) + end + end + + context 'when email domain is uppercase' do + let(:email) { 'user@GMAIL.COM' } + let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) } + + before do + allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address) + allow(EmailProviderInfo).to receive(:call).with(email).and_return('gmail') + end + + it 'returns false (case insensitive)' do + expect(service.perform).to be(false) + end + end + end +end diff --git a/spec/enterprise/services/contacts/company_association_service_spec.rb b/spec/enterprise/services/contacts/company_association_service_spec.rb new file mode 100644 index 000000000..ea9363bad --- /dev/null +++ b/spec/enterprise/services/contacts/company_association_service_spec.rb @@ -0,0 +1,83 @@ +require 'rails_helper' + +RSpec.describe Contacts::CompanyAssociationService, type: :service do + let(:account) { create(:account) } + let(:service) { described_class.new } + + describe '#associate_company_from_email' do + context 'when contact has business email and no company' do + it 'creates a new company and associates it' do + contact = create(:contact, email: 'john@acme.com', account: account, company_id: nil) + Company.delete_all # Delete any companies created by the callback + # rubocop:disable Rails/SkipsModelValidations + contact.update_column(:company_id, nil) # Delete the company association created by the callback + # rubocop:enable Rails/SkipsModelValidations + + valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) + allow(ValidEmail2::Address).to receive(:new).with('john@acme.com').and_return(valid_email_address) + allow(EmailProviderInfo).to receive(:call).with('john@acme.com').and_return(nil) + + expect do + service.associate_company_from_email(contact) + end.to change(Company, :count).by(1) + + contact.reload + expect(contact.company).to be_present + expect(contact.company.domain).to eq('acme.com') + expect(contact.company.name).to eq('Acme') + end + + it 'reuses existing company with same domain' do + existing_company = create(:company, domain: 'acme.com', account: account) + contact = create(:contact, email: 'john@acme.com', account: account, company_id: nil) + # rubocop:disable Rails/SkipsModelValidations + contact.update_column(:company_id, nil) # Delete the company association created by the callback + # rubocop:enable Rails/SkipsModelValidations + + valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) + allow(ValidEmail2::Address).to receive(:new).with('john@acme.com').and_return(valid_email_address) + allow(EmailProviderInfo).to receive(:call).with('john@acme.com').and_return(nil) + + expect do + service.associate_company_from_email(contact) + end.not_to change(Company, :count) + + contact.reload + expect(contact.company).to eq(existing_company) + end + end + + context 'when contact already has a company' do + it 'skips association and returns nil' do + existing_company = create(:company, account: account) + contact = create(:contact, email: 'john@acme.com', account: account, company_id: existing_company.id) + result = service.associate_company_from_email(contact) + + expect(result).to be_nil + contact.reload + expect(contact.company).to eq(existing_company) + end + end + + context 'when contact has free email provider' do + it 'skips association for email' do + contact = create(:contact, email: 'john@gmail.com', account: account, company_id: nil) + expect do + service.associate_company_from_email(contact) + end.not_to change(Company, :count) + contact.reload + expect(contact.company).to be_nil + end + end + + context 'when contact has no email' do + it 'skips association' do + contact = create(:contact, email: nil, account: account, company_id: nil) + + result = service.associate_company_from_email(contact) + expect(result).to be_nil + expect(contact.reload.company).to be_nil + end + end + end +end diff --git a/spec/factories/messages.rb b/spec/factories/messages.rb index b2ae41c5e..99a2c7cd8 100644 --- a/spec/factories/messages.rb +++ b/spec/factories/messages.rb @@ -27,6 +27,13 @@ FactoryBot.define do end end + trait :bot_message do + message_type { 'outgoing' } + after(:build) do |message| + message.sender = nil + end + end + after(:build) do |message| message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account) message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account) diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 72962ba54..51bb43384 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -576,9 +576,38 @@ RSpec.describe Conversation do expect(conversation.status).to eq('pending') end - it 'returns conversation as open if campaign is present' do - conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: create(:campaign)) - expect(conversation.status).to eq('open') + context 'with campaigns' do + let(:user) { create(:user, account: bot_inbox.inbox.account) } + + it 'returns conversation as open if campaign has a sender' do + campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: user) + conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign) + expect(conversation.status).to eq('open') + end + + it 'returns conversation as pending if campaign has no sender (bot-initiated) and bot is active' do + campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: nil) + conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign) + expect(conversation.status).to eq('pending') + end + end + + context 'with campaigns in inbox without bot' do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:user) { create(:user, account: account) } + + it 'returns conversation as open if campaign has no sender but no bot is active' do + campaign = create(:campaign, inbox: inbox, account: account, sender: nil) + conversation = create(:conversation, inbox: inbox, campaign: campaign) + expect(conversation.status).to eq('open') + end + + it 'returns conversation as open if campaign has a sender' do + campaign = create(:campaign, inbox: inbox, account: account, sender: user) + conversation = create(:conversation, inbox: inbox, campaign: campaign) + expect(conversation.status).to eq('open') + end end end diff --git a/spec/presenters/messages/search_data_presenter_spec.rb b/spec/presenters/messages/search_data_presenter_spec.rb new file mode 100644 index 000000000..0bbe1a812 --- /dev/null +++ b/spec/presenters/messages/search_data_presenter_spec.rb @@ -0,0 +1,78 @@ +require 'rails_helper' + +RSpec.describe Messages::SearchDataPresenter do + let(:presenter) { described_class.new(message) } + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:message) { create(:message, account: account, inbox: inbox, conversation: conversation, sender: contact) } + + describe '#search_data' do + let(:expected_data) do + { + content: message.content, + account_id: message.account_id, + inbox_id: message.inbox_id, + conversation_id: message.conversation_id, + message_type: message.message_type, + private: message.private, + created_at: message.created_at, + source_id: message.source_id, + sender_id: message.sender_id, + sender_type: message.sender_type, + conversation: { + id: conversation.display_id + } + } + end + + it 'returns search index payload with core fields' do + expect(presenter.search_data).to include(expected_data) + end + + context 'with attachments' do + before do + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + attachment.meta = { 'transcribed_text' => 'Hello world' } + end + + it 'includes attachment transcriptions' do + attachments_data = presenter.search_data[:attachments] + expect(attachments_data).to be_an(Array) + expect(attachments_data.first).to include(transcribed_text: 'Hello world') + end + end + + context 'with email content attributes' do + before do + message.update( + content_attributes: { email: { subject: 'Test Subject' } } + ) + end + + it 'includes email subject' do + content_attrs = presenter.search_data[:content_attributes] + expect(content_attrs[:email][:subject]).to eq('Test Subject') + end + end + + context 'with campaign and automation data' do + before do + message.update( + additional_attributes: { 'campaign_id' => '123' }, + content_attributes: { 'automation_rule_id' => '456' } + ) + end + + it 'includes campaign_id' do + expect(presenter.search_data[:additional_attributes][:campaign_id]).to eq('123') + end + + it 'includes automation_rule_id' do + expect(presenter.search_data[:additional_attributes][:automation_rule_id]).to eq('456') + end + end + end +end diff --git a/spec/services/contacts/bulk_action_service_spec.rb b/spec/services/contacts/bulk_action_service_spec.rb new file mode 100644 index 000000000..0411c8455 --- /dev/null +++ b/spec/services/contacts/bulk_action_service_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe Contacts::BulkActionService do + subject(:service) { described_class.new(account: account, user: user, params: params) } + + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + + describe '#perform' do + context 'when delete action is requested via action_name' do + let(:params) { { ids: [1, 2], action_name: 'delete' } } + + it 'delegates to the bulk delete service' do + bulk_delete_service = instance_double(Contacts::BulkDeleteService, perform: true) + + expect(Contacts::BulkDeleteService).to receive(:new) + .with(account: account, contact_ids: [1, 2]) + .and_return(bulk_delete_service) + + service.perform + end + end + + context 'when labels are provided' do + let(:params) { { ids: [10, 20], labels: { add: %w[vip support] }, extra: 'ignored' } } + + it 'delegates to the bulk assign labels service with permitted params' do + bulk_assign_service = instance_double(Contacts::BulkAssignLabelsService, perform: true) + + expect(Contacts::BulkAssignLabelsService).to receive(:new) + .with(account: account, contact_ids: [10, 20], labels: %w[vip support]) + .and_return(bulk_assign_service) + + service.perform + end + end + end +end diff --git a/spec/services/contacts/bulk_delete_service_spec.rb b/spec/services/contacts/bulk_delete_service_spec.rb new file mode 100644 index 000000000..4eaecaa0f --- /dev/null +++ b/spec/services/contacts/bulk_delete_service_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe Contacts::BulkDeleteService do + subject(:service) { described_class.new(account: account, contact_ids: contact_ids) } + + let(:account) { create(:account) } + let!(:contact_one) { create(:contact, account: account) } + let!(:contact_two) { create(:contact, account: account) } + let(:contact_ids) { [contact_one.id, contact_two.id] } + + describe '#perform' do + it 'deletes the provided contacts' do + expect { service.perform } + .to change { account.contacts.exists?(contact_one.id) }.from(true).to(false) + .and change { account.contacts.exists?(contact_two.id) }.from(true).to(false) + end + + it 'returns when no contact ids are provided' do + empty_service = described_class.new(account: account, contact_ids: []) + + expect { empty_service.perform }.not_to change(Contact, :count) + end + end +end diff --git a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb index 49fcc1a18..b79ee6d79 100644 --- a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb +++ b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb @@ -28,6 +28,14 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do content: 'Hello, I need help' ) + create( + :message, + :bot_message, + conversation: conversation, + message_type: 'outgoing', + content: 'Thanks for reaching out, an agent will reach out to you soon' + ) + create( :message, conversation: conversation, @@ -40,7 +48,8 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do "Channel: #{conversation.inbox.channel.name}", 'Message History:', 'User: Hello, I need help', - 'Support agent: How can I assist you today?', + 'Bot: Thanks for reaching out, an agent will reach out to you soon', + 'Support Agent: How can I assist you today?', '' ].join("\n") diff --git a/spec/services/widget/token_service_expiry_spec.rb b/spec/services/widget/token_service_expiry_spec.rb index 051a757a5..50e104c65 100644 --- a/spec/services/widget/token_service_expiry_spec.rb +++ b/spec/services/widget/token_service_expiry_spec.rb @@ -15,11 +15,11 @@ RSpec.describe Widget::TokenService, type: :service do end it 'uses the configured value for token expiry' do - freeze_time do + travel_to '2025-01-01' do token = service.generate_token decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first - expect(decoded['iat']).to eq(Time.now.to_i) - expect(decoded['exp']).to eq(30.days.from_now.to_i) + expect(decoded['iat']).to eq(Time.zone.now.to_i) + expect(decoded['exp']).to eq(Time.zone.now.to_i + 30.days.to_i) end end end @@ -30,11 +30,11 @@ RSpec.describe Widget::TokenService, type: :service do end it 'uses the default expiry' do - freeze_time do + travel_to '2025-01-01' do token = service.generate_token decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first - expect(decoded['iat']).to eq(Time.now.to_i) - expect(decoded['exp']).to eq(180.days.from_now.to_i) + expect(decoded['iat']).to eq(Time.zone.now.to_i) + expect(decoded['exp']).to eq(Time.zone.now.to_i + 180.days.to_i) end end end