From 56f668db6bca4ad7c91ca88a9e258d00bfb46f7a Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Tue, 7 Jun 2022 13:01:01 +0530 Subject: [PATCH 001/226] feat: Attribute changed filter for automations (#4621) --- .../accounts/automation_rules_controller.rb | 12 ++- app/listeners/automation_rule_listener.rb | 10 +- .../conditions_filter_service.rb | 37 ++++++++ .../automation_rule_listener_spec.rb | 95 +++++++++++++++++++ 4 files changed, 148 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 5e649b6e0..78cd15e31 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -9,6 +9,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def create @automation_rule = Current.account.automation_rules.new(automation_rules_permit) @automation_rule.actions = params[:actions] + @automation_rule.conditions = params[:conditions] render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid? @@ -31,9 +32,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def update ActiveRecord::Base.transaction do - @automation_rule.update!(automation_rules_permit) - @automation_rule.actions = params[:actions] if params[:actions] - @automation_rule.save! + automation_rule_update process_attachments rescue StandardError => e @@ -67,6 +66,13 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont private + def automation_rule_update + @automation_rule.update!(automation_rules_permit) + @automation_rule.actions = params[:actions] if params[:actions] + @automation_rule.conditions = params[:conditions] if params[:conditions] + @automation_rule.save! + end + def automation_rules_permit params.permit( :name, :description, :event_name, :account_id, :active, diff --git a/app/listeners/automation_rule_listener.rb b/app/listeners/automation_rule_listener.rb index 0f445fea7..f778dcd69 100644 --- a/app/listeners/automation_rule_listener.rb +++ b/app/listeners/automation_rule_listener.rb @@ -4,11 +4,12 @@ class AutomationRuleListener < BaseListener conversation = event_obj.data[:conversation] account = conversation.account + changed_attributes = event_obj.data[:changed_attributes] return unless rule_present?('conversation_updated', account) @rules.each do |rule| - conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation).perform + conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present? end end @@ -18,11 +19,12 @@ class AutomationRuleListener < BaseListener conversation = event_obj.data[:conversation] account = conversation.account + changed_attributes = event_obj.data[:changed_attributes] return unless rule_present?('conversation_created', account) @rules.each do |rule| - conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation).perform + conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform ::AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present? end end @@ -32,11 +34,13 @@ class AutomationRuleListener < BaseListener message = event_obj.data[:message] account = message.try(:account) + changed_attributes = event_obj.data[:changed_attributes] return unless rule_present?('message_created', account) @rules.each do |rule| - conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation, { message: message }).perform + conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation, + { message: message, changed_attributes: changed_attributes }).perform ::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present? end end diff --git a/app/services/automation_rules/conditions_filter_service.rb b/app/services/automation_rules/conditions_filter_service.rb index 67cd2a62c..e32a95751 100644 --- a/app/services/automation_rules/conditions_filter_service.rb +++ b/app/services/automation_rules/conditions_filter_service.rb @@ -11,18 +11,24 @@ class AutomationRules::ConditionsFilterService < FilterService file = File.read('./lib/filters/filter_keys.json') @filters = JSON.parse(file) @options = options + @changed_attributes = options[:changed_attributes] end def perform @conversation_filters = @filters['conversations'] @contact_filters = @filters['contacts'] @message_filters = @filters['messages'] + @attribute_changed_query_filter = [] @rule.conditions.each_with_index do |query_hash, current_index| + @attribute_changed_query_filter << query_hash and next if query_hash['filter_operator'] == 'attribute_changed' + apply_filter(query_hash, current_index) end records = base_relation.where(@query_string, @filter_values.with_indifferent_access) + records = perform_attribute_changed_filter(records) if @attribute_changed_query_filter.any? + records.any? end @@ -43,6 +49,37 @@ class AutomationRules::ConditionsFilterService < FilterService end end + # If attribute_changed type filter is present perform this against array + def perform_attribute_changed_filter(records) + @attribute_changed_records = [] + current_attribute_changed_record = base_relation + filter_based_on_attribute_change(records, current_attribute_changed_record) + + @attribute_changed_records.uniq + end + + # Loop through attribute_changed_query_filter + def filter_based_on_attribute_change(records, current_attribute_changed_record) + @attribute_changed_query_filter.each do |filter| + @changed_attributes = @changed_attributes.with_indifferent_access + changed_attribute = @changed_attributes[filter['attribute_key']].presence + + if changed_attribute[0].in?(filter['values']['from']) && changed_attribute[1].in?(filter['values']['to']) + @attribute_changed_records = attribute_changed_filter_query(filter, records, current_attribute_changed_record) + end + current_attribute_changed_record = @attribute_changed_records + end + end + + # We intersect with the record if query_operator-AND is present and union if query_operator-OR is present + def attribute_changed_filter_query(filter, records, current_attribute_changed_record) + if filter['query_operator'] == 'AND' + @attribute_changed_records + (current_attribute_changed_record & records) + else + @attribute_changed_records + (current_attribute_changed_record | records) + end + end + def message_query_string(current_filter, query_hash, current_index) attribute_key = query_hash['attribute_key'] query_operator = query_hash['query_operator'] diff --git a/spec/listeners/automation_rule_listener_spec.rb b/spec/listeners/automation_rule_listener_spec.rb index 6ab931554..8f4213137 100644 --- a/spec/listeners/automation_rule_listener_spec.rb +++ b/spec/listeners/automation_rule_listener_spec.rb @@ -259,6 +259,101 @@ describe AutomationRuleListener do expect(conversation.messages.first.content).to eq('Send this message.') end end + + context 'when conditions based on attribute_changed' do + before do + automation_rule.update!( + event_name: 'conversation_updated', + name: 'Call actions conversation updated when company changed from DC to Marvel', + description: 'Add labels, assign team after conversation updated', + conditions: [ + { + attribute_key: 'company', + filter_operator: 'attribute_changed', + values: { from: ['DC'], to: ['Marvel'] }, + query_operator: 'AND' + }.with_indifferent_access, + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['snoozed'], + query_operator: nil + }.with_indifferent_access + ] + ) + conversation.update(status: :snoozed) + end + + let!(:event) do + Events::Base.new('conversation_updated', Time.zone.now, { conversation: conversation, changed_attributes: { + company: %w[DC Marvel] + } }) + end + + context 'when rule matches' do + it 'triggers automation rule to assign team' do + expect(conversation.team_id).not_to eq(team.id) + + listener.conversation_updated(event) + + conversation.reload + expect(conversation.team_id).to eq(team.id) + end + + it 'triggers automation rule to assign team with OR operator' do + conversation.update(status: :open) + automation_rule.update!( + conditions: [ + { + attribute_key: 'company', + filter_operator: 'attribute_changed', + values: { from: ['DC'], to: ['Marvel'] }, + query_operator: 'OR' + }.with_indifferent_access, + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['snoozed'], + query_operator: nil + }.with_indifferent_access + ] + ) + + expect(conversation.team_id).not_to eq(team.id) + + listener.conversation_updated(event) + + conversation.reload + expect(conversation.team_id).to eq(team.id) + end + end + + context 'when rule doesnt match' do + it 'when automation rule is triggered it will not assign team' do + conversation.update(status: :open) + + expect(conversation.team_id).not_to eq(team.id) + + listener.conversation_updated(event) + + conversation.reload + expect(conversation.team_id).not_to eq(team.id) + end + + it 'when automation rule is triggers, it will not assign team on attribute_changed values' do + conversation.update(status: :snoozed) + event = Events::Base.new('conversation_updated', Time.zone.now, { conversation: conversation, + changed_attributes: { company: %w[Marvel DC] } }) + + expect(conversation.team_id).not_to eq(team.id) + + listener.conversation_updated(event) + + conversation.reload + expect(conversation.team_id).not_to eq(team.id) + end + end + end end describe '#message_created' do From bad24f97abd3f839d40f890b24f797c09e25a9c6 Mon Sep 17 00:00:00 2001 From: Fayaz Ahmed <15716057+fayazara@users.noreply.github.com> Date: Tue, 7 Jun 2022 17:33:33 +0530 Subject: [PATCH 002/226] feat: Add support for Whatsapp template messages in the UI (#4711) Co-authored-by: Pranav Raj S --- app/builders/messages/message_builder.rb | 6 +- app/javascript/dashboard/api/inbox/message.js | 4 + app/javascript/dashboard/components/Modal.vue | 3 + .../widgets/WootWriter/ReplyBottomPanel.vue | 17 ++ .../widgets/conversation/ReplyBox.vue | 63 ++++-- .../conversation/WhatsappTemplates/Modal.vue | 76 ++++++++ .../WhatsappTemplates/TemplateParser.vue | 183 ++++++++++++++++++ .../WhatsappTemplates/TemplatesPicker.vue | 163 ++++++++++++++++ .../widgets/conversation/bubble/Actions.vue | 8 +- .../components/widgets/forms/Input.vue | 5 + .../dashboard/i18n/locale/en/index.js | 2 + .../i18n/locale/en/whatsappTemplates.json | 25 +++ .../conversation/contact/ConversationForm.vue | 58 ++++-- .../contact/WhatsappTemplates.vue | 59 ++++++ .../dashboard/settings/inbox/Settings.vue | 2 +- .../dashboard/store/modules/inboxes.js | 14 ++ .../FluentIcon/dashboard-icons.json | 1 + app/models/channel/whatsapp.rb | 34 ++-- app/models/inbox.rb | 4 + .../whatsapp/send_on_whatsapp_service.rb | 26 ++- app/views/api/v1/models/_inbox.json.jbuilder | 3 + .../whatsapp/send_on_whatsapp_service_spec.rb | 31 +++ 22 files changed, 733 insertions(+), 54 deletions(-) create mode 100644 app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplatesPicker.vue create mode 100644 app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json create mode 100644 app/javascript/dashboard/routes/dashboard/conversation/contact/WhatsappTemplates.vue diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 5c8cadbcd..e9bf0802b 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -73,6 +73,10 @@ class Messages::MessageBuilder @params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {} end + def template_params + @params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {} + end + def message_sender return if @params[:sender_type] != 'AgentBot' @@ -91,6 +95,6 @@ class Messages::MessageBuilder items: @items, in_reply_to: @in_reply_to, echo_id: @params[:echo_id] - }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id) + }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) end end diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index f3c5e83e9..f0096cf23 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -10,6 +10,7 @@ export const buildCreatePayload = ({ files, ccEmails = '', bccEmails = '', + templateParams, }) => { let payload; if (files && files.length !== 0) { @@ -32,6 +33,7 @@ export const buildCreatePayload = ({ content_attributes: contentAttributes, cc_emails: ccEmails, bcc_emails: bccEmails, + template_params: templateParams, }; } return payload; @@ -51,6 +53,7 @@ class MessageApi extends ApiClient { files, ccEmails = '', bccEmails = '', + templateParams, }) { return axios({ method: 'post', @@ -63,6 +66,7 @@ class MessageApi extends ApiClient { files, ccEmails, bccEmails, + templateParams, }), }); } diff --git a/app/javascript/dashboard/components/Modal.vue b/app/javascript/dashboard/components/Modal.vue index f86935959..f4cad844a 100644 --- a/app/javascript/dashboard/components/Modal.vue +++ b/app/javascript/dashboard/components/Modal.vue @@ -98,4 +98,7 @@ export default { width: 48rem; } } +.modal-big { + width: 60%; +} diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index 6dcb47532..e7a0b5995 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -79,6 +79,16 @@ :title="signatureToggleTooltip" @click="toggleMessageSignature" /> +
+
@@ -137,7 +146,7 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages'; import { BUS_EVENTS } from 'shared/constants/busEvents'; - +import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import { isEscape, isEnter, @@ -162,6 +171,7 @@ export default { WootMessageEditor, WootAudioRecorder, Banner, + WhatsappTemplates, }, mixins: [ clickaway, @@ -201,6 +211,7 @@ export default { hasSlashCommand: false, bccEmails: '', ccEmails: '', + showWhatsAppTemplatesModal: false, }; }, computed: { @@ -212,7 +223,6 @@ export default { globalConfig: 'globalConfig/get', accountId: 'getCurrentAccountId', }), - showRichContentEditor() { if (this.isOnPrivateNote) { return true; @@ -256,7 +266,9 @@ export default { return false; }, - + hasWhatsappTemplates() { + return !!this.inbox.message_templates; + }, enterToSendEnabled() { return !!this.uiSettings.enter_to_send_enabled; }, @@ -484,7 +496,7 @@ export default { hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused; if (shouldSendMessage) { e.preventDefault(); - this.sendMessage(); + this.onSendReply(); } } else if (hasPressedCommandPlusKKey(e)) { this.openCommandBar(); @@ -497,6 +509,12 @@ export default { toggleEnterToSend(enterToSendEnabled) { this.updateUISettings({ enter_to_send_enabled: enterToSendEnabled }); }, + openWhatsappTemplateModal() { + this.showWhatsAppTemplatesModal = true; + }, + hideWhatsappTemplatesModal() { + this.showWhatsAppTemplatesModal = false; + }, onClickSelfAssign() { const { account_id, @@ -520,7 +538,7 @@ export default { }; this.assignedAgent = selfAssign; }, - async sendMessage() { + async onSendReply() { if (this.isReplyButtonDisabled) { return; } @@ -531,22 +549,31 @@ export default { } const messagePayload = this.getMessagePayload(newMessage); this.clearMessage(); - try { - await this.$store.dispatch( - 'createPendingMessageAndSend', - messagePayload - ); - bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE); - } catch (error) { - const errorMessage = - error?.response?.data?.error || - this.$t('CONVERSATION.MESSAGE_ERROR'); - this.showAlert(errorMessage); - } + this.sendMessage(messagePayload); this.hideEmojiPicker(); this.$emit('update:popoutReplyBox', false); } }, + async sendMessage(messagePayload) { + try { + await this.$store.dispatch( + 'createPendingMessageAndSend', + messagePayload + ); + bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE); + } catch (error) { + const errorMessage = + error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR'); + this.showAlert(errorMessage); + } + }, + async onSendWhatsAppReply(messagePayload) { + this.sendMessage({ + conversationId: this.currentChat.id, + ...messagePayload, + }); + this.hideWhatsappTemplatesModal(); + }, replaceText(message) { setTimeout(() => { this.message = message; diff --git a/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue new file mode 100644 index 000000000..1c888408b --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue new file mode 100644 index 000000000..7fc8ac431 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplatesPicker.vue b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplatesPicker.vue new file mode 100644 index 000000000..b9e9ba71c --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplatesPicker.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue index c1c3440bc..7f454fa20 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue @@ -7,7 +7,7 @@ @@ -47,6 +48,10 @@ export default { type: Boolean, deafaut: false, }, + styles: { + type: Object, + default: () => {}, + }, }, methods: { onChange(e) { diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index b21009322..d49420335 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -21,6 +21,7 @@ import { default as _setNewPassword } from './setNewPassword.json'; import { default as _settings } from './settings.json'; import { default as _signup } from './signup.json'; import { default as _teamsSettings } from './teamsSettings.json'; +import { default as _whatsappTemplates } from './whatsappTemplates.json'; import { default as _bulkActions } from './bulkActions.json'; export default { @@ -47,5 +48,6 @@ export default { ..._settings, ..._signup, ..._teamsSettings, + ..._whatsappTemplates, ..._bulkActions, }; diff --git a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json new file mode 100644 index 000000000..bbcf28156 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json @@ -0,0 +1,25 @@ +{ + "WHATSAPP_TEMPLATES": { + "MODAL": { + "TITLE": "Whatsapp Templates", + "SUBTITLE": "Select the whatsapp template you want to send", + "TEMPLATE_SELECTED_SUBTITLE": "Process %{templateName}" + }, + "PICKER": { + "SEARCH_PLACEHOLDER": "Search Templates", + "NO_TEMPLATES_FOUND": "No templates found for", + "LABELS": { + "LANGUAGE": "Language", + "TEMPLATE_BODY": "Template Body", + "CATEGORY": "Category" + } + }, + "PARSER": { + "VARIABLES_LABEL": "Variables", + "VARIABLE_PLACEHOLDER": "Enter %{variable} value", + "GO_BACK_LABEL": "Go Back", + "SEND_MESSAGE_LABEL": "Send Message", + "FORM_ERROR_MESSAGE": "Please fill all variables before sending" + } + } +} diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue index f2d262644..9dda5c02c 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue @@ -1,12 +1,12 @@ diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index ee12b989a..7b90ef6eb 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -11,14 +11,13 @@ @mouseleave="onCardLeave" @click="cardClick(chat)" > -