Merge branch 'develop' into feat/CW-5624

This commit is contained in:
Sivin Varghese
2025-11-06 14:21:26 +05:30
committed by GitHub
46 changed files with 1188 additions and 140 deletions
+2
View File
@@ -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
+1
View File
@@ -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 --##
+2
View File
@@ -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)
@@ -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
@@ -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 = () => {
</script>
<template>
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<Policy :permissions="['administrator']">
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
</div>
</div>
</div>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</Policy>
</template>
@@ -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"
/>
</div>
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
<Policy :permissions="['administrator']">
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</Policy>
</div>
</template>
@@ -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"
@@ -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.
@@ -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"
/>
<AttachmentPreviews
@@ -17,6 +17,7 @@ const props = defineProps({
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
});
const { t } = useI18n();
@@ -90,6 +91,10 @@ const replaceText = async message => {
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/>
</template>
<template v-else>
@@ -47,6 +47,7 @@ const isReel = computed(() => {
'max-w-48': isReel,
'max-w-full': !isReel,
}"
@click.stop
@error="handleError"
/>
</div>
@@ -302,7 +302,16 @@ function isBodyEmpty(content) {
}
function handleEmptyBodyWithSignature() {
const { schema, tr } = state;
const { schema, tr, doc } = state;
const isEmptyParagraph = node =>
node && node.type === schema.nodes.paragraph && node.content.size === 0;
// Check if empty paragraph already exists to prevent duplicates when toggling signatures
if (isEmptyParagraph(doc.firstChild)) {
focusEditorInputField('start');
return;
}
// create a paragraph node and
// start a transaction to append it at the end
@@ -580,7 +580,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": {
@@ -6,6 +6,7 @@ import { vOnClickOutside } from '@vueuse/components';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
visibleContactIds: {
@@ -22,7 +23,12 @@ const props = defineProps({
},
});
const emit = defineEmits(['clearSelection', 'assignLabels', 'toggleAll']);
const emit = defineEmits([
'clearSelection',
'assignLabels',
'toggleAll',
'deleteSelected',
]);
const { t } = useI18n();
@@ -139,6 +145,21 @@ const handleAssignLabels = labels => {
/>
</transition>
</div>
<Policy :permissions="['administrator']">
<Button
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
sm
faded
ruby
icon="i-lucide-trash"
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
:disabled="!selectedCount || isLoading"
:is-loading="isLoading"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
@click="emit('deleteSelected')"
/>
</Policy>
</div>
</template>
</BulkSelectBar>
@@ -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"
/>
<ContactEmptyState
v-if="showEmptyStateLayout"
@@ -408,12 +463,22 @@ onMounted(async () => {
{{ emptyStateMessage }}
</span>
</div>
<div v-else class="flex flex-col gap-4 px-6 pt-2 pb-6">
<div v-else class="flex flex-col gap-4 px-6 pt-4 pb-6">
<ContactsList
:contacts="contacts"
:selected-contact-ids="selectedContactIds"
@toggle-contact="toggleContactSelection"
/>
<Dialog
v-if="selectedCount"
ref="bulkDeleteDialogRef"
type="alert"
:title="bulkDeleteDialogTitle"
:description="bulkDeleteDialogDescription"
:confirm-button-label="bulkDeleteDialogConfirmLabel"
:is-loading="isBulkActionLoading"
@confirm="deleteContacts"
/>
</div>
</template>
</ContactsListLayout>
+6 -3
View File
@@ -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
+4 -9
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+4 -2
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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
@@ -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
@@ -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
+4 -3
View File
@@ -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
@@ -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
@@ -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
@@ -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
+12
View File
@@ -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
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+7
View File
@@ -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)
+32 -3
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
@@ -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