From 40c75941f582d3331d162c68c11439da524e18e1 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 4 Nov 2025 13:28:51 -0800 Subject: [PATCH 1/9] fix: Avoid introducing new attributes in search (#12791) Fix `Limit of total fields [1000] has been exceeded` https://linear.app/chatwoot/issue/CW-5861/searchkickimporterror-type-=-illegal-argument-exception-reason-=-limit#comment-6b6e41bd --- app/presenters/messages/search_data_presenter.rb | 4 ++-- .../messages/search_data_presenter_spec.rb | 4 ++-- spec/services/widget/token_service_expiry_spec.rb | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/presenters/messages/search_data_presenter.rb b/app/presenters/messages/search_data_presenter.rb index dba0b9499..7d0638add 100644 --- a/app/presenters/messages/search_data_presenter.rb +++ b/app/presenters/messages/search_data_presenter.rb @@ -3,7 +3,7 @@ class Messages::SearchDataPresenter < SimpleDelegator { **searchable_content, **message_attributes, - **search_additional_data, + additional_attributes: additional_attributes_data, conversation: conversation_data } end @@ -49,7 +49,7 @@ class Messages::SearchDataPresenter < SimpleDelegator { id: conversation.display_id } end - def search_additional_data + def additional_attributes_data { campaign_id: additional_attributes&.dig('campaign_id'), automation_rule_id: content_attributes&.dig('automation_rule_id') diff --git a/spec/presenters/messages/search_data_presenter_spec.rb b/spec/presenters/messages/search_data_presenter_spec.rb index a5062086a..0bbe1a812 100644 --- a/spec/presenters/messages/search_data_presenter_spec.rb +++ b/spec/presenters/messages/search_data_presenter_spec.rb @@ -67,11 +67,11 @@ RSpec.describe Messages::SearchDataPresenter do end it 'includes campaign_id' do - expect(presenter.search_data[:campaign_id]).to eq('123') + expect(presenter.search_data[:additional_attributes][:campaign_id]).to eq('123') end it 'includes automation_rule_id' do - expect(presenter.search_data[:automation_rule_id]).to eq('456') + expect(presenter.search_data[:additional_attributes][:automation_rule_id]).to eq('456') end end end diff --git a/spec/services/widget/token_service_expiry_spec.rb b/spec/services/widget/token_service_expiry_spec.rb index 051a757a5..50e104c65 100644 --- a/spec/services/widget/token_service_expiry_spec.rb +++ b/spec/services/widget/token_service_expiry_spec.rb @@ -15,11 +15,11 @@ RSpec.describe Widget::TokenService, type: :service do end it 'uses the configured value for token expiry' do - freeze_time do + travel_to '2025-01-01' do token = service.generate_token decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first - expect(decoded['iat']).to eq(Time.now.to_i) - expect(decoded['exp']).to eq(30.days.from_now.to_i) + expect(decoded['iat']).to eq(Time.zone.now.to_i) + expect(decoded['exp']).to eq(Time.zone.now.to_i + 30.days.to_i) end end end @@ -30,11 +30,11 @@ RSpec.describe Widget::TokenService, type: :service do end it 'uses the default expiry' do - freeze_time do + travel_to '2025-01-01' do token = service.generate_token decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first - expect(decoded['iat']).to eq(Time.now.to_i) - expect(decoded['exp']).to eq(180.days.from_now.to_i) + expect(decoded['iat']).to eq(Time.zone.now.to_i) + expect(decoded['exp']).to eq(Time.zone.now.to_i + 180.days.to_i) end end end From e8ae73230d7b4a0eb69ada06a76e4247f79d53e7 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 4 Nov 2025 16:01:47 -0800 Subject: [PATCH 2/9] fix: Gate Sidekiq dequeue logger behind env (#12790) ## Summary - wrap the dequeue middleware registration in a boolean env flag - document the ENABLE_SIDEKIQ_DEQUEUE_LOGGER option in .env.example --- .env.example | 2 ++ config/initializers/sidekiq.rb | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index de671599c..3ff349ffc 100644 --- a/.env.example +++ b/.env.example @@ -256,6 +256,8 @@ AZURE_APP_SECRET= ## Change these values to fine tune performance # control the concurrency setting of sidekiq # SIDEKIQ_CONCURRENCY=10 +# Enable verbose logging each time a job is dequeued in Sidekiq +# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false # AI powered features diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 04d605c2e..dd5c71a4d 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -18,8 +18,10 @@ end Sidekiq.configure_server do |config| config.redis = Redis::Config.app - config.server_middleware do |chain| - chain.add ChatwootDequeuedLogger + if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_SIDEKIQ_DEQUEUE_LOGGER', false)) + config.server_middleware do |chain| + chain.add ChatwootDequeuedLogger + end end # skip the default start stop logging From f89d9a440109b93d2293817c99ff32aedf284390 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 4 Nov 2025 17:47:53 -0800 Subject: [PATCH 3/9] feat: Bulk delete for contacts (#12778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new bulk action `delete` for contacts ref: https://github.com/chatwoot/chatwoot/pull/12763 ## Screens Screenshot 2025-10-31 at 6 27 21 PM Screenshot 2025-10-31 at 6 27 32 PM --- .../v1/accounts/bulk_actions_controller.rb | 31 ++++++-- .../ContactsCard/ContactDeleteSection.vue | 73 +++++++++--------- .../Contacts/Pages/ContactDetails.vue | 43 ++++++----- .../dashboard/i18n/locale/en/contact.json | 13 +++- .../components/ContactsBulkActionBar.vue | 23 +++++- .../contacts/pages/ContactsIndex.vue | 69 ++++++++++++++++- app/services/contacts/bulk_action_service.rb | 12 +++ app/services/contacts/bulk_delete_service.rb | 18 +++++ .../accounts/bulk_actions_controller_spec.rb | 76 +++++++++++-------- .../contacts/bulk_action_service_spec.rb | 38 ++++++++++ .../contacts/bulk_delete_service_spec.rb | 24 ++++++ 11 files changed, 325 insertions(+), 95 deletions(-) create mode 100644 app/services/contacts/bulk_delete_service.rb create mode 100644 spec/services/contacts/bulk_action_service_spec.rb create mode 100644 spec/services/contacts/bulk_delete_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 1b8babbc9..222c66714 100644 --- a/app/controllers/api/v1/accounts/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/bulk_actions_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll enqueue_conversation_job head :ok when 'Contact' + check_authorization_for_contact_action enqueue_contact_job head :ok else @@ -34,14 +35,34 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll ) end + def delete_contact_action? + params[:action_name] == 'delete' + end + + def check_authorization_for_contact_action + authorize(Contact, :destroy?) if delete_contact_action? + end + def conversation_params - params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []]) + # TODO: Align conversation payloads with the `{ action_name, action_attributes }` + # and then remove this method in favor of a common params method. + base = params.permit( + :snoozed_until, + fields: [:status, :assignee_id, :team_id] + ) + append_common_bulk_attributes(base) end def contact_params - params.require(:ids) - permitted = params.permit(:type, ids: [], labels: [add: []]) - permitted[:ids] = permitted[:ids].map(&:to_i) if permitted[:ids].present? - permitted + # TODO: remove this method in favor of a common params method. + # once legacy conversation payloads are migrated. + append_common_bulk_attributes({}) + end + + def append_common_bulk_attributes(base_params) + # NOTE: Conversation payloads historically diverged per action. Going forward we + # want all objects to share a common contract: `{ action_name, action_attributes }` + common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []]) + base_params.merge(common) end end diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue index 47b779b61..041f06410 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue @@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core'; import Button from 'dashboard/components-next/button/Button.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; defineProps({ selectedContact: { @@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => { diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue index aec21a976..b7014c42f 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue @@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue'; import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; const props = defineProps({ selectedContact: { @@ -174,27 +175,29 @@ const handleAvatarDelete = async () => { @click="updateContact" /> -
-
-
- {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} -
- - {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} - + +
+
+
+ {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} +
+ + {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} + +
+
-
- +
diff --git a/app/javascript/dashboard/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 => { /> + +