Merge branch 'develop' into feat/CW-5624
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 --##
|
||||
|
||||
@@ -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
|
||||
|
||||
+38
-35
@@ -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.
|
||||
|
||||
+14
-8
@@ -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": {
|
||||
|
||||
+22
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user