From 159c81011740f1f97cee25b93ca36fd3ecc06f40 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 30 Oct 2025 02:58:28 -0700 Subject: [PATCH 01/21] feat: Bulk actions for contacts (#12763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces APIs and UI for bulk actions in contacts table. The initial action available will be assign labels Fixes: #8536 #12253 ## Screens Screenshot 2025-10-29 at 4 05 08 PM Screenshot 2025-10-29 at 4 05 19 PM --------- Co-authored-by: Muhsin Co-authored-by: iamsivin Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../v1/accounts/bulk_actions_controller.rb | 43 ++- .../Contacts/ContactsCard/ContactsCard.vue | 242 ++++++++++------- .../Contacts/Pages/ContactsList.vue | 65 +++-- .../captain/assistant/BulkSelectBar.vue | 13 +- .../conversationBulkActions/LabelActions.vue | 251 +++++++----------- .../dashboard/i18n/locale/en/bulkActions.json | 2 +- .../dashboard/i18n/locale/en/contact.json | 10 + .../components/ContactsBulkActionBar.vue | 142 ++++++++++ .../contacts/pages/ContactsIndex.vue | 90 ++++++- app/jobs/contacts/bulk_action_job.rb | 14 + app/services/contacts/bulk_action_service.rb | 32 +++ .../contacts/bulk_assign_labels_service.rb | 19 ++ .../accounts/bulk_actions_controller_spec.rb | 31 +++ spec/jobs/contacts/bulk_action_job_spec.rb | 22 ++ .../bulk_assign_labels_service_spec.rb | 48 ++++ 15 files changed, 730 insertions(+), 294 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue create mode 100644 app/jobs/contacts/bulk_action_job.rb create mode 100644 app/services/contacts/bulk_action_service.rb create mode 100644 app/services/contacts/bulk_assign_labels_service.rb create mode 100644 spec/jobs/contacts/bulk_action_job_spec.rb create mode 100644 spec/services/contacts/bulk_assign_labels_service_spec.rb diff --git a/app/controllers/api/v1/accounts/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/bulk_actions_controller.rb index 34db47861..1b8babbc9 100644 --- a/app/controllers/api/v1/accounts/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/bulk_actions_controller.rb @@ -1,13 +1,11 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController - before_action :type_matches? - def create - if type_matches? - ::BulkActionsJob.perform_later( - account: @current_account, - user: current_user, - params: permitted_params - ) + case normalized_type + when 'Conversation' + enqueue_conversation_job + head :ok + when 'Contact' + enqueue_contact_job head :ok else render json: { success: false }, status: :unprocessable_entity @@ -16,11 +14,34 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll private - def type_matches? - ['Conversation'].include?(params[:type]) + def normalized_type + params[:type].to_s.camelize end - def permitted_params + def enqueue_conversation_job + ::BulkActionsJob.perform_later( + account: @current_account, + user: current_user, + params: conversation_params + ) + end + + def enqueue_contact_job + Contacts::BulkActionJob.perform_later( + @current_account.id, + current_user.id, + contact_params + ) + end + + def conversation_params params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []]) 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 + end end diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index 0e893b767..12bed151d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue'; import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue'; +import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import countries from 'shared/constants/countries'; const props = defineProps({ @@ -20,9 +21,17 @@ const props = defineProps({ availabilityStatus: { type: String, default: null }, isExpanded: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false }, + selectable: { type: Boolean, default: false }, + isSelected: { type: Boolean, default: false }, }); -const emit = defineEmits(['toggle', 'updateContact', 'showContact']); +const emit = defineEmits([ + 'toggle', + 'updateContact', + 'showContact', + 'select', + 'avatarHover', +]); const { t } = useI18n(); @@ -88,111 +97,148 @@ const onClickExpand = () => { }; const onClickViewDetails = () => emit('showContact', props.id); + +const toggleSelect = checked => { + emit('select', checked); +}; + +const handleAvatarHover = isHovered => { + emit('avatarHover', isHovered); +}; diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue index 7acc2ff55..72bc2c7dd 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue @@ -10,7 +10,15 @@ import { } from 'shared/helpers/CustomErrors'; import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue'; -defineProps({ contacts: { type: Array, required: true } }); +const props = defineProps({ + contacts: { type: Array, required: true }, + selectedContactIds: { + type: Array, + default: () => [], + }, +}); + +const emit = defineEmits(['toggleContact']); const { t } = useI18n(); const store = useStore(); @@ -20,6 +28,9 @@ const route = useRoute(); const uiFlags = useMapGetter('contacts/getUIFlags'); const isUpdating = computed(() => uiFlags.value.isUpdating); const expandedCardId = ref(null); +const hoveredAvatarId = ref(null); + +const selectedIdsSet = computed(() => new Set(props.selectedContactIds || [])); const updateContact = async updatedData => { try { @@ -58,25 +69,43 @@ const onClickViewDetails = async id => { const toggleExpanded = id => { expandedCardId.value = expandedCardId.value === id ? null : id; }; + +const isSelected = id => selectedIdsSet.value.has(id); + +const shouldShowSelection = id => { + return hoveredAvatarId.value === id || isSelected(id); +}; + +const handleSelect = (id, value) => { + emit('toggleContact', { id, value }); +}; + +const handleAvatarHover = (id, isHovered) => { + hoveredAvatarId.value = isHovered ? id : null; +}; diff --git a/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue b/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue index 94cab9a33..b7ee59ead 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue @@ -61,23 +61,26 @@ const bulkCheckboxState = computed({ >
-
+
- + {{ selectAllLabel }}
- + {{ selectedCountLabel }} +
+
-
- +
+
+ +
+ +
diff --git a/app/jobs/contacts/bulk_action_job.rb b/app/jobs/contacts/bulk_action_job.rb new file mode 100644 index 000000000..e05a9541f --- /dev/null +++ b/app/jobs/contacts/bulk_action_job.rb @@ -0,0 +1,14 @@ +class Contacts::BulkActionJob < ApplicationJob + queue_as :medium + + def perform(account_id, user_id, params) + account = Account.find(account_id) + user = User.find(user_id) + + Contacts::BulkActionService.new( + account: account, + user: user, + params: params + ).perform + end +end diff --git a/app/services/contacts/bulk_action_service.rb b/app/services/contacts/bulk_action_service.rb new file mode 100644 index 000000000..c759e95a5 --- /dev/null +++ b/app/services/contacts/bulk_action_service.rb @@ -0,0 +1,32 @@ +class Contacts::BulkActionService + def initialize(account:, user:, params:) + @account = account + @user = user + @params = params.deep_symbolize_keys + end + + def perform + return assign_labels if labels_to_add.any? + + Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}") + { success: false, error: 'unknown_operation' } + end + + private + + def assign_labels + Contacts::BulkAssignLabelsService.new( + account: @account, + contact_ids: ids, + labels: labels_to_add + ).perform + end + + def ids + Array(@params[:ids]).compact + end + + def labels_to_add + @labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?) + end +end diff --git a/app/services/contacts/bulk_assign_labels_service.rb b/app/services/contacts/bulk_assign_labels_service.rb new file mode 100644 index 000000000..4aa4a5de5 --- /dev/null +++ b/app/services/contacts/bulk_assign_labels_service.rb @@ -0,0 +1,19 @@ +class Contacts::BulkAssignLabelsService + def initialize(account:, contact_ids:, labels:) + @account = account + @contact_ids = Array(contact_ids) + @labels = Array(labels).compact_blank + end + + def perform + return { success: true, updated_contact_ids: [] } if @contact_ids.blank? || @labels.blank? + + contacts = @account.contacts.where(id: @contact_ids) + + contacts.find_each do |contact| + contact.add_labels(@labels) + end + + { success: true, updated_contact_ids: contacts.pluck(:id) } + end +end diff --git a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb index ec6af61be..9c53099fd 100644 --- a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb @@ -195,6 +195,37 @@ 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 diff --git a/spec/jobs/contacts/bulk_action_job_spec.rb b/spec/jobs/contacts/bulk_action_job_spec.rb new file mode 100644 index 000000000..12a727000 --- /dev/null +++ b/spec/jobs/contacts/bulk_action_job_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +RSpec.describe Contacts::BulkActionJob, type: :job do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:params) { { 'ids' => [1], 'labels' => { 'add' => ['vip'] } } } + + it 'invokes the bulk action service with account and user' do + service_instance = instance_double(Contacts::BulkActionService, perform: true) + + allow(Contacts::BulkActionService).to receive(:new).and_return(service_instance) + + described_class.perform_now(account.id, user.id, params) + + expect(Contacts::BulkActionService).to have_received(:new).with( + account: account, + user: user, + params: params + ) + expect(service_instance).to have_received(:perform) + end +end diff --git a/spec/services/contacts/bulk_assign_labels_service_spec.rb b/spec/services/contacts/bulk_assign_labels_service_spec.rb new file mode 100644 index 000000000..3ae27a81b --- /dev/null +++ b/spec/services/contacts/bulk_assign_labels_service_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +RSpec.describe Contacts::BulkAssignLabelsService do + subject(:service) do + described_class.new( + account: account, + contact_ids: [contact_one.id, contact_two.id, other_contact.id], + labels: labels + ) + end + + let(:account) { create(:account) } + let!(:contact_one) { create(:contact, account: account) } + let!(:contact_two) { create(:contact, account: account) } + let!(:other_contact) { create(:contact) } + let(:labels) { %w[vip support] } + + it 'assigns labels to the contacts that belong to the account' do + service.perform + + expect(contact_one.reload.label_list).to include(*labels) + expect(contact_two.reload.label_list).to include(*labels) + end + + it 'does not assign labels to contacts outside the account' do + service.perform + + expect(other_contact.reload.label_list).to be_empty + end + + it 'returns ids of contacts that were updated' do + result = service.perform + + expect(result[:success]).to be(true) + expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id) + end + + it 'returns success with no updates when labels are blank' do + result = described_class.new( + account: account, + contact_ids: [contact_one.id], + labels: [] + ).perform + + expect(result).to eq(success: true, updated_contact_ids: []) + expect(contact_one.reload.label_list).to be_empty + end +end From faaf67129efb8b82f41b121b2030aee425363f2a Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Thu, 30 Oct 2025 21:17:37 +0530 Subject: [PATCH 02/21] feat: Enable opensearch on paid plans automatically (#12770) - enable `advanced_search feature` on all paid plans automatically ref: https://github.com/chatwoot/chatwoot/pull/12503 --- .../services/enterprise/billing/handle_stripe_event_service.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index d0d2f73d8..5364409a1 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -16,6 +16,7 @@ class Enterprise::Billing::HandleStripeEventService channel_instagram captain_integration advanced_search_indexing + advanced_search ].freeze # Additional features available starting with the Business plan From 6b87d6784eeea836901f589f602e7e2f28c7fb25 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 31 Oct 2025 00:27:46 +0530 Subject: [PATCH 03/21] chore: Make contacts bulk action bar sticky (#12773) # Pull Request Template ## Description This PR makes the contacts bulk action bar sticky while scrolling. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Screenshots image image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../captain/assistant/BulkSelectBar.vue | 2 +- .../components/ContactsBulkActionBar.vue | 100 +++++++++--------- .../contacts/pages/ContactsIndex.vue | 23 ++-- 3 files changed, 63 insertions(+), 62 deletions(-) diff --git a/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue b/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue index b7ee59ead..0bf2007ff 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/BulkSelectBar.vue @@ -61,7 +61,7 @@ const bulkCheckboxState = computed({ >
diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue index c7bb5fbbf..24f0c7aa5 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue @@ -87,56 +87,60 @@ const handleAssignLabels = labels => { diff --git a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue index 4bd290301..b5ee2ae4c 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue @@ -383,6 +383,15 @@ onMounted(async () => {
diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 9b87da6b2..a711a05af 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -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": { diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue index 24f0c7aa5..e8ddd5223 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue @@ -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 => { />
+ +
diff --git a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue index b5ee2ae4c..c2b832388 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue @@ -13,6 +13,7 @@ import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/Con import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue'; import ContactsBulkActionBar from '../components/ContactsBulkActionBar.vue'; +import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import BulkActionsAPI from 'dashboard/api/bulkActions'; const DEFAULT_SORT_FIELD = 'last_activity_at'; @@ -64,7 +65,26 @@ const totalItems = computed(() => meta.value?.count); const selectedContactIds = ref([]); const isBulkActionLoading = ref(false); -const hasSelection = computed(() => selectedContactIds.value.length > 0); +const bulkDeleteDialogRef = ref(null); +const selectedCount = computed(() => selectedContactIds.value.length); +const bulkDeleteDialogTitle = computed(() => + selectedCount.value > 1 + ? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.TITLE') + : t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_TITLE') +); +const bulkDeleteDialogDescription = computed(() => + selectedCount.value > 1 + ? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.DESCRIPTION', { + count: selectedCount.value, + }) + : t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_DESCRIPTION') +); +const bulkDeleteDialogConfirmLabel = computed(() => + selectedCount.value > 1 + ? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_MULTIPLE') + : t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_SINGLE') +); +const hasSelection = computed(() => selectedCount.value > 0); const activeSegment = computed(() => { if (!activeSegmentId.value) return undefined; return segments.value.find(view => view.id === Number(activeSegmentId.value)); @@ -120,6 +140,11 @@ const clearSelection = () => { selectedContactIds.value = []; }; +const openBulkDeleteDialog = () => { + if (!selectedContactIds.value.length || isBulkActionLoading.value) return; + bulkDeleteDialogRef.value?.open?.(); +}; + const toggleSelectAll = shouldSelect => { selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : []; }; @@ -256,6 +281,29 @@ const assignLabels = async labels => { } }; +const deleteContacts = async () => { + if (!selectedContactIds.value.length) { + return; + } + + isBulkActionLoading.value = true; + try { + await BulkActionsAPI.create({ + type: 'Contact', + ids: selectedContactIds.value, + action_name: 'delete', + }); + useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_SUCCESS')); + clearSelection(); + await fetchContactsBasedOnContext(pageNumber.value); + bulkDeleteDialogRef.value?.close?.(); + } catch (error) { + useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_FAILED')); + } finally { + isBulkActionLoading.value = false; + } +}; + const handleSort = async ({ sort, order }) => { Object.assign(sortState, { activeSort: sort, activeOrdering: order }); @@ -297,6 +345,12 @@ watch( { deep: true } ); +watch(hasSelection, value => { + if (!value) { + bulkDeleteDialogRef.value?.close?.(); + } +}); + watch( () => uiSettings.value?.contacts_sort_by, newSortBy => { @@ -391,6 +445,7 @@ onMounted(async () => { @toggle-all="toggleSelectAll" @clear-selection="clearSelection" @assign-labels="assignLabels" + @delete-selected="openBulkDeleteDialog" /> { {{ emptyStateMessage }}
-
+
+
diff --git a/app/services/contacts/bulk_action_service.rb b/app/services/contacts/bulk_action_service.rb index c759e95a5..a0e11ad9a 100644 --- a/app/services/contacts/bulk_action_service.rb +++ b/app/services/contacts/bulk_action_service.rb @@ -6,6 +6,7 @@ class Contacts::BulkActionService end def perform + return delete_contacts if delete_requested? return assign_labels if labels_to_add.any? Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}") @@ -22,6 +23,13 @@ class Contacts::BulkActionService ).perform end + def delete_contacts + Contacts::BulkDeleteService.new( + account: @account, + contact_ids: ids + ).perform + end + def ids Array(@params[:ids]).compact end @@ -29,4 +37,8 @@ class Contacts::BulkActionService def labels_to_add @labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?) end + + def delete_requested? + @params[:action_name] == 'delete' + end end diff --git a/app/services/contacts/bulk_delete_service.rb b/app/services/contacts/bulk_delete_service.rb new file mode 100644 index 000000000..d197f17f6 --- /dev/null +++ b/app/services/contacts/bulk_delete_service.rb @@ -0,0 +1,18 @@ +class Contacts::BulkDeleteService + def initialize(account:, contact_ids: []) + @account = account + @contact_ids = Array(contact_ids).compact + end + + def perform + return if @contact_ids.blank? + + contacts.find_each(&:destroy!) + end + + private + + def contacts + @account.contacts.where(id: @contact_ids) + end +end diff --git a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb index 9c53099fd..1ab490a96 100644 --- a/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/bulk_actions_controller_spec.rb @@ -195,37 +195,6 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer') expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer') end - - it 'enqueues contact bulk action job with permitted params' do - contact_one = create(:contact, account: account) - contact_two = create(:contact, account: account) - - previous_adapter = ActiveJob::Base.queue_adapter - ActiveJob::Base.queue_adapter = :test - - expect do - post "/api/v1/accounts/#{account.id}/bulk_actions", - headers: agent.create_new_auth_token, - params: { - type: 'Contact', - ids: [contact_one.id, contact_two.id], - labels: { add: %w[vip support] }, - extra: 'ignored' - } - end.to have_enqueued_job(Contacts::BulkActionJob).with( - account.id, - agent.id, - hash_including( - 'ids' => [contact_one.id, contact_two.id], - 'labels' => hash_including('add' => %w[vip support]) - ) - ) - - expect(response).to have_http_status(:success) - ensure - ActiveJob::Base.queue_adapter = previous_adapter - clear_enqueued_jobs - end end end @@ -256,4 +225,49 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do end end end + + describe 'POST /api/v1/accounts/{account.id}/bulk_actions (contacts)' do + context 'when it is an authenticated user' do + let!(:agent) { create(:user, account: account, role: :agent) } + + it 'enqueues Contacts::BulkActionJob with permitted params' do + contact_one = create(:contact, account: account) + contact_two = create(:contact, account: account) + + expect do + post "/api/v1/accounts/#{account.id}/bulk_actions", + headers: agent.create_new_auth_token, + params: { + type: 'Contact', + ids: [contact_one.id, contact_two.id], + labels: { add: %w[vip support] }, + extra: 'ignored' + } + end.to have_enqueued_job(Contacts::BulkActionJob).with( + account.id, + agent.id, + hash_including( + 'ids' => [contact_one.id.to_s, contact_two.id.to_s], + 'labels' => hash_including('add' => %w[vip support]) + ) + ) + + expect(response).to have_http_status(:success) + end + + it 'returns unauthorized for delete action when user is not admin' do + contact = create(:contact, account: account) + + post "/api/v1/accounts/#{account.id}/bulk_actions", + headers: agent.create_new_auth_token, + params: { + type: 'Contact', + ids: [contact.id], + action_name: 'delete' + } + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/services/contacts/bulk_action_service_spec.rb b/spec/services/contacts/bulk_action_service_spec.rb new file mode 100644 index 000000000..0411c8455 --- /dev/null +++ b/spec/services/contacts/bulk_action_service_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe Contacts::BulkActionService do + subject(:service) { described_class.new(account: account, user: user, params: params) } + + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + + describe '#perform' do + context 'when delete action is requested via action_name' do + let(:params) { { ids: [1, 2], action_name: 'delete' } } + + it 'delegates to the bulk delete service' do + bulk_delete_service = instance_double(Contacts::BulkDeleteService, perform: true) + + expect(Contacts::BulkDeleteService).to receive(:new) + .with(account: account, contact_ids: [1, 2]) + .and_return(bulk_delete_service) + + service.perform + end + end + + context 'when labels are provided' do + let(:params) { { ids: [10, 20], labels: { add: %w[vip support] }, extra: 'ignored' } } + + it 'delegates to the bulk assign labels service with permitted params' do + bulk_assign_service = instance_double(Contacts::BulkAssignLabelsService, perform: true) + + expect(Contacts::BulkAssignLabelsService).to receive(:new) + .with(account: account, contact_ids: [10, 20], labels: %w[vip support]) + .and_return(bulk_assign_service) + + service.perform + end + end + end +end diff --git a/spec/services/contacts/bulk_delete_service_spec.rb b/spec/services/contacts/bulk_delete_service_spec.rb new file mode 100644 index 000000000..4eaecaa0f --- /dev/null +++ b/spec/services/contacts/bulk_delete_service_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe Contacts::BulkDeleteService do + subject(:service) { described_class.new(account: account, contact_ids: contact_ids) } + + let(:account) { create(:account) } + let!(:contact_one) { create(:contact, account: account) } + let!(:contact_two) { create(:contact, account: account) } + let(:contact_ids) { [contact_one.id, contact_two.id] } + + describe '#perform' do + it 'deletes the provided contacts' do + expect { service.perform } + .to change { account.contacts.exists?(contact_one.id) }.from(true).to(false) + .and change { account.contacts.exists?(contact_two.id) }.from(true).to(false) + end + + it 'returns when no contact ids are provided' do + empty_service = described_class.new(account: account, contact_ids: []) + + expect { empty_service.perform }.not_to change(Contact, :count) + end + end +end From 72391f9c36007e5347b5762476b8204f29177c8d Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:40:11 +0530 Subject: [PATCH 12/21] fix: Video bubble click and play issue (#12764) Co-authored-by: Muhsin Keloth --- .../dashboard/components-next/message/bubbles/Video.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/dashboard/components-next/message/bubbles/Video.vue b/app/javascript/dashboard/components-next/message/bubbles/Video.vue index b8bc7a43d..2151b188c 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Video.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Video.vue @@ -47,6 +47,7 @@ const isReel = computed(() => { 'max-w-48': isReel, 'max-w-full': !isReel, }" + @click.stop @error="handleError" />
From 5491ca24701975a60c6b38532098ab153115e397 Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 5 Nov 2025 11:42:21 -0800 Subject: [PATCH 13/21] feat: Differentiate bot and user in the summary (#12801) While generating the summary, use the appropriate sender type for the message. --- .../llm_formatter/conversation_llm_formatter.rb | 9 ++++++++- spec/factories/messages.rb | 7 +++++++ .../llm_formatter/conversation_llm_formatter_spec.rb | 11 ++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/services/llm_formatter/conversation_llm_formatter.rb b/app/services/llm_formatter/conversation_llm_formatter.rb index 8654e0adf..4e0bd7013 100644 --- a/app/services/llm_formatter/conversation_llm_formatter.rb +++ b/app/services/llm_formatter/conversation_llm_formatter.rb @@ -39,7 +39,14 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter end def format_message(message) - sender = message.message_type == 'incoming' ? 'User' : 'Support agent' + sender = case message.sender_type + when 'User' + 'Support Agent' + when 'Contact' + 'User' + else + 'Bot' + end sender = "[Private Note] #{sender}" if message.private? "#{sender}: #{message.content}\n" end diff --git a/spec/factories/messages.rb b/spec/factories/messages.rb index b2ae41c5e..99a2c7cd8 100644 --- a/spec/factories/messages.rb +++ b/spec/factories/messages.rb @@ -27,6 +27,13 @@ FactoryBot.define do end end + trait :bot_message do + message_type { 'outgoing' } + after(:build) do |message| + message.sender = nil + end + end + after(:build) do |message| message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account) message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account) diff --git a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb index 49fcc1a18..b79ee6d79 100644 --- a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb +++ b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb @@ -28,6 +28,14 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do content: 'Hello, I need help' ) + create( + :message, + :bot_message, + conversation: conversation, + message_type: 'outgoing', + content: 'Thanks for reaching out, an agent will reach out to you soon' + ) + create( :message, conversation: conversation, @@ -40,7 +48,8 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do "Channel: #{conversation.inbox.channel.name}", 'Message History:', 'User: Hello, I need help', - 'Support agent: How can I assist you today?', + 'Bot: Thanks for reaching out, an agent will reach out to you soon', + 'Support Agent: How can I assist you today?', '' ].join("\n") From 48ba273730f57aafc7df67e6f70154549dff8cf7 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:53:31 +0530 Subject: [PATCH 14/21] fix: Invalid image URL issue in Help Center articles (#12806) --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 6fb7156cb..4660b44fa 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "dependencies": { "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.2.1", + "@chatwoot/prosemirror-schema": "1.2.3", "@chatwoot/utils": "^0.0.51", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22c662d89..e5629af3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.2.1 - version: 1.2.1 + specifier: 1.2.3 + version: 1.2.3 '@chatwoot/utils': specifier: ^0.0.51 version: 0.0.51 @@ -406,8 +406,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.2.1': - resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==} + '@chatwoot/prosemirror-schema@1.2.3': + resolution: {integrity: sha512-q/EfirVK9jt8FJAx3Gf6y3LoVadmYVLknbYvPrkUe81WO0f2mkZ/kY2UQgpUISVvOGEkCH4bkfYMp5UQ+Buz3g==} '@chatwoot/utils@0.0.51': resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==} @@ -4768,7 +4768,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.2.1': + '@chatwoot/prosemirror-schema@1.2.3': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.6.0 From ec6c3b3571187e2e3f64781746d9e5a81e046dee Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 6 Nov 2025 14:00:47 +0530 Subject: [PATCH 15/21] feat: allow bots to handle campaigns when sender_id is nil (#12805) --- app/models/conversation.rb | 9 +++++--- spec/models/conversation_spec.rb | 35 +++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 4ec63acc2..eb97a22e8 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -228,14 +228,17 @@ class Conversation < ApplicationRecord def determine_conversation_status self.status = :resolved and return if contact.blocked? - # Message template hooks aren't executed for conversations from campaigns - # So making these conversations open for agent visibility - return if campaign.present? + return handle_campaign_status if campaign.present? # TODO: make this an inbox config instead of assuming bot conversations should start as pending self.status = :pending if inbox.active_bot? end + def handle_campaign_status + # If campaign has no sender (bot-initiated) and inbox has active bot, let bot handle it + self.status = :pending if campaign.sender_id.nil? && inbox.active_bot? + end + def notify_conversation_creation dispatcher_dispatch(CONVERSATION_CREATED) end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 72962ba54..51bb43384 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -576,9 +576,38 @@ RSpec.describe Conversation do expect(conversation.status).to eq('pending') end - it 'returns conversation as open if campaign is present' do - conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: create(:campaign)) - expect(conversation.status).to eq('open') + context 'with campaigns' do + let(:user) { create(:user, account: bot_inbox.inbox.account) } + + it 'returns conversation as open if campaign has a sender' do + campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: user) + conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign) + expect(conversation.status).to eq('open') + end + + it 'returns conversation as pending if campaign has no sender (bot-initiated) and bot is active' do + campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: nil) + conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign) + expect(conversation.status).to eq('pending') + end + end + + context 'with campaigns in inbox without bot' do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:user) { create(:user, account: account) } + + it 'returns conversation as open if campaign has no sender but no bot is active' do + campaign = create(:campaign, inbox: inbox, account: account, sender: nil) + conversation = create(:conversation, inbox: inbox, campaign: campaign) + expect(conversation.status).to eq('open') + end + + it 'returns conversation as open if campaign has a sender' do + campaign = create(:campaign, inbox: inbox, account: account, sender: user) + conversation = create(:conversation, inbox: inbox, campaign: campaign) + expect(conversation.status).to eq('open') + end end end From 9b75d9bd1b21341c6af2024b24d743434c8c6879 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:05:52 +0530 Subject: [PATCH 16/21] fix: Add empty line before signature in compose conversation editor (#12702) Co-authored-by: Shivam Mishra --- .../components-next/Editor/Editor.vue | 8 +++++++ .../components/ActionButtons.vue | 1 - .../components/ComposeNewConversationForm.vue | 22 ++++++++++++------- .../components/MessageEditor.vue | 5 +++++ .../components/widgets/WootWriter/Editor.vue | 11 +++++++++- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index a2f139bdc..67936fa59 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -21,6 +21,10 @@ const props = defineProps({ enableCannedResponses: { type: Boolean, default: true }, enabledMenuOptions: { type: Array, default: () => [] }, enableCaptainTools: { type: Boolean, default: false }, + signature: { type: String, default: '' }, + allowSignature: { type: Boolean, default: false }, + sendWithSignature: { type: Boolean, default: false }, + channelType: { type: String, default: '' }, }); const emit = defineEmits(['update:modelValue']); @@ -100,6 +104,10 @@ watch( :enable-canned-responses="enableCannedResponses" :enabled-menu-options="enabledMenuOptions" :enable-captain-tools="enableCaptainTools" + :signature="signature" + :allow-signature="allowSignature" + :send-with-signature="sendWithSignature" + :channel-type="channelType" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index 773ebe315..92c5850de 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -92,7 +92,6 @@ const setSignature = () => { const toggleMessageSignature = () => { setSignatureFlagForInbox(props.channelType, !sendWithSignature.value); - setSignature(); }; // Added this watch to dynamically set signature on target inbox change. diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 4d6d41dac..a02d6d495 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => { state.attachedFiles = []; }; -const removeTargetInbox = value => { - v$.value.$reset(); - // Remove the signature from message content - // Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp) - if (props.sendWithSignature) { - const signatureToRemove = inboxTypes.value.isEmailOrWebWidget - ? props.messageSignature - : extractTextFromMarkdown(props.messageSignature); +const removeSignatureFromMessage = () => { + // Always remove the signature from message content when inbox/contact is removed + // to ensure no leftover signature content remains + const signatureToRemove = inboxTypes.value.isEmailOrWebWidget + ? props.messageSignature + : extractTextFromMarkdown(props.messageSignature); + if (signatureToRemove) { state.message = removeSignature(state.message, signatureToRemove); } +}; + +const removeTargetInbox = value => { + v$.value.$reset(); + removeSignatureFromMessage(); emit('updateTargetInbox', value); state.attachedFiles = []; }; @@ -216,6 +220,7 @@ const removeTargetInbox = value => { const clearSelectedContact = () => { emit('clearSelectedContact'); state.attachedFiles = []; + removeSignatureFromMessage(); }; const onClickInsertEmoji = emoji => { @@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => { :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :has-errors="validationStates.isMessageInvalid" :has-attachments="state.attachedFiles.length > 0" + :channel-type="inboxChannelType" /> { " enable-variables :show-character-count="false" + :signature="messageSignature" + allow-signature + :send-with-signature="sendWithSignature" + :channel-type="channelType" />