From 8e15ada16454ad4a436cbe5acb35c9c49b954d03 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 19 Jan 2024 19:55:51 +0530 Subject: [PATCH 001/101] fix: TypeError: Cannot read properties of undefined (reading 'emoji') (#8747) --- .../dashboard/components/widgets/forms/PhoneInput.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/forms/PhoneInput.vue b/app/javascript/dashboard/components/widgets/forms/PhoneInput.vue index 430a1ccb5..6aa560ade 100644 --- a/app/javascript/dashboard/components/widgets/forms/PhoneInput.vue +++ b/app/javascript/dashboard/components/widgets/forms/PhoneInput.vue @@ -5,8 +5,8 @@ class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14" @click="toggleCountryDropdown" > -
- {{ activeCountry.emoji }} +
+ {{ activeCountryEmoji }}
@@ -144,6 +144,9 @@ export default { } return ''; }, + activeCountryEmoji() { + return this.activeCountry?.emoji || ''; + }, }, watch: { value() { From a8f053921b5870d7bcf3c95e18e96c4a70b6adfd Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Fri, 19 Jan 2024 22:32:18 -0800 Subject: [PATCH 002/101] fix: Migrate notes when merging the contacts (#8749) Fixes: https://linear.app/chatwoot/issue/CW-2987/migrate-notes-of-the-secondary-contact-to-primary-contact-when-merging --- app/actions/contact_merge_action.rb | 5 +++++ spec/actions/contact_merge_action_spec.rb | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/app/actions/contact_merge_action.rb b/app/actions/contact_merge_action.rb index 9d58ef207..650dbe89f 100644 --- a/app/actions/contact_merge_action.rb +++ b/app/actions/contact_merge_action.rb @@ -12,6 +12,7 @@ class ContactMergeAction merge_conversations merge_messages merge_contact_inboxes + merge_contact_notes merge_and_remove_mergee_contact end @base_contact @@ -33,6 +34,10 @@ class ContactMergeAction Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) end + def merge_contact_notes + Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).update(contact_id: @base_contact.id) + end + def merge_messages Message.where(sender: @mergee_contact).update(sender: @base_contact) end diff --git a/spec/actions/contact_merge_action_spec.rb b/spec/actions/contact_merge_action_spec.rb index de207498a..7cd4c71fe 100644 --- a/spec/actions/contact_merge_action_spec.rb +++ b/spec/actions/contact_merge_action_spec.rb @@ -18,6 +18,7 @@ describe ContactMergeAction do create(:conversation, contact: base_contact) create(:conversation, contact: mergee_contact) create(:message, sender: mergee_contact) + create(:note, contact: mergee_contact, account: mergee_contact.account) end end @@ -68,6 +69,17 @@ describe ContactMergeAction do end end + context 'when mergee contact has notes' do + it 'moves the notes to base contact' do + expect(base_contact.notes.count).to be 0 + expect(mergee_contact.notes.count).to be 2 + + contact_merge + + expect(base_contact.reload.notes.count).to be 2 + end + end + context 'when contacts belong to a different account' do it 'throws an exception' do new_account = create(:account) From fd4376d062d2831839598a7858b25c249ec4b814 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 22 Jan 2024 12:06:59 +0530 Subject: [PATCH 003/101] fix: TypeError: Cannot read properties of undefined (reading 'emoji') (#8753) --- .../dashboard/components/widgets/forms/PhoneInput.vue | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/forms/PhoneInput.vue b/app/javascript/dashboard/components/widgets/forms/PhoneInput.vue index 6aa560ade..d4fb3c2e7 100644 --- a/app/javascript/dashboard/components/widgets/forms/PhoneInput.vue +++ b/app/javascript/dashboard/components/widgets/forms/PhoneInput.vue @@ -5,8 +5,8 @@ class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14" @click="toggleCountryDropdown" > -
- {{ activeCountryEmoji }} +
+ {{ activeCountry.emoji }}
@@ -144,9 +144,6 @@ export default { } return ''; }, - activeCountryEmoji() { - return this.activeCountry?.emoji || ''; - }, }, watch: { value() { From b3c9d1f1a5f4235399413cfbf9fdeded91d71ac0 Mon Sep 17 00:00:00 2001 From: Arooba Shahoor <56495631+Arooba-git@users.noreply.github.com> Date: Mon, 22 Jan 2024 15:38:33 +0900 Subject: [PATCH 004/101] fix: clear timers and animation frame request before component unmounts (#8700) Co-authored-by: Sojan Jose --- .../portal/components/PublicArticleSearch.vue | 4 ++++ app/javascript/widget/components/ReplyToChip.vue | 11 ++++++++++- .../widget/components/layouts/ViewWithHeader.vue | 4 +++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/javascript/portal/components/PublicArticleSearch.vue b/app/javascript/portal/components/PublicArticleSearch.vue index 59dc94cf0..f75c9e644 100644 --- a/app/javascript/portal/components/PublicArticleSearch.vue +++ b/app/javascript/portal/components/PublicArticleSearch.vue @@ -84,6 +84,10 @@ export default { }, }, + beforeDestroy() { + clearTimeout(this.typingTimer); + }, + methods: { onChange(e) { this.$emit('input', e.target.value); diff --git a/app/javascript/widget/components/ReplyToChip.vue b/app/javascript/widget/components/ReplyToChip.vue index 76c250ea0..919f1bcfe 100644 --- a/app/javascript/widget/components/ReplyToChip.vue +++ b/app/javascript/widget/components/ReplyToChip.vue @@ -24,6 +24,11 @@ export default { default: () => {}, }, }, + data() { + return { + timeOutID: null, + }; + }, computed: { replyToAttachment() { if (!this.replyTo?.attachments?.length) { @@ -34,6 +39,10 @@ export default { return this.$t(`ATTACHMENTS.${fileType}.CONTENT`); }, }, + + beforeDestroy() { + clearTimeout(this.timeOutID); + }, methods: { navigateTo(id) { const elementId = `cwmsg-${id}`; @@ -41,7 +50,7 @@ export default { const el = document.getElementById(elementId); el.scrollIntoView(); el.classList.add('bg-slate-100', 'dark:bg-slate-900'); - setTimeout(() => { + this.timeOutID = setTimeout(() => { el.classList.remove('bg-slate-100', 'dark:bg-slate-900'); }, 500); }); diff --git a/app/javascript/widget/components/layouts/ViewWithHeader.vue b/app/javascript/widget/components/layouts/ViewWithHeader.vue index d0b86a30e..dad629a16 100644 --- a/app/javascript/widget/components/layouts/ViewWithHeader.vue +++ b/app/javascript/widget/components/layouts/ViewWithHeader.vue @@ -60,6 +60,7 @@ export default { scrollPosition: 0, ticking: true, disableBranding: window.chatwootWebChannel.disableBranding || false, + requestID: null, }; }, computed: { @@ -120,6 +121,7 @@ export default { }, beforeDestroy() { this.$el.removeEventListener('scroll', this.updateScrollPosition); + cancelAnimationFrame(this.requestID); }, methods: { closeWindow() { @@ -128,7 +130,7 @@ export default { updateScrollPosition(event) { this.scrollPosition = event.target.scrollTop; if (!this.ticking) { - window.requestAnimationFrame(() => { + this.requestID = window.requestAnimationFrame(() => { this.ticking = false; }); From 381fda270a33167bf7fdbbfae0daab0abe2fd0dc Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 22 Jan 2024 13:07:36 +0400 Subject: [PATCH 005/101] chore: Fix typo in Inbox Management copy (#8750) - Fixes the typo in Inbox Management copy -> vistors to visitors --- app/javascript/dashboard/i18n/locale/en/inboxMgmt.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 74eff2033..8d1662f91 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -569,7 +569,7 @@ "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", - "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", + "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours visitors can be warned with a message and a pre-chat form.", "DAY": { "ENABLE": "Enable availability for this day", "UNAVAILABLE": "Unavailable", From d2c5c2f9a37aae860705f8a4062ac99682f6091e Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 22 Jan 2024 13:12:26 +0400 Subject: [PATCH 006/101] chore: [Snyk] Security upgrade sidekiq from 7.2.0 to 7.2.1 (#8748) Co-authored-by: snyk-bot --- Gemfile | 2 +- Gemfile.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index 718e90979..0f9a1d5ba 100644 --- a/Gemfile +++ b/Gemfile @@ -114,7 +114,7 @@ gem 'sentry-ruby', require: false gem 'sentry-sidekiq', '>= 5.14.0', require: false ##-- background job processing --## -gem 'sidekiq', '>= 7.1.3' +gem 'sidekiq', '>= 7.2.1' # We want cron jobs gem 'sidekiq-cron', '>= 1.12.0' diff --git a/Gemfile.lock b/Gemfile.lock index 8d8714fd8..08c2696c6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -169,7 +169,7 @@ GEM climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) - concurrent-ruby (1.2.2) + concurrent-ruby (1.2.3) connection_pool (2.4.1) crack (0.4.5) rexml @@ -610,7 +610,7 @@ GEM ffi (~> 1.0) redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.19.0) + redis-client (0.19.1) connection_pool redis-namespace (1.10.0) redis (>= 4) @@ -720,11 +720,11 @@ GEM sexp_processor (4.17.0) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) - sidekiq (7.2.0) + sidekiq (7.2.1) concurrent-ruby (< 2) connection_pool (>= 2.3.0) rack (>= 2.2.4) - redis-client (>= 0.14.0) + redis-client (>= 0.19.0) sidekiq-cron (1.12.0) fugit (~> 1.8) globalid (>= 1.0.1) @@ -939,7 +939,7 @@ DEPENDENCIES sentry-ruby sentry-sidekiq (>= 5.14.0) shoulda-matchers - sidekiq (>= 7.1.3) + sidekiq (>= 7.2.1) sidekiq-cron (>= 1.12.0) simplecov (= 0.17.1) slack-ruby-client (~> 2.2.0) From bc04d81a5a64fa816f37404ec7bfc9341d6cc163 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 22 Jan 2024 14:02:44 +0400 Subject: [PATCH 007/101] fix: Handle Net::IMAP::InvalidResponseError Exception bad response type "ESMTP" (#8755) --- app/jobs/inboxes/fetch_imap_emails_job.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index c59ae2787..8f61e54b5 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -12,7 +12,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob rescue *ExceptionList::IMAP_EXCEPTIONS => e Rails.logger.error e channel.authorization_error! - rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError => e + rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError => e Rails.logger.error e rescue LockAcquisitionError Rails.logger.error "Lock failed for #{channel.inbox.id}" From 1dc66db516e177e854d680f583ae05085605d603 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 22 Jan 2024 16:09:34 +0530 Subject: [PATCH 008/101] fix: SQL error when rules with missing attributes is triggered (#8673) --- .../condition_validation_service.rb | 56 ++++++++++ .../conditions_filter_service.rb | 20 +++- lib/filters/filter_keys.json | 2 +- .../automation_rule_listener_old_spec.rb | 2 + .../condition_validation_service_spec.rb | 105 ++++++++++++++++++ 5 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 app/services/automation_rules/condition_validation_service.rb create mode 100644 spec/services/automation_rules/condition_validation_service_spec.rb diff --git a/app/services/automation_rules/condition_validation_service.rb b/app/services/automation_rules/condition_validation_service.rb new file mode 100644 index 000000000..6f9f9a7c3 --- /dev/null +++ b/app/services/automation_rules/condition_validation_service.rb @@ -0,0 +1,56 @@ +class AutomationRules::ConditionValidationService + ATTRIBUTE_MODEL = 'conversation_attribute'.freeze + + def initialize(rule) + @rule = rule + @account = rule.account + + file = File.read('./lib/filters/filter_keys.json') + @filters = JSON.parse(file) + + @conversation_filters = @filters['conversations'] + @contact_filters = @filters['contacts'] + @message_filters = @filters['messages'] + end + + def perform + @rule.conditions.each do |condition| + return false unless valid_condition?(condition) + end + + true + end + + private + + def valid_condition?(condition) + key = condition['attribute_key'] + + conversation_filter = @conversation_filters[key] + contact_filter = @contact_filters[key] + message_filter = @message_filters[key] + + if conversation_filter || contact_filter || message_filter + operation_valid?(condition, conversation_filter || contact_filter || message_filter) + else + custom_attribute_present?(key, condition['custom_attribute_type']) + end + end + + def operation_valid?(condition, filter) + filter_operator = condition['filter_operator'] + + # attribute changed is a special case + return true if filter_operator == 'attribute_changed' + + filter['filter_operators'].include?(filter_operator) + end + + def custom_attribute_present?(attribute_key, attribute_model) + attribute_model = attribute_model.presence || self.class::ATTRIBUTE_MODEL + + @account.custom_attribute_definitions.where( + attribute_model: attribute_model + ).find_by(attribute_key: attribute_key).present? + end +end diff --git a/app/services/automation_rules/conditions_filter_service.rb b/app/services/automation_rules/conditions_filter_service.rb index b8f28ea9e..44b72db9e 100644 --- a/app/services/automation_rules/conditions_filter_service.rb +++ b/app/services/automation_rules/conditions_filter_service.rb @@ -5,19 +5,25 @@ class AutomationRules::ConditionsFilterService < FilterService def initialize(rule, conversation = nil, options = {}) super([], nil) + # assign rule, conversation and account to instance variables @rule = rule @conversation = conversation @account = conversation.account + + # setup filters from json file file = File.read('./lib/filters/filter_keys.json') @filters = JSON.parse(file) + @conversation_filters = @filters['conversations'] + @contact_filters = @filters['contacts'] + @message_filters = @filters['messages'] + @options = options @changed_attributes = options[:changed_attributes] end def perform - @conversation_filters = @filters['conversations'] - @contact_filters = @filters['contacts'] - @message_filters = @filters['messages'] + return false unless rule_valid? + @attribute_changed_query_filter = [] @rule.conditions.each_with_index do |query_hash, current_index| @@ -36,6 +42,14 @@ class AutomationRules::ConditionsFilterService < FilterService false end + def rule_valid? + is_valid = AutomationRules::ConditionValidationService.new(@rule).perform + + Rails.logger.info "Automation rule condition validation failed for rule id: #{@rule.id}" unless is_valid + + is_valid + end + def filter_operation(query_hash, current_index) if query_hash[:filter_operator] == 'starts_with' @filter_values["value_#{current_index}"] = "#{string_filter_values(query_hash)}%" diff --git a/lib/filters/filter_keys.json b/lib/filters/filter_keys.json index 1853b08e6..9266d9bea 100644 --- a/lib/filters/filter_keys.json +++ b/lib/filters/filter_keys.json @@ -118,7 +118,7 @@ "attribute_name": "Phone Number", "input_type": "text", "data_type": "text", - "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain" ], + "filter_operators": [ "equal_to", "not_equal_to", "contains", "does_not_contain", "starts_with"], "attribute_type": "standard" }, "email": { diff --git a/spec/listeners/automation_rule_listener_old_spec.rb b/spec/listeners/automation_rule_listener_old_spec.rb index 5c02ef90a..4394a18bd 100644 --- a/spec/listeners/automation_rule_listener_old_spec.rb +++ b/spec/listeners/automation_rule_listener_old_spec.rb @@ -83,6 +83,7 @@ describe AutomationRuleListener do { attribute_key: 'customer_type', filter_operator: 'equal_to', + custom_attribute_type: 'contact_attribute', values: ['platinum'], query_operator: 'AND' }.with_indifferent_access, @@ -154,6 +155,7 @@ describe AutomationRuleListener do { attribute_key: 'customer_type', filter_operator: 'equal_to', + custom_attribute_type: 'contact_attribute', values: ['platinum'], query_operator: nil }.with_indifferent_access diff --git a/spec/services/automation_rules/condition_validation_service_spec.rb b/spec/services/automation_rules/condition_validation_service_spec.rb new file mode 100644 index 000000000..c1c380780 --- /dev/null +++ b/spec/services/automation_rules/condition_validation_service_spec.rb @@ -0,0 +1,105 @@ +require 'rails_helper' + +RSpec.describe AutomationRules::ConditionValidationService do + let(:account) { create(:account) } + let(:rule) { create(:automation_rule, account: account) } + + describe '#perform' do + context 'with standard attributes' do + before do + rule.conditions = [ + { 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'equal_to' }, + { 'values': ['+918484'], 'attribute_key': 'phone_number', 'query_operator': 'OR', 'filter_operator': 'contains' }, + { 'values': ['test'], 'attribute_key': 'email', 'query_operator': nil, 'filter_operator': 'contains' } + ] + rule.save + end + + it 'returns true' do + expect(described_class.new(rule).perform).to be(true) + end + end + + context 'with wrong attribute' do + before do + rule.conditions = [ + { 'values': ['open'], 'attribute_key': 'not-a-standard-attribute-for-sure', 'query_operator': nil, 'filter_operator': 'equal_to' } + ] + rule.save + end + + it 'returns false' do + expect(described_class.new(rule).perform).to be(false) + end + end + + context 'with wrong filter operator' do + before do + rule.conditions = [ + { 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'not-a-filter-operator' } + ] + rule.save + end + + it 'returns false' do + expect(described_class.new(rule).perform).to be(false) + end + end + + context 'with "attribute_changed" filter operator' do + before do + rule.conditions = [ + { 'values': ['open'], 'attribute_key': 'status', 'query_operator': nil, 'filter_operator': 'attribute_changed' } + ] + rule.save + end + + it 'returns true' do + expect(described_class.new(rule).perform).to be(true) + end + end + + context 'with correct custom attribute' do + before do + create(:custom_attribute_definition, + attribute_key: 'custom_attr_priority', + account: account, + attribute_model: 'conversation_attribute', + attribute_display_type: 'list', + attribute_values: %w[P0 P1 P2]) + + rule.conditions = [ + { + 'values': ['true'], + 'attribute_key': 'custom_attr_priority', + 'filter_operator': 'equal_to', + 'custom_attribute_type': 'conversation_attribute' + } + ] + rule.save + end + + it 'returns true' do + expect(described_class.new(rule).perform).to be(true) + end + end + + context 'with missing custom attribute' do + before do + rule.conditions = [ + { + 'values': ['true'], + 'attribute_key': 'attribute_is_not_present', # the attribute is not present + 'filter_operator': 'equal_to', + 'custom_attribute_type': 'conversation_attribute' + } + ] + rule.save + end + + it 'returns false for missing custom attribute' do + expect(described_class.new(rule).perform).to be(false) + end + end + end +end From 682a2aea1cdc89fd1432490d0b6e55b103322a4b Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 22 Jan 2024 17:03:26 +0530 Subject: [PATCH 009/101] chore: Handle twillio `Down::ClientError` (#8757) Fixes: https://linear.app/chatwoot/issue/CW-2992/downclienterror-400-bad-request-downclienterror --- app/services/twilio/incoming_message_service.rb | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb index f28192f4e..e379b6e0d 100644 --- a/app/services/twilio/incoming_message_service.rb +++ b/app/services/twilio/incoming_message_service.rb @@ -127,7 +127,7 @@ class Twilio::IncomingMessageService def download_attachment_file download_with_auth - rescue Down::Error => e + rescue Down::Error, Down::ClientError => e handle_download_attachment_error(e) end @@ -141,12 +141,10 @@ class Twilio::IncomingMessageService # This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future. def handle_download_attachment_error(error) - Rails.logger.info "Error downloading attachment from Twilio: #{error.message}" - if error.message.include?('401 Unauthorized') - Down.download(params[:MediaUrl0]) - else - ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception - nil - end + Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying" + Down.download(params[:MediaUrl0]) + rescue StandardError => e + Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping" + nil end end From d0cd1c8887ccd3bf5685fdc88431c72e485322e4 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 23 Jan 2024 14:06:51 +0530 Subject: [PATCH 010/101] fix: Help center articles not accessible after authoring agent is deleted (#8756) --- .../SecondaryChildNavItem.vue | 2 +- .../dashboard/i18n/locale/en/helpCenter.json | 3 +- .../helpcenter/components/ArticleItem.vue | 40 +++++++++++++------ .../FluentIcon/dashboard-icons.json | 1 + 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue index 5517d12ae..d85fedfa5 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue @@ -56,7 +56,7 @@ class="bg-slate-50 dark:bg-slate-700 rounded text-xxs font-medium mx-1 py-0 px-1" :class=" isCountZero - ? `text-slate-300 dark:text-slate-700` + ? `text-slate-300 dark:text-slate-500` : `text-slate-700 dark:text-slate-50` " > diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index 1f3bda807..d94692e3d 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -324,7 +324,8 @@ "LAST_EDITED": "Last edited" }, "COLUMNS": { - "BY": "by" + "BY": "by", + "AUTHOR_NOT_AVAILABLE": "Author is not available" } }, "EDIT_ARTICLE": { diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleItem.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleItem.vue index 95a5d55d0..6a4fd17f6 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleItem.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleItem.vue @@ -11,9 +11,30 @@ {{ title }} -
- {{ $t('HELP_CENTER.TABLE.COLUMNS.BY') }} - {{ articleAuthorName }} +
+ +
+ +
+ + {{ articleAuthorName }} +
@@ -57,10 +78,12 @@ import timeMixin from 'dashboard/mixins/time'; import portalMixin from '../mixins/portalMixin'; import { frontendURL } from 'dashboard/helper/URLHelper'; import EmojiOrIcon from '../../../../../shared/components/EmojiOrIcon.vue'; +import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; export default { components: { EmojiOrIcon, + Thumbnail, }, mixins: [timeMixin, portalMixin], props: { @@ -110,7 +133,7 @@ export default { }).format(this.views || 0); }, articleAuthorName() { - return this.author.name; + return this.author?.name || '-'; }, labelColor() { switch (this.status) { @@ -189,15 +212,6 @@ export default { .article-block { @apply min-w-0; } - - .author { - .by { - @apply font-normal text-slate-500 dark:text-slate-200 text-sm; - } - .name { - @apply font-normal text-slate-500 dark:text-slate-200 text-sm; - } - } } span { diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json index e6471a5cd..93a876346 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -154,6 +154,7 @@ "person-add-outline": "M17.5 12a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11zm-5.477 2a6.47 6.47 0 0 0-.709 1.5H4.253a.749.749 0 0 0-.75.75v.577c0 .535.192 1.053.54 1.46c1.253 1.469 3.22 2.214 5.957 2.214c.597 0 1.157-.035 1.68-.106c.246.495.553.954.912 1.367c-.795.16-1.66.24-2.592.24c-3.146 0-5.532-.906-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.578A2.249 2.249 0 0 1 4.253 14h7.77zm5.477 0l-.09.008a.5.5 0 0 0-.402.402L17 14.5V17h-2.496l-.09.008a.5.5 0 0 0-.402.402l-.008.09l.008.09a.5.5 0 0 0 .402.402l.09.008H17L17 20.5l.008.09a.5.5 0 0 0 .402.402l.09.008l.09-.008a.5.5 0 0 0 .402-.402L18 20.5V18h2.504l.09-.008a.5.5 0 0 0 .402-.402l.008-.09l-.008-.09a.5.5 0 0 0-.402-.402l-.09-.008H18L18 14.5l-.008-.09a.5.5 0 0 0-.402-.402L17.5 14zM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z", "person-assign-outline": "M11.313 15.5a6.471 6.471 0 0 1 .709-1.5h-7.77a2.249 2.249 0 0 0-2.249 2.25v.577c0 .892.319 1.756.899 2.435c1.566 1.834 3.952 2.74 7.098 2.74c.931 0 1.796-.08 2.592-.24a6.51 6.51 0 0 1-.913-1.366c-.524.07-1.083.105-1.68.105c-2.737 0-4.703-.745-5.957-2.213a2.25 2.25 0 0 1-.539-1.461v-.578a.75.75 0 0 1 .75-.749h7.06ZM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7ZM23 17.5a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0Zm-4.647-2.853a.5.5 0 0 0-.707.707L19.293 17H15a.5.5 0 1 0 0 1h4.293l-1.647 1.647a.5.5 0 0 0 .707.707l2.5-2.5a.497.497 0 0 0 .147-.345V17.5a.498.498 0 0 0-.15-.357l-2.497-2.496Z", "person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z", + "person-filled": "M17.754 14a2.249 2.249 0 0 1 2.249 2.25v.918a2.75 2.75 0 0 1-.513 1.598c-1.545 2.164-4.07 3.235-7.49 3.235c-3.421 0-5.944-1.072-7.486-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.251 14h11.502ZM12 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Z", "play-circle-outline": "M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12Zm8.856-3.845A1.25 1.25 0 0 0 9 9.248v5.504a1.25 1.25 0 0 0 1.856 1.093l5.757-3.189a.75.75 0 0 0 0-1.312l-5.757-3.189Z", "power-outline": "M8.204 4.82a.75.75 0 0 1 .634 1.36A7.51 7.51 0 0 0 4.5 12.991c0 4.148 3.358 7.51 7.499 7.51s7.499-3.362 7.499-7.51a7.51 7.51 0 0 0-4.323-6.804.75.75 0 1 1 .637-1.358 9.01 9.01 0 0 1 5.186 8.162c0 4.976-4.029 9.01-9 9.01C7.029 22 3 17.966 3 12.99a9.01 9.01 0 0 1 5.204-8.17ZM12 2.496a.75.75 0 0 1 .743.648l.007.102v7.5a.75.75 0 0 1-1.493.102l-.007-.102v-7.5a.75.75 0 0 1 .75-.75Z", "quote-outline": "M7.5 6a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.555-1.24 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.64-1.737 2.66-3.674 3.077-5.859A2.5 2.5 0 1 1 7.5 6Zm9 0a2.5 2.5 0 0 1 2.495 2.336l.005.206c-.01 3.56-1.238 6.614-3.705 9.223a.75.75 0 1 1-1.09-1.03c1.643-1.738 2.662-3.672 3.078-5.859A2.5 2.5 0 1 1 16.5 6Zm-9 1.5a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Zm9 0a1 1 0 1 0 .993 1.117l.007-.124a1 1 0 0 0-1-.993Z", From 834c219b9b3b21cd7ac73e37f3057e148aa43a0d Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 23 Jan 2024 15:50:00 +0530 Subject: [PATCH 011/101] feat(perf): update query to do a simpler search [CW-2997] (#8763) Message search would frequently timeout. The reason was that the query would join the conversation too, the new query searches the message table directly Co-authored-by: Sojan --- app/models/inbox.rb | 2 +- spec/mailboxes/imap/imap_mailbox_spec.rb | 2 +- spec/models/inbox_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 311d7369b..17f4fc045 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -66,7 +66,7 @@ class Inbox < ApplicationRecord has_many :inbox_members, dependent: :destroy_async has_many :members, through: :inbox_members, source: :user has_many :conversations, dependent: :destroy_async - has_many :messages, through: :conversations + has_many :messages, dependent: :destroy_async has_one :agent_bot_inbox, dependent: :destroy_async has_one :agent_bot, through: :agent_bot_inbox diff --git a/spec/mailboxes/imap/imap_mailbox_spec.rb b/spec/mailboxes/imap/imap_mailbox_spec.rb index 798f21112..33302629d 100644 --- a/spec/mailboxes/imap/imap_mailbox_spec.rb +++ b/spec/mailboxes/imap/imap_mailbox_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Imap::ImapMailbox do imap_port: 993, imap_login: 'imap@gmail.com', imap_password: 'password', account: account) end - let(:inbox) { create(:inbox, channel: channel, account: account) } + let(:inbox) { channel.inbox } let!(:contact) { create(:contact, email: 'email@gmail.com', phone_number: '+919584546666', account: account, identifier: '123') } let(:conversation) { Conversation.where(inbox_id: channel.inbox).last } let(:class_instance) { described_class.new } diff --git a/spec/models/inbox_spec.rb b/spec/models/inbox_spec.rb index 30c6ef235..8bcb6bcd5 100644 --- a/spec/models/inbox_spec.rb +++ b/spec/models/inbox_spec.rb @@ -25,7 +25,7 @@ RSpec.describe Inbox do it { is_expected.to have_many(:conversations).dependent(:destroy_async) } - it { is_expected.to have_many(:messages).through(:conversations) } + it { is_expected.to have_many(:messages).dependent(:destroy_async) } it { is_expected.to have_one(:agent_bot_inbox) } From 4b40c612018f35470d2f81d2d9982f8d774acd9a Mon Sep 17 00:00:00 2001 From: Surabhi Suman Date: Tue, 23 Jan 2024 19:31:57 +0530 Subject: [PATCH 012/101] feat: Support Regex validation for custom attributes (#7856) This allows a user to add/update a custom regex and a cue while defining custom attributes(Only applicable for type- text). While adding/editing custom attributes, the values are validated against the attribute definition regex, and if it is incorrect, a cue message or default error message is shown and restricts invalid values from being saved. Fixes: #6866 --- ...custom_attribute_definitions_controller.rb | 2 + .../dashboard/components/CustomAttribute.vue | 23 +++++++- app/javascript/dashboard/helper/preChat.js | 2 + .../dashboard/helper/specs/inboxFixture.js | 14 +++++ .../dashboard/helper/specs/preChat.spec.js | 24 +++++++- .../i18n/locale/en/attributesMgmt.json | 22 ++++++++ .../dashboard/i18n/locale/en/contact.json | 3 +- .../dashboard/mixins/customAttributeMixin.js | 11 ++++ .../customAttributes/CustomAttributes.vue | 2 + .../settings/attributes/AddAttribute.vue | 44 +++++++++++++++ .../settings/attributes/EditAttribute.vue | 55 ++++++++++++++++++- .../widget/components/PreChat/Form.vue | 39 +++++++++---- app/javascript/widget/i18n/locale/en.json | 3 +- ...pdate_widget_pre_chat_custom_fields_job.rb | 4 +- app/models/channel/web_widget.rb | 2 +- app/models/custom_attribute_definition.rb | 2 + ..._custom_attribute_definition.json.jbuilder | 2 + ...dd_regex_to_custom_attribute_definition.rb | 6 ++ db/schema.rb | 2 + ..._widget_pre_chat_custom_fields_job_spec.rb | 7 ++- 20 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 app/javascript/dashboard/mixins/customAttributeMixin.js create mode 100644 db/migrate/20230905060223_add_regex_to_custom_attribute_definition.rb diff --git a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb index 3840644ce..69df99e14 100644 --- a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb +++ b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb @@ -39,6 +39,8 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account :attribute_display_type, :attribute_key, :attribute_model, + :regex_pattern, + :regex_cue, attribute_values: [] ) end diff --git a/app/javascript/dashboard/components/CustomAttribute.vue b/app/javascript/dashboard/components/CustomAttribute.vue index 9b145c1f2..c4846bcbc 100644 --- a/app/javascript/dashboard/components/CustomAttribute.vue +++ b/app/javascript/dashboard/components/CustomAttribute.vue @@ -126,18 +126,26 @@ import { required, url } from 'vuelidate/lib/validators'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue'; import { isValidURL } from '../helper/URLHelper'; +import customAttributeMixin from '../mixins/customAttributeMixin'; const DATE_FORMAT = 'yyyy-MM-dd'; export default { components: { MultiselectDropdown, }, + mixins: [customAttributeMixin], props: { label: { type: String, required: true }, values: { type: Array, default: () => [] }, value: { type: [String, Number, Boolean], default: '' }, showActions: { type: Boolean, default: false }, attributeType: { type: String, default: 'text' }, + attributeRegex: { + type: String, + default: null, + }, + regexCue: { type: String, default: null }, + regexEnabled: { type: Boolean, default: false }, attributeKey: { type: String, required: true }, contactId: { type: Number, default: null }, }, @@ -204,6 +212,11 @@ export default { if (this.$v.editedValue.url) { return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL'); } + if (!this.$v.editedValue.regexValidation) { + return this.regexCue + ? this.regexCue + : this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT'); + } return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED'); }, }, @@ -221,7 +234,15 @@ export default { }; } return { - editedValue: { required }, + editedValue: { + required, + regexValidation: value => { + return !( + this.attributeRegex && + !this.getRegexp(this.attributeRegex).test(value) + ); + }, + }, }; }, mounted() { diff --git a/app/javascript/dashboard/helper/preChat.js b/app/javascript/dashboard/helper/preChat.js index 14d062707..ec4158205 100644 --- a/app/javascript/dashboard/helper/preChat.js +++ b/app/javascript/dashboard/helper/preChat.js @@ -47,6 +47,8 @@ export const getCustomFields = ({ standardFields, customAttributes }) => { type: attribute.attribute_display_type, values: attribute.attribute_values, field_type: attribute.attribute_model, + regex_pattern: attribute.regex_pattern, + regex_cue: attribute.regex_cue, required: false, enabled: false, }); diff --git a/app/javascript/dashboard/helper/specs/inboxFixture.js b/app/javascript/dashboard/helper/specs/inboxFixture.js index 6622a6de2..4a83464ef 100644 --- a/app/javascript/dashboard/helper/specs/inboxFixture.js +++ b/app/javascript/dashboard/helper/specs/inboxFixture.js @@ -44,4 +44,18 @@ export default { created_at: '2021-11-29T10:20:04.563Z', }, ], + customAttributesWithRegex: [ + { + id: 2, + attribute_description: 'Test contact Attribute', + attribute_display_name: 'Test contact Attribute', + attribute_display_type: 'text', + attribute_key: 'test_contact_attribute', + attribute_model: 'contact_attribute', + attribute_values: Array(0), + created_at: '2023-09-20T10:20:04.563Z', + regex_pattern: '^w+$', + regex_cue: 'It should be a combination of alphabets and numbers', + }, + ], }; diff --git a/app/javascript/dashboard/helper/specs/preChat.spec.js b/app/javascript/dashboard/helper/specs/preChat.spec.js index 74f3e72f5..cec255f05 100644 --- a/app/javascript/dashboard/helper/specs/preChat.spec.js +++ b/app/javascript/dashboard/helper/specs/preChat.spec.js @@ -5,7 +5,8 @@ import { } from '../preChat'; import inboxFixture from './inboxFixture'; -const { customFields, customAttributes } = inboxFixture; +const { customFields, customAttributes, customAttributesWithRegex } = + inboxFixture; describe('#Pre chat Helpers', () => { describe('getPreChatFields', () => { it('should return correct pre-chat fields form options passed', () => { @@ -27,7 +28,6 @@ describe('#Pre chat Helpers', () => { placeholder: 'Please enter your email address', type: 'email', field_type: 'standard', - required: false, enabled: false, }, @@ -71,6 +71,26 @@ describe('#Pre chat Helpers', () => { values: [], }, ]); + + expect( + getCustomFields({ + standardFields: { pre_chat_fields: customFields.pre_chat_fields }, + customAttributes: customAttributesWithRegex, + }) + ).toEqual([ + { + enabled: false, + label: 'Test contact Attribute', + placeholder: 'Test contact Attribute', + name: 'test_contact_attribute', + required: false, + field_type: 'contact_attribute', + type: 'text', + values: [], + regex_pattern: '^w+$', + regex_cue: 'It should be a combination of alphabets and numbers', + }, + ]); }); }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json b/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json index 7cf58059e..a2f7386dc 100644 --- a/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/attributesMgmt.json @@ -39,6 +39,17 @@ "PLACEHOLDER": "Enter custom attribute key", "ERROR": "Key is required", "IN_VALID": "Invalid key" + }, + "REGEX_PATTERN": { + "LABEL": "Regex Pattern", + "PLACEHOLDER": "Please enter custom attribute regex pattern. (Optional)" + }, + "REGEX_CUE": { + "LABEL": "Regex Cue", + "PLACEHOLDER": "Please enter regex pattern hint. (Optional)" + }, + "ENABLE_REGEX": { + "LABEL": "Enable regex validation" } }, "API": { @@ -88,6 +99,17 @@ "EMPTY_RESULT": { "404": "There are no custom attributes created", "NOT_FOUND": "There are no custom attributes configured" + }, + "REGEX_PATTERN": { + "LABEL": "Regex Pattern", + "PLACEHOLDER": "Please enter custom attribute regex pattern. (Optional)" + }, + "REGEX_CUE": { + "LABEL": "Regex Cue", + "PLACEHOLDER": "Please enter regex pattern hint. (Optional)" + }, + "ENABLE_REGEX": { + "LABEL": "Enable regex validation" } } } diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 7ad18b792..594e34c4f 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -339,7 +339,8 @@ }, "VALIDATIONS": { "REQUIRED": "Valid value is required", - "INVALID_URL": "Invalid URL" + "INVALID_URL": "Invalid URL", + "INVALID_INPUT": "Invalid Input" } }, "MERGE_CONTACTS": { diff --git a/app/javascript/dashboard/mixins/customAttributeMixin.js b/app/javascript/dashboard/mixins/customAttributeMixin.js new file mode 100644 index 000000000..a0617685d --- /dev/null +++ b/app/javascript/dashboard/mixins/customAttributeMixin.js @@ -0,0 +1,11 @@ +export default { + methods: { + getRegexp(regexPatternValue) { + let lastSlash = regexPatternValue.lastIndexOf('/'); + return new RegExp( + regexPatternValue.slice(1, lastSlash), + regexPatternValue.slice(lastSlash + 1) + ); + }, + }, +}; diff --git a/app/javascript/dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue b/app/javascript/dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue index f038e3a42..ca721d4d8 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue @@ -11,6 +11,8 @@ emoji="" :value="attribute.value" :show-actions="true" + :attribute-regex="attribute.regex_pattern" + :regex-cue="attribute.regex_cue" :class="attributeClass" @update="onUpdate" @delete="onDelete" diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue index eeceaf743..e517651d3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue @@ -86,6 +86,30 @@ {{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }} +
+ + {{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }} +
+ +
+
+ + {{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }} +
+ +
@@ -88,9 +112,10 @@ import { mapGetters } from 'vuex'; import { required, minLength } from 'vuelidate/lib/validators'; import { ATTRIBUTE_TYPES } from './constants'; import alertMixin from 'shared/mixins/alertMixin'; +import customAttributeMixin from '../../../../mixins/customAttributeMixin'; export default { components: {}, - mixins: [alertMixin], + mixins: [alertMixin, customAttributeMixin], props: { selectedAttribute: { type: Object, @@ -106,6 +131,9 @@ export default { displayName: '', description: '', attributeType: 0, + regexPattern: null, + regexCue: null, + regexEnabled: false, types: ATTRIBUTE_TYPES, show: true, attributeKey: '', @@ -152,6 +180,7 @@ export default { this.isAttributeTypeList && this.isTouched && this.values.length === 0 ); }, + pageTitle() { return `${this.$t('ATTRIBUTES_MGMT.EDIT.TITLE')} - ${ this.selectedAttribute.attribute_display_name @@ -173,6 +202,12 @@ export default { isAttributeTypeList() { return this.attributeType === 6; }, + isAttributeTypeText() { + return this.attributeType === 0; + }, + isRegexEnabled() { + return this.regexEnabled; + }, }, mounted() { this.setFormValues(); @@ -189,10 +224,16 @@ export default { this.$refs.tagInput.$el.focus(); }, setFormValues() { + const regexPattern = this.selectedAttribute.regex_pattern + ? this.getRegexp(this.selectedAttribute.regex_pattern).source + : null; this.displayName = this.selectedAttribute.attribute_display_name; this.description = this.selectedAttribute.attribute_description; this.attributeType = this.selectedAttributeType; this.attributeKey = this.selectedAttribute.attribute_key; + this.regexPattern = regexPattern; + this.regexCue = this.selectedAttribute.regex_cue; + this.regexEnabled = regexPattern != null; this.values = this.setAttributeListValue; }, async editAttributes() { @@ -200,14 +241,21 @@ export default { if (this.$v.$invalid) { return; } + if (!this.regexEnabled) { + this.regexPattern = null; + this.regexCue = null; + } try { await this.$store.dispatch('attributes/update', { id: this.selectedAttribute.id, attribute_description: this.description, attribute_display_name: this.displayName, attribute_values: this.updatedAttributeListValues, + regex_pattern: this.regexPattern + ? new RegExp(this.regexPattern).toString() + : null, + regex_cue: this.regexCue, }); - this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE'); this.onClose(); } catch (error) { @@ -218,6 +266,9 @@ export default { this.showAlert(this.alertMessage); } }, + toggleRegexEnabled() { + this.regexEnabled = !this.regexEnabled; + }, }, }; diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue index 50c4a22c4..394b338d5 100644 --- a/app/javascript/widget/components/PreChat/Form.vue +++ b/app/javascript/widget/components/PreChat/Form.vue @@ -28,6 +28,9 @@ isValidPhoneNumber: $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.VALID_ERROR'), email: $t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.VALID_ERROR'), required: $t('PRE_CHAT_FORM.REQUIRED'), + matches: item.regex_cue + ? item.regex_cue + : $t('PRE_CHAT_FORM.REGEX_ERROR'), }" :has-error-in-phone-input="hasErrorInPhoneInput" /> @@ -68,13 +71,20 @@ import { isEmptyObject } from 'widget/helpers/utils'; import routerMixin from 'widget/mixins/routerMixin'; import darkModeMixin from 'widget/mixins/darkModeMixin'; import configMixin from 'widget/mixins/configMixin'; +import customAttributeMixin from '../../../dashboard/mixins/customAttributeMixin'; export default { components: { CustomButton, Spinner, }, - mixins: [routerMixin, darkModeMixin, messageFormatterMixin, configMixin], + mixins: [ + routerMixin, + darkModeMixin, + messageFormatterMixin, + configMixin, + customAttributeMixin, + ], props: { options: { type: Object, @@ -235,30 +245,37 @@ export default { } return this.formValues[name] || null; }, - getValidation({ type, name }) { + getValidation({ type, name, field_type, regex_pattern }) { + let regex = regex_pattern ? this.getRegexp(regex_pattern) : null; const validations = { emailAddress: 'email', - phoneNumber: 'startsWithPlus|isValidPhoneNumber', + phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'], url: 'url', date: 'date', text: null, select: null, number: null, checkbox: false, + contact_attribute: regex ? [['matches', regex]] : null, + conversation_attribute: regex ? [['matches', regex]] : null, }; const validationKeys = Object.keys(validations); const isRequired = this.isContactFieldRequired(name); - const validation = isRequired ? 'bail|required' : 'bail|optional'; + const validation = isRequired + ? ['bail', 'required'] + : ['bail', 'optional']; - if (validationKeys.includes(name) || validationKeys.includes(type)) { - const validationType = validations[type] || validations[name]; - const validationString = validationType - ? `${validation}|${validationType}` - : validation; - return validationString; + if ( + validationKeys.includes(name) || + validationKeys.includes(type) || + validationKeys.includes(field_type) + ) { + const validationType = + validations[type] || validations[name] || validations[field_type]; + return validationType ? validation.concat(validationType) : validation; } - return ''; + return []; }, findFieldType(type) { if (type === 'link') { diff --git a/app/javascript/widget/i18n/locale/en.json b/app/javascript/widget/i18n/locale/en.json index 3a1f51ccf..83b442e51 100644 --- a/app/javascript/widget/i18n/locale/en.json +++ b/app/javascript/widget/i18n/locale/en.json @@ -80,7 +80,8 @@ }, "CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation", "IS_REQUIRED": "is required", - "REQUIRED": "Required" + "REQUIRED": "Required", + "REGEX_ERROR": "Please provide a valid input" }, "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit", "CHAT_FORM": { diff --git a/app/jobs/inboxes/update_widget_pre_chat_custom_fields_job.rb b/app/jobs/inboxes/update_widget_pre_chat_custom_fields_job.rb index b17ecdf55..3f3326633 100644 --- a/app/jobs/inboxes/update_widget_pre_chat_custom_fields_job.rb +++ b/app/jobs/inboxes/update_widget_pre_chat_custom_fields_job.rb @@ -12,7 +12,9 @@ class Inboxes::UpdateWidgetPreChatCustomFieldsJob < ApplicationJob pre_chat_field.deep_merge({ 'label' => custom_attribute['attribute_display_name'], 'placeholder' => custom_attribute['attribute_display_name'], - 'values' => custom_attribute['attribute_values'] + 'values' => custom_attribute['attribute_values'], + 'regex_pattern' => custom_attribute['regex_pattern'], + 'regex_cue' => custom_attribute['regex_cue'] }) end web_widget.save! diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb index a75e979b9..2efe74881 100644 --- a/app/models/channel/web_widget.rb +++ b/app/models/channel/web_widget.rb @@ -35,7 +35,7 @@ class Channel::WebWidget < ApplicationRecord { pre_chat_form_options: [:pre_chat_message, :require_email, { pre_chat_fields: [:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required, - :locale, { values: [] }] }] }, + :locale, { values: [] }, :regex_pattern, :regex_cue] }] }, { selected_feature_flags: [] }].freeze before_validation :validate_pre_chat_options diff --git a/app/models/custom_attribute_definition.rb b/app/models/custom_attribute_definition.rb index 71aab06f8..7d7b36e42 100644 --- a/app/models/custom_attribute_definition.rb +++ b/app/models/custom_attribute_definition.rb @@ -10,6 +10,8 @@ # attribute_model :integer default("conversation_attribute") # attribute_values :jsonb # default_value :integer +# regex_cue :string +# regex_pattern :string # created_at :datetime not null # updated_at :datetime not null # account_id :bigint diff --git a/app/views/api/v1/models/_custom_attribute_definition.json.jbuilder b/app/views/api/v1/models/_custom_attribute_definition.json.jbuilder index 0a1036903..8a1010d45 100644 --- a/app/views/api/v1/models/_custom_attribute_definition.json.jbuilder +++ b/app/views/api/v1/models/_custom_attribute_definition.json.jbuilder @@ -3,6 +3,8 @@ json.attribute_display_name resource.attribute_display_name json.attribute_display_type resource.attribute_display_type json.attribute_description resource.attribute_description json.attribute_key resource.attribute_key +json.regex_pattern resource.regex_pattern +json.regex_cue resource.regex_cue json.attribute_values resource.attribute_values json.attribute_model resource.attribute_model json.default_value resource.default_value diff --git a/db/migrate/20230905060223_add_regex_to_custom_attribute_definition.rb b/db/migrate/20230905060223_add_regex_to_custom_attribute_definition.rb new file mode 100644 index 000000000..dccca8cb5 --- /dev/null +++ b/db/migrate/20230905060223_add_regex_to_custom_attribute_definition.rb @@ -0,0 +1,6 @@ +class AddRegexToCustomAttributeDefinition < ActiveRecord::Migration[7.0] + def change + add_column :custom_attribute_definitions, :regex_pattern, :string + add_column :custom_attribute_definitions, :regex_cue, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 1fdf26882..2d7debe09 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -501,6 +501,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do t.datetime "updated_at", null: false t.text "attribute_description" t.jsonb "attribute_values", default: [] + t.string "regex_pattern" + t.string "regex_cue" t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id" t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true end diff --git a/spec/jobs/inboxes/update_widget_pre_chat_custom_fields_job_spec.rb b/spec/jobs/inboxes/update_widget_pre_chat_custom_fields_job_spec.rb index 284a0de70..a33b2c7bd 100644 --- a/spec/jobs/inboxes/update_widget_pre_chat_custom_fields_job_spec.rb +++ b/spec/jobs/inboxes/update_widget_pre_chat_custom_fields_job_spec.rb @@ -11,7 +11,9 @@ RSpec.describe Inboxes::UpdateWidgetPreChatCustomFieldsJob do pre_chat_message = 'Share your queries here.' custom_attribute = { 'attribute_key' => 'developer_id', - 'attribute_display_name' => 'Developer Number' + 'attribute_display_name' => 'Developer Number', + 'regex_pattern' => '^[0-9]*', + 'regex_cue' => 'It should be only digits' } let!(:account) { create(:account) } let!(:web_widget) do @@ -23,7 +25,8 @@ RSpec.describe Inboxes::UpdateWidgetPreChatCustomFieldsJob do described_class.perform_now(account, custom_attribute) expect(web_widget.reload.pre_chat_form_options['pre_chat_fields']).to eq [ { 'label' => 'Developer Number', 'name' => 'developer_id', 'placeholder' => 'Developer Number', - 'values' => nil }, { 'label' => 'Full Name', 'name' => 'full_name' } + 'values' => nil, 'regex_pattern' => '^[0-9]*', 'regex_cue' => 'It should be only digits' }, + { 'label' => 'Full Name', 'name' => 'full_name' } ] end end From 232369cd5c0b86e6afae43cd7f552ff93b111b8f Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 23 Jan 2024 23:48:02 +0530 Subject: [PATCH 013/101] feat: sla 1 - refactor sla_policies model and add applied_sla model (#8602) * feat: add models * chore: refactor sla column names * chore: remove foreign keys * chore: fix spec * chore: refactor models --- ...31223033019_refactor_sla_policy_columns.rb | 8 +++++++ .../20231223040257_create_applied_slas.rb | 13 +++++++++++ db/schema.rb | 20 +++++++++++++--- .../v1/accounts/sla_policies_controller.rb | 3 ++- enterprise/app/models/applied_sla.rb | 23 +++++++++++++++++++ enterprise/app/models/sla_policy.rb | 18 ++++++++------- .../api/v1/models/_sla_policy.json.jbuilder | 6 +++-- .../accounts/sla_policies_controller_spec.rb | 6 +++-- spec/enterprise/models/applied_sla_spec.rb | 16 +++++++++++++ spec/enterprise/models/sla_policy_spec.rb | 5 ++++ spec/factories/applied_slas.rb | 8 +++++++ spec/factories/sla_policies.rb | 6 +++-- 12 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 db/migrate/20231223033019_refactor_sla_policy_columns.rb create mode 100644 db/migrate/20231223040257_create_applied_slas.rb create mode 100644 enterprise/app/models/applied_sla.rb create mode 100644 spec/enterprise/models/applied_sla_spec.rb create mode 100644 spec/factories/applied_slas.rb diff --git a/db/migrate/20231223033019_refactor_sla_policy_columns.rb b/db/migrate/20231223033019_refactor_sla_policy_columns.rb new file mode 100644 index 000000000..2a6913799 --- /dev/null +++ b/db/migrate/20231223033019_refactor_sla_policy_columns.rb @@ -0,0 +1,8 @@ +class RefactorSlaPolicyColumns < ActiveRecord::Migration[7.0] + def change + rename_column :sla_policies, :rt_threshold, :next_response_time_threshold + rename_column :sla_policies, :frt_threshold, :first_response_time_threshold + add_column :sla_policies, :description, :string + add_column :sla_policies, :resolution_time_threshold, :float + end +end diff --git a/db/migrate/20231223040257_create_applied_slas.rb b/db/migrate/20231223040257_create_applied_slas.rb new file mode 100644 index 000000000..1aedb7d05 --- /dev/null +++ b/db/migrate/20231223040257_create_applied_slas.rb @@ -0,0 +1,13 @@ +class CreateAppliedSlas < ActiveRecord::Migration[7.0] + def change + create_table :applied_slas do |t| + t.references :account, null: false + t.references :sla_policy, null: false + t.references :conversation, null: false + + t.string :sla_status + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 2d7debe09..0fcf7ba61 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do +ActiveRecord::Schema[7.0].define(version: 2023_12_23_040257) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -115,6 +115,18 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do t.index ["account_id"], name: "index_agent_bots_on_account_id" end + create_table "applied_slas", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "sla_policy_id", null: false + t.bigint "conversation_id", null: false + t.string "sla_status" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_applied_slas_on_account_id" + t.index ["conversation_id"], name: "index_applied_slas_on_conversation_id" + t.index ["sla_policy_id"], name: "index_applied_slas_on_sla_policy_id" + end + create_table "articles", force: :cascade do |t| t.integer "account_id", null: false t.integer "portal_id", null: false @@ -824,12 +836,14 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_19_073832) do create_table "sla_policies", force: :cascade do |t| t.string "name", null: false - t.float "frt_threshold" - t.float "rt_threshold" + t.float "first_response_time_threshold" + t.float "next_response_time_threshold" t.boolean "only_during_business_hours", default: false t.bigint "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "description" + t.float "resolution_time_threshold" t.index ["account_id"], name: "index_sla_policies_on_account_id" end diff --git a/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb b/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb index fa79c5362..e64256bc7 100644 --- a/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb @@ -22,7 +22,8 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc end def permitted_params - params.require(:sla_policy).permit(:name, :rt_threshold, :frt_threshold, :only_during_business_hours) + params.require(:sla_policy).permit(:name, :description, :first_response_time_threshold, :next_response_time_threshold, + :resolution_time_threshold, :only_during_business_hours) end def fetch_sla diff --git a/enterprise/app/models/applied_sla.rb b/enterprise/app/models/applied_sla.rb new file mode 100644 index 000000000..8329f643b --- /dev/null +++ b/enterprise/app/models/applied_sla.rb @@ -0,0 +1,23 @@ +# == Schema Information +# +# Table name: applied_slas +# +# id :bigint not null, primary key +# sla_status :string +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# conversation_id :bigint not null +# sla_policy_id :bigint not null +# +# Indexes +# +# index_applied_slas_on_account_id (account_id) +# index_applied_slas_on_conversation_id (conversation_id) +# index_applied_slas_on_sla_policy_id (sla_policy_id) +# +class AppliedSla < ApplicationRecord + belongs_to :account + belongs_to :sla_policy + belongs_to :conversation +end diff --git a/enterprise/app/models/sla_policy.rb b/enterprise/app/models/sla_policy.rb index 14ea1c34a..328367aeb 100644 --- a/enterprise/app/models/sla_policy.rb +++ b/enterprise/app/models/sla_policy.rb @@ -2,14 +2,16 @@ # # Table name: sla_policies # -# id :bigint not null, primary key -# frt_threshold :float -# name :string not null -# only_during_business_hours :boolean default(FALSE) -# rt_threshold :float -# created_at :datetime not null -# updated_at :datetime not null -# account_id :bigint not null +# id :bigint not null, primary key +# description :string +# first_response_time_threshold :float +# name :string not null +# next_response_time_threshold :float +# only_during_business_hours :boolean default(FALSE) +# resolution_time_threshold :float +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null # # Indexes # diff --git a/enterprise/app/views/api/v1/models/_sla_policy.json.jbuilder b/enterprise/app/views/api/v1/models/_sla_policy.json.jbuilder index cd03d50ad..686b4317f 100644 --- a/enterprise/app/views/api/v1/models/_sla_policy.json.jbuilder +++ b/enterprise/app/views/api/v1/models/_sla_policy.json.jbuilder @@ -1,5 +1,7 @@ json.id sla_policy.id json.name sla_policy.name -json.frt_threshold sla_policy.frt_threshold -json.rt_threshold sla_policy.rt_threshold +json.description sla_policy.description +json.first_response_time_threshold sla_policy.first_response_time_threshold +json.next_response_time_threshold sla_policy.next_response_time_threshold +json.resolution_time_threshold sla_policy.resolution_time_threshold json.only_during_business_hours sla_policy.only_during_business_hours diff --git a/spec/enterprise/controllers/api/v1/accounts/sla_policies_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/sla_policies_controller_spec.rb index 99a3bf0f0..b1619ef85 100644 --- a/spec/enterprise/controllers/api/v1/accounts/sla_policies_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/sla_policies_controller_spec.rb @@ -80,8 +80,10 @@ RSpec.describe 'Enterprise SLA API', type: :request do describe 'POST #create' do let(:valid_params) do { sla_policy: { name: 'SLA 2', - frt_threshold: 1000, - rt_threshold: 1000, + description: 'SLA for premium customers', + first_response_time_threshold: 1000, + next_response_time_threshold: 2000, + resolution_time_threshold: 3000, only_during_business_hours: false } } end diff --git a/spec/enterprise/models/applied_sla_spec.rb b/spec/enterprise/models/applied_sla_spec.rb new file mode 100644 index 000000000..af73395a6 --- /dev/null +++ b/spec/enterprise/models/applied_sla_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe AppliedSla, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:sla_policy) } + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:conversation) } + end + + describe 'validates_factory' do + it 'creates valid applied sla policy object' do + applied_sla = create(:applied_sla) + expect(applied_sla.sla_status).to eq 'active' + end + end +end diff --git a/spec/enterprise/models/sla_policy_spec.rb b/spec/enterprise/models/sla_policy_spec.rb index 6fa8044cd..dc3c5cb87 100644 --- a/spec/enterprise/models/sla_policy_spec.rb +++ b/spec/enterprise/models/sla_policy_spec.rb @@ -18,6 +18,11 @@ RSpec.describe SlaPolicy, type: :model do it 'creates valid sla policy object' do sla_policy = create(:sla_policy) expect(sla_policy.name).to eq 'sla_1' + expect(sla_policy.first_response_time_threshold).to eq 2000 + expect(sla_policy.description).to eq 'SLA policy for enterprise customers' + expect(sla_policy.next_response_time_threshold).to eq 1000 + expect(sla_policy.resolution_time_threshold).to eq 3000 + expect(sla_policy.only_during_business_hours).to be false end end end diff --git a/spec/factories/applied_slas.rb b/spec/factories/applied_slas.rb new file mode 100644 index 000000000..8ab48c558 --- /dev/null +++ b/spec/factories/applied_slas.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :applied_sla do + account + sla_policy + conversation + sla_status { 'active' } + end +end diff --git a/spec/factories/sla_policies.rb b/spec/factories/sla_policies.rb index 3f1d33b43..fbbd892ce 100644 --- a/spec/factories/sla_policies.rb +++ b/spec/factories/sla_policies.rb @@ -2,8 +2,10 @@ FactoryBot.define do factory :sla_policy do account name { 'sla_1' } - rt_threshold { 1000 } - frt_threshold { 2000 } + first_response_time_threshold { 2000 } + description { 'SLA policy for enterprise customers' } + next_response_time_threshold { 1000 } + resolution_time_threshold { 3000 } only_during_business_hours { false } end end From 143299f13802fbc4a608d69ff57e866cb8694f58 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 24 Jan 2024 12:26:47 +0530 Subject: [PATCH 014/101] feat: Add `contact_type` attribute to contact model (#8768) --- app/models/contact.rb | 3 +++ db/migrate/20240124054340_add_contact_type_to_contacts.rb | 5 +++++ db/schema.rb | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240124054340_add_contact_type_to_contacts.rb diff --git a/app/models/contact.rb b/app/models/contact.rb index ba10139ba..176109f24 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -6,6 +6,7 @@ # # id :integer not null, primary key # additional_attributes :jsonb +# contact_type :integer default("visitor") # custom_attributes :jsonb # email :string # identifier :string @@ -55,6 +56,8 @@ class Contact < ApplicationRecord after_update_commit :dispatch_update_event after_destroy_commit :dispatch_destroy_event + enum contact_type: { visitor: 0, lead: 1, customer: 2 } + scope :order_on_last_activity_at, lambda { |direction| order( Arel::Nodes::SqlLiteral.new( diff --git a/db/migrate/20240124054340_add_contact_type_to_contacts.rb b/db/migrate/20240124054340_add_contact_type_to_contacts.rb new file mode 100644 index 000000000..d6b939a36 --- /dev/null +++ b/db/migrate/20240124054340_add_contact_type_to_contacts.rb @@ -0,0 +1,5 @@ +class AddContactTypeToContacts < ActiveRecord::Migration[7.0] + def change + add_column :contacts, :contact_type, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 0fcf7ba61..b914eca61 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_12_23_040257) do +ActiveRecord::Schema[7.0].define(version: 2024_01_24_054340) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -418,6 +418,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_12_23_040257) do t.string "identifier" t.jsonb "custom_attributes", default: {} t.datetime "last_activity_at", precision: nil + t.integer "contact_type", default: 0 t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id" t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["account_id"], name: "index_contacts_on_account_id" From a861257f738e2c8fcda31c2c52d3a5f6685d8f72 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 24 Jan 2024 13:58:27 +0400 Subject: [PATCH 015/101] chore: Fix flaky contacts spec (#8773) - The ordering was not guaranteed; hence, the specs were failing randomly. Made changes to the expectations accordingly --- .../api/v1/accounts/contacts_controller_spec.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index 7352b842f..ace9e8e03 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -36,9 +36,11 @@ RSpec.describe 'Contacts API', type: :request do expect(response).to have_http_status(:success) response_body = response.parsed_body - expect(response_body['payload'].first['email']).to eq(contact.email) - expect(response_body['payload'].first['contact_inboxes'].first['source_id']).to eq(contact_inbox.source_id) - expect(response_body['payload'].first['contact_inboxes'].first['inbox']['name']).to eq(contact_inbox.inbox.name) + contact_emails = response_body['payload'].pluck('email') + contact_inboxes_source_ids = response_body['payload'].flat_map { |c| c['contact_inboxes'].pluck('source_id') } + + expect(contact_emails).to include(contact.email) + expect(contact_inboxes_source_ids).to include(contact_inbox.source_id) end it 'returns all contacts without contact inboxes' do From 3760f206e82dd01db8490923bf89b23f337b5c26 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 24 Jan 2024 15:48:21 +0530 Subject: [PATCH 016/101] fix: mutex timeout and error handling (#8770) Fixes the follow cases - The ensure block released the lock even on LockAcquisitionError - Custom timeout was not allowed This also refactored the with_lock method, now the key has to be constructed in the parent function itself Co-authored-by: Sojan Jose --- app/jobs/inboxes/fetch_imap_emails_job.rb | 3 +- app/jobs/mutex_application_job.rb | 32 +++++++++++++++------ app/jobs/send_on_slack_job.rb | 3 +- app/jobs/webhooks/facebook_events_job.rb | 3 +- app/jobs/webhooks/instagram_events_job.rb | 3 +- spec/jobs/mutex_application_job_spec.rb | 35 ++++++++++++++--------- 6 files changed, 54 insertions(+), 25 deletions(-) diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index 8f61e54b5..0f7718701 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -6,7 +6,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob def perform(channel) return unless should_fetch_email?(channel) - with_lock(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) do + key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) + with_lock(key, 5.minutes) do process_email_for_channel(channel) end rescue *ExceptionList::IMAP_EXCEPTIONS => e diff --git a/app/jobs/mutex_application_job.rb b/app/jobs/mutex_application_job.rb index 98e6bf9cd..58c7cbf36 100644 --- a/app/jobs/mutex_application_job.rb +++ b/app/jobs/mutex_application_job.rb @@ -14,20 +14,36 @@ class MutexApplicationJob < ApplicationJob class LockAcquisitionError < StandardError; end - def with_lock(key_format, *args) - lock_key = format(key_format, *args) + def with_lock(lock_key, timeout = Redis::LockManager::LOCK_TIMEOUT) lock_manager = Redis::LockManager.new begin - if lock_manager.lock(lock_key) - Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}" + if lock_manager.lock(lock_key, timeout) + log_attempt(lock_key, executions) yield + # release the lock after the block has been executed + lock_manager.unlock(lock_key) else - Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}" - raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}" + handle_failed_lock_acquisition(lock_key) end - ensure - lock_manager.unlock(lock_key) + rescue StandardError => e + handle_error(e, lock_manager, lock_key) end end + + private + + def log_attempt(lock_key, executions) + Rails.logger.info "[#{self.class.name}] Acquired lock for: #{lock_key} on attempt #{executions}" + end + + def handle_error(err, lock_manager, lock_key) + lock_manager.unlock(lock_key) unless err.is_a?(LockAcquisitionError) + raise err + end + + def handle_failed_lock_acquisition(lock_key) + Rails.logger.warn "[#{self.class.name}] Failed to acquire lock on attempt #{executions}: #{lock_key}" + raise LockAcquisitionError, "Failed to acquire lock for key: #{lock_key}" + end end diff --git a/app/jobs/send_on_slack_job.rb b/app/jobs/send_on_slack_job.rb index eececa409..c8a556ce7 100644 --- a/app/jobs/send_on_slack_job.rb +++ b/app/jobs/send_on_slack_job.rb @@ -3,7 +3,8 @@ class SendOnSlackJob < MutexApplicationJob retry_on LockAcquisitionError, wait: 1.second, attempts: 8 def perform(message, hook) - with_lock(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id) do + key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id) + with_lock(key) do Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform end end diff --git a/app/jobs/webhooks/facebook_events_job.rb b/app/jobs/webhooks/facebook_events_job.rb index 4240d62b9..0694a2b85 100644 --- a/app/jobs/webhooks/facebook_events_job.rb +++ b/app/jobs/webhooks/facebook_events_job.rb @@ -5,7 +5,8 @@ class Webhooks::FacebookEventsJob < MutexApplicationJob def perform(message) response = ::Integrations::Facebook::MessageParser.new(message) - with_lock(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id) do + key = format(::Redis::Alfred::FACEBOOK_MESSAGE_MUTEX, sender_id: response.sender_id, recipient_id: response.recipient_id) + with_lock(key) do process_message(response) end end diff --git a/app/jobs/webhooks/instagram_events_job.rb b/app/jobs/webhooks/instagram_events_job.rb index 024ba4a23..825f220a7 100644 --- a/app/jobs/webhooks/instagram_events_job.rb +++ b/app/jobs/webhooks/instagram_events_job.rb @@ -12,7 +12,8 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob def perform(entries) @entries = entries - with_lock(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id) do + key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id) + with_lock(key) do process_entries(entries) end end diff --git a/spec/jobs/mutex_application_job_spec.rb b/spec/jobs/mutex_application_job_spec.rb index 919195c32..b62db00f0 100644 --- a/spec/jobs/mutex_application_job_spec.rb +++ b/spec/jobs/mutex_application_job_spec.rb @@ -4,16 +4,6 @@ RSpec.describe MutexApplicationJob do let(:lock_manager) { instance_double(Redis::LockManager) } let(:lock_key) { 'test_key' } - let(:test_mutex_job_class) do - stub_const('TestMutexJob', Class.new(MutexApplicationJob) do - def perform - with_lock('test_key') do - # Do nothing - end - end - end) - end - before do allow(Redis::LockManager).to receive(:new).and_return(lock_manager) allow(lock_manager).to receive(:lock).and_return(true) @@ -22,24 +12,43 @@ RSpec.describe MutexApplicationJob do describe '#with_lock' do it 'acquires the lock and yields the block if lock is not acquired' do - expect(lock_manager).to receive(:lock).with(lock_key).and_return(true) + expect(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(true) expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true) expect { |b| described_class.new.send(:with_lock, lock_key, &b) }.to yield_control end + it 'acquires the lock with custom timeout' do + expect(lock_manager).to receive(:lock).with(lock_key, 5.seconds).and_return(true) + expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true) + + expect { |b| described_class.new.send(:with_lock, lock_key, 5.seconds, &b) }.to yield_control + end + it 'raises LockAcquisitionError if it cannot acquire the lock' do - allow(lock_manager).to receive(:lock).with(lock_key).and_return(false) + allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false) expect do described_class.new.send(:with_lock, lock_key) do # Do nothing end end.to raise_error(MutexApplicationJob::LockAcquisitionError) + expect(lock_manager).not_to receive(:unlock) + end + + it 'raises StandardError if it execution raises it' do + allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false) + allow(lock_manager).to receive(:unlock).with(lock_key).and_return(true) + + expect do + described_class.new.send(:with_lock, lock_key) do + raise StandardError + end + end.to raise_error(StandardError) end it 'ensures that the lock is released even if there is an error during block execution' do - expect(lock_manager).to receive(:lock).with(lock_key).and_return(true) + expect(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(true) expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true) expect do From fa907840c7f0bb728db63daef75f5400ce8da70c Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 24 Jan 2024 16:22:04 +0530 Subject: [PATCH 017/101] feat: Add `middle_name` and `last_name` to contact model (#8771) feat: Add `middle_name` and `last_name` --- app/models/contact.rb | 2 ++ ...40124084032_add_middle_name_and_last_name_to_contacts.rb | 6 ++++++ db/schema.rb | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240124084032_add_middle_name_and_last_name_to_contacts.rb diff --git a/app/models/contact.rb b/app/models/contact.rb index 176109f24..90f31d71f 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -11,6 +11,8 @@ # email :string # identifier :string # last_activity_at :datetime +# last_name :string default("") +# middle_name :string default("") # name :string default("") # phone_number :string # created_at :datetime not null diff --git a/db/migrate/20240124084032_add_middle_name_and_last_name_to_contacts.rb b/db/migrate/20240124084032_add_middle_name_and_last_name_to_contacts.rb new file mode 100644 index 000000000..5d7a8e266 --- /dev/null +++ b/db/migrate/20240124084032_add_middle_name_and_last_name_to_contacts.rb @@ -0,0 +1,6 @@ +class AddMiddleNameAndLastNameToContacts < ActiveRecord::Migration[7.0] + def change + add_column :contacts, :middle_name, :string, default: '' + add_column :contacts, :last_name, :string, default: '' + end +end diff --git a/db/schema.rb b/db/schema.rb index b914eca61..83065fb80 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_01_24_054340) do +ActiveRecord::Schema[7.0].define(version: 2024_01_24_084032) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -419,6 +419,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_01_24_054340) do t.jsonb "custom_attributes", default: {} t.datetime "last_activity_at", precision: nil t.integer "contact_type", default: 0 + t.string "middle_name", default: "" + t.string "last_name", default: "" t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id" t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["account_id"], name: "index_contacts_on_account_id" From 904d76420db98b99a6babb26ceba882ab3cd6ba2 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 25 Jan 2024 12:05:00 +0530 Subject: [PATCH 018/101] fix: Add `last_activity_at` to notification push event data (#8784) fix: Add last_activity_at to push event data --- app/models/notification.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/notification.rb b/app/models/notification.rb index db8fbb8ea..e72265f47 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -60,6 +60,7 @@ class Notification < ApplicationRecord secondary_actor: secondary_actor&.push_event_data, user: user&.push_event_data, created_at: created_at.to_i, + last_activity_at: last_activity_at.to_i, account_id: account_id } From b7c9f779ade0f5ce4b3e59249ef461f63778d356 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Wed, 24 Jan 2024 23:40:18 -0800 Subject: [PATCH 019/101] fix: Avoid processing reactions, ephemeral, request_welcome or unsupported messages (#8780) Currently, we do not support reactions, ephemeral messages, or the request_welcome event for the WhatsApp channel. However, if this is the first event we receive in Chatwoot (i.e., there is no previous conversation or contact in Chatwoot), it will create a contact and a conversation without any messages. This confuses our customer, as it may appear that Chatwoot has missed some messages. There are multiple cases where this might be the first event we receive in Chatwoot. One quick example is when the user has sent an outbound campaign from another tool and their customers reacted to the message. Another event like this is request_welcome event. WhatsApp has a concept for welcome messages. You can send an outbound message even though the user has not send a message. You can receive notifications through a webhook whenever a WhatsApp user initiates a chat with you for the first time. (Read the Welcome message section: https://developers.facebook.com/docs/whatsapp/cloud-api/phone-numbers/conversational-components/ ). Although this can help the business send a pro-active message to the user, we don't have it scoped in our feature set. For now, I'm ignoring this event. Fixes https://linear.app/chatwoot/issue/CW-3018/whatsapp-handle-request-welcome-case-properly Fixes https://linear.app/chatwoot/issue/CW-3017/whatsapp-handle-reactions-properly --- .../whatsapp/incoming_message_base_service.rb | 10 +++- .../incoming_message_service_helpers.rb | 2 +- .../jobs/webhooks/whatsapp_events_job_spec.rb | 55 +++++++++++++++++++ .../whatsapp/incoming_message_service_spec.rb | 12 ++-- 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb index 2d3b4efd3..f057fadbd 100644 --- a/app/services/whatsapp/incoming_message_base_service.rb +++ b/app/services/whatsapp/incoming_message_base_service.rb @@ -19,7 +19,13 @@ class Whatsapp::IncomingMessageBaseService private def process_messages - # message allready exists so we don't need to process + # We don't support reactions & ephemeral message now, we need to skip processing the message + # if the webhook event is a reaction or an ephermal message or an unsupported message. + return if unprocessable_message_type?(message_type) + + # Multiple webhook event can be received against the same message due to misconfigurations in the Meta + # business manager account. While we have not found the core reason yet, the following line ensure that + # there are no duplicate messages created. return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process? cache_message_source_id_in_redis @@ -49,8 +55,6 @@ class Whatsapp::IncomingMessageBaseService end def create_messages - return if unprocessable_message_type?(message_type) - message = @processed_params[:messages].first log_error(message) && return if error_webhook_event?(message) diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb index 76a31576e..c5474314b 100644 --- a/app/services/whatsapp/incoming_message_service_helpers.rb +++ b/app/services/whatsapp/incoming_message_service_helpers.rb @@ -44,7 +44,7 @@ module Whatsapp::IncomingMessageServiceHelpers end def unprocessable_message_type?(message_type) - %w[reaction ephemeral unsupported].include?(message_type) + %w[reaction ephemeral unsupported request_welcome].include?(message_type) end def brazil_phone_number?(phone_number) diff --git a/spec/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb index 8a7e783ad..1b7ec172f 100644 --- a/spec/jobs/webhooks/whatsapp_events_job_spec.rb +++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb @@ -146,6 +146,61 @@ RSpec.describe Webhooks::WhatsappEventsJob do end.not_to change(Message, :count) end + it 'ignore reaction type message, would not create contact if the reaction is the first event' do + other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false, + validate_provider_config: false) + wb_params = { + phone_number: channel.phone_number, + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + contacts: [{ profile: { name: 'Test Test' }, wa_id: '1111981136571' }], + messages: [{ + from: '1111981136571', reaction: { emoji: '👍' }, timestamp: '1664799904', type: 'reaction' + }], + metadata: { + phone_number_id: other_channel.provider_config['phone_number_id'], + display_phone_number: other_channel.phone_number.delete('+') + } + } + }] + }] + }.with_indifferent_access + expect do + Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform + end.not_to change(Contact, :count) + end + + it 'ignore request_welcome type message, would not create contact or conversation' do + other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false, + validate_provider_config: false) + wb_params = { + phone_number: channel.phone_number, + object: 'whatsapp_business_account', + entry: [{ + changes: [{ + value: { + messages: [{ + from: '1111981136571', timestamp: '1664799904', type: 'request_welcome' + }], + metadata: { + phone_number_id: other_channel.provider_config['phone_number_id'], + display_phone_number: other_channel.phone_number.delete('+') + } + } + }] + }] + }.with_indifferent_access + expect do + Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform + end.not_to change(Contact, :count) + + expect do + Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform + end.not_to change(Conversation, :count) + end + it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb index 114faf953..0bcbf2a3e 100644 --- a/spec/services/whatsapp/incoming_message_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_service_spec.rb @@ -81,7 +81,7 @@ describe Whatsapp::IncomingMessageService do end context 'when unsupported message types' do - it 'ignores type ephemeral' do + it 'ignores type ephemeral and does not create ghost conversation' do params = { 'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }], 'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa', 'text' => { 'body' => 'Test' }, @@ -89,12 +89,12 @@ describe Whatsapp::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: whatsapp_channel.inbox, params: params).perform - expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(whatsapp_channel.inbox.conversations.count).to eq(0) + expect(Contact.count).to eq(0) expect(whatsapp_channel.inbox.messages.count).to eq(0) end - it 'ignores type unsupported' do + it 'ignores type unsupported and does not create ghost conversation' do params = { 'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }], 'messages' => [{ @@ -105,8 +105,8 @@ describe Whatsapp::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: whatsapp_channel.inbox, params: params).perform - expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(whatsapp_channel.inbox.conversations.count).to eq(0) + expect(Contact.count).to eq(0) expect(whatsapp_channel.inbox.messages.count).to eq(0) end end From 381423b1aedb71f3a959bbe62252c16afa6d7fe6 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 25 Jan 2024 17:17:16 +0530 Subject: [PATCH 020/101] fix: Removed author section from public help center (#8767) Co-authored-by: Sojan Jose --- .../dashboard/helpcenter/components/ArticleTable.vue | 2 +- .../api/v1/portals/articles/_article_header.html.erb | 7 +++---- .../public/api/v1/portals/categories/show.html.erb | 12 +----------- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleTable.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleTable.vue index 6704ce7bf..aa92ced67 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleTable.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/components/ArticleTable.vue @@ -1,7 +1,7 @@