mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	feat: Ability to lock to single conversation (#5881)
Adds the ability to lock conversation to a single thread for Whatsapp and Sms Inboxes when using outbound messages. demo: https://www.loom.com/share/c9e1e563c8914837a4139dfdd2503fef fixes: #4975 Co-authored-by: Nithin David <1277421+nithindavid@users.noreply.github.com>
This commit is contained in:
		| @@ -16,7 +16,6 @@ Metrics/ClassLength: | ||||
|     - 'app/models/message.rb' | ||||
|     - 'app/builders/messages/facebook/message_builder.rb' | ||||
|     - 'app/controllers/api/v1/accounts/contacts_controller.rb' | ||||
|     - 'app/controllers/api/v1/accounts/conversations_controller.rb' | ||||
|     - 'app/listeners/action_cable_listener.rb' | ||||
|     - 'app/models/conversation.rb' | ||||
| RSpec/ExampleLength: | ||||
|   | ||||
							
								
								
									
										40
									
								
								app/builders/conversation_builder.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								app/builders/conversation_builder.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| class ConversationBuilder | ||||
|   pattr_initialize [:params!, :contact_inbox!] | ||||
|  | ||||
|   def perform | ||||
|     look_up_exising_conversation || create_new_conversation | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def look_up_exising_conversation | ||||
|     return unless @contact_inbox.inbox.lock_to_single_conversation? | ||||
|  | ||||
|     @contact_inbox.conversations.last | ||||
|   end | ||||
|  | ||||
|   def create_new_conversation | ||||
|     ::Conversation.create!(conversation_params) | ||||
|   end | ||||
|  | ||||
|   def conversation_params | ||||
|     additional_attributes = params[:additional_attributes]&.permit! || {} | ||||
|     custom_attributes = params[:custom_attributes]&.permit! || {} | ||||
|     status = params[:status].present? ? { status: params[:status] } : {} | ||||
|  | ||||
|     # TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases | ||||
|     # commenting this out to see if there are any errors, if not we can remove this in subsequent releases | ||||
|     # status = { status: 'pending' } if status[:status] == 'bot' | ||||
|     { | ||||
|       account_id: @contact_inbox.inbox.account_id, | ||||
|       inbox_id: @contact_inbox.inbox_id, | ||||
|       contact_id: @contact_inbox.contact_id, | ||||
|       contact_inbox_id: @contact_inbox.id, | ||||
|       additional_attributes: additional_attributes, | ||||
|       custom_attributes: custom_attributes, | ||||
|       snoozed_until: params[:snoozed_until], | ||||
|       assignee_id: params[:assignee_id], | ||||
|       team_id: params[:team_id] | ||||
|     }.merge(status) | ||||
|   end | ||||
| end | ||||
| @@ -24,7 +24,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro | ||||
|  | ||||
|   def create | ||||
|     ActiveRecord::Base.transaction do | ||||
|       @conversation = ::Conversation.create!(conversation_params) | ||||
|       @conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform | ||||
|       Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present? | ||||
|     end | ||||
|   end | ||||
| @@ -99,8 +99,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro | ||||
|   end | ||||
|  | ||||
|   def set_conversation_status | ||||
|     status = params[:status] == 'bot' ? 'pending' : params[:status] | ||||
|     @conversation.status = status | ||||
|     # TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases | ||||
|     # commenting this out to see if there are any errors, if not we can remove this in subsequent releases | ||||
|     # status = params[:status] == 'bot' ? 'pending' : params[:status] | ||||
|     @conversation.status = params[:status] | ||||
|     @conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until] | ||||
|   end | ||||
|  | ||||
| @@ -152,26 +154,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro | ||||
|     ).perform | ||||
|   end | ||||
|  | ||||
|   def conversation_params | ||||
|     additional_attributes = params[:additional_attributes]&.permit! || {} | ||||
|     custom_attributes = params[:custom_attributes]&.permit! || {} | ||||
|     status = params[:status].present? ? { status: params[:status] } : {} | ||||
|  | ||||
|     # TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases | ||||
|     status = { status: 'pending' } if status[:status] == 'bot' | ||||
|     { | ||||
|       account_id: Current.account.id, | ||||
|       inbox_id: @contact_inbox.inbox_id, | ||||
|       contact_id: @contact_inbox.contact_id, | ||||
|       contact_inbox_id: @contact_inbox.id, | ||||
|       additional_attributes: additional_attributes, | ||||
|       custom_attributes: custom_attributes, | ||||
|       snoozed_until: params[:snoozed_until], | ||||
|       assignee_id: params[:assignee_id], | ||||
|       team_id: params[:team_id] | ||||
|     }.merge(status) | ||||
|   end | ||||
|  | ||||
|   def conversation_finder | ||||
|     @conversation_finder ||= ConversationFinder.new(Current.user, params) | ||||
|   end | ||||
|   | ||||
| @@ -113,7 +113,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController | ||||
|  | ||||
|   def inbox_attributes | ||||
|     [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, | ||||
|      :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved] | ||||
|      :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, | ||||
|      :lock_to_single_conversation] | ||||
|   end | ||||
|  | ||||
|   def permitted_params(channel_attributes = []) | ||||
|   | ||||
| @@ -388,6 +388,10 @@ | ||||
|         "ENABLED": "Enabled", | ||||
|         "DISABLED": "Disabled" | ||||
|       }, | ||||
|       "LOCK_TO_SINGLE_CONVERSATION": { | ||||
|         "ENABLED": "Enabled", | ||||
|         "DISABLED": "Disabled" | ||||
|       }, | ||||
|       "ENABLE_HMAC": { | ||||
|         "LABEL": "Enable" | ||||
|       } | ||||
| @@ -441,6 +445,8 @@ | ||||
|       "ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation", | ||||
|       "ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email", | ||||
|       "ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.", | ||||
|       "LOCK_TO_SINGLE_CONVERSATION": "Lock to single conversation", | ||||
|       "LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Enable or disable multiple conversations for the same contact in this inbox", | ||||
|       "INBOX_UPDATE_TITLE": "Inbox Settings", | ||||
|       "INBOX_UPDATE_SUB_TEXT": "Update your inbox settings", | ||||
|       "AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.", | ||||
|   | ||||
| @@ -258,6 +258,28 @@ | ||||
|           </p> | ||||
|         </label> | ||||
|  | ||||
|         <label | ||||
|           v-if="canLocktoSingleConversation" | ||||
|           class="medium-9 columns settings-item" | ||||
|         > | ||||
|           {{ $t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION') }} | ||||
|           <select v-model="locktoSingleConversation"> | ||||
|             <option :value="true"> | ||||
|               {{ $t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED') }} | ||||
|             </option> | ||||
|             <option :value="false"> | ||||
|               {{ $t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED') }} | ||||
|             </option> | ||||
|           </select> | ||||
|           <p class="help-text"> | ||||
|             {{ | ||||
|               $t( | ||||
|                 'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT' | ||||
|               ) | ||||
|             }} | ||||
|           </p> | ||||
|         </label> | ||||
|  | ||||
|         <label v-if="isAWebWidgetInbox"> | ||||
|           {{ $t('INBOX_MGMT.FEATURES.LABEL') }} | ||||
|         </label> | ||||
| @@ -380,6 +402,7 @@ export default { | ||||
|       greetingMessage: '', | ||||
|       emailCollectEnabled: false, | ||||
|       csatSurveyEnabled: false, | ||||
|       locktoSingleConversation: false, | ||||
|       allowMessagesAfterResolved: true, | ||||
|       continuityViaEmail: true, | ||||
|       selectedInboxName: '', | ||||
| @@ -496,6 +519,9 @@ export default { | ||||
|       } | ||||
|       return this.inbox.name; | ||||
|     }, | ||||
|     canLocktoSingleConversation() { | ||||
|       return this.isASmsInbox || this.isAWhatsAppChannel; | ||||
|     }, | ||||
|     inboxNameLabel() { | ||||
|       if (this.isAWebWidgetInbox) { | ||||
|         return this.$t('INBOX_MGMT.ADD.WEBSITE_NAME.LABEL'); | ||||
| @@ -567,6 +593,7 @@ export default { | ||||
|         this.channelWelcomeTagline = this.inbox.welcome_tagline; | ||||
|         this.selectedFeatureFlags = this.inbox.selected_feature_flags || []; | ||||
|         this.replyTime = this.inbox.reply_time; | ||||
|         this.locktoSingleConversation = this.inbox.lock_to_single_conversation; | ||||
|       }); | ||||
|     }, | ||||
|     async updateInbox() { | ||||
| @@ -579,6 +606,7 @@ export default { | ||||
|           allow_messages_after_resolved: this.allowMessagesAfterResolved, | ||||
|           greeting_enabled: this.greetingEnabled, | ||||
|           greeting_message: this.greetingMessage || '', | ||||
|           lock_to_single_conversation: this.locktoSingleConversation, | ||||
|           channel: { | ||||
|             widget_color: this.inbox.widget_color, | ||||
|             website_url: this.channelWebsiteUrl, | ||||
|   | ||||
| @@ -89,7 +89,19 @@ export const mutations = { | ||||
|   }, | ||||
|   [types.default.ADD_CONTACT_CONVERSATION]: ($state, { id, data }) => { | ||||
|     const conversations = $state.records[id] || []; | ||||
|     Vue.set($state.records, id, [...conversations, data]); | ||||
|  | ||||
|     const updatedConversations = [...conversations]; | ||||
|     const index = conversations.findIndex( | ||||
|       conversation => conversation.id === data.id | ||||
|     ); | ||||
|  | ||||
|     if (index !== -1) { | ||||
|       updatedConversations[index] = { ...conversations[index], ...data }; | ||||
|     } else { | ||||
|       updatedConversations.push(data); | ||||
|     } | ||||
|  | ||||
|     Vue.set($state.records, id, updatedConversations); | ||||
|   }, | ||||
|   [types.default.DELETE_CONTACT_CONVERSATION]: ($state, id) => { | ||||
|     Vue.delete($state.records, id); | ||||
|   | ||||
| @@ -14,6 +14,7 @@ | ||||
| #  enable_email_collect          :boolean          default(TRUE) | ||||
| #  greeting_enabled              :boolean          default(FALSE) | ||||
| #  greeting_message              :string | ||||
| #  lock_to_single_conversation   :boolean          default(FALSE), not null | ||||
| #  name                          :string           not null | ||||
| #  out_of_office_message         :string | ||||
| #  timezone                      :string           default("UTC") | ||||
|   | ||||
| @@ -15,12 +15,13 @@ json.working_hours resource.weekly_schedule | ||||
| json.timezone resource.timezone | ||||
| json.callback_webhook_url resource.callback_webhook_url | ||||
| json.allow_messages_after_resolved resource.allow_messages_after_resolved | ||||
|  | ||||
| json.tweets_enabled resource.channel.try(:tweets_enabled) if resource.twitter? | ||||
| json.lock_to_single_conversation resource.lock_to_single_conversation | ||||
|  | ||||
| ## Channel specific settings | ||||
| ## TODO : Clean up and move the attributes into channel sub section | ||||
|  | ||||
| json.tweets_enabled resource.channel.try(:tweets_enabled) if resource.twitter? | ||||
|  | ||||
| ## WebWidget Attributes | ||||
| json.widget_color resource.channel.try(:widget_color) | ||||
| json.website_url resource.channel.try(:website_url) | ||||
|   | ||||
| @@ -0,0 +1,5 @@ | ||||
| class AddLockConversationToSingleThread < ActiveRecord::Migration[6.1] | ||||
|   def change | ||||
|     add_column :inboxes, :lock_to_single_conversation, :boolean, null: false, default: false | ||||
|   end | ||||
| end | ||||
| @@ -535,6 +535,7 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do | ||||
|     t.boolean "csat_survey_enabled", default: false | ||||
|     t.boolean "allow_messages_after_resolved", default: true | ||||
|     t.jsonb "auto_assignment_config", default: {} | ||||
|     t.boolean "lock_to_single_conversation", default: false, null: false | ||||
|     t.index ["account_id"], name: "index_inboxes_on_account_id" | ||||
|   end | ||||
|  | ||||
|   | ||||
							
								
								
									
										46
									
								
								spec/builders/conversation_builder_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								spec/builders/conversation_builder_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| describe ::ConversationBuilder do | ||||
|   let(:account) { create(:account) } | ||||
|   let!(:sms_channel) { create(:channel_sms, account: account) } | ||||
|   let!(:sms_inbox) { create(:inbox, channel: sms_channel, account: account) } | ||||
|   let(:contact) { create(:contact, account: account) } | ||||
|   let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: sms_inbox) } | ||||
|  | ||||
|   describe '#perform' do | ||||
|     it 'creates conversation' do | ||||
|       conversation = described_class.new( | ||||
|         contact_inbox: contact_inbox, | ||||
|         params: {} | ||||
|       ).perform | ||||
|  | ||||
|       expect(conversation.contact_inbox_id).to eq(contact_inbox.id) | ||||
|     end | ||||
|  | ||||
|     context 'when lock_to_single_conversation is true for inbox' do | ||||
|       before do | ||||
|         sms_inbox.update!(lock_to_single_conversation: true) | ||||
|       end | ||||
|  | ||||
|       it 'creates conversation when existing conversation is not present' do | ||||
|         conversation = described_class.new( | ||||
|           contact_inbox: contact_inbox, | ||||
|           params: {} | ||||
|         ).perform | ||||
|  | ||||
|         expect(conversation.contact_inbox_id).to eq(contact_inbox.id) | ||||
|       end | ||||
|  | ||||
|       it 'returns last from existing conversations when existing conversation is not present' do | ||||
|         create(:conversation, contact_inbox: contact_inbox) | ||||
|         existing_conversation = create(:conversation, contact_inbox: contact_inbox) | ||||
|         conversation = described_class.new( | ||||
|           contact_inbox: contact_inbox, | ||||
|           params: {} | ||||
|         ).perform | ||||
|  | ||||
|         expect(conversation.id).to eq(existing_conversation.id) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -265,17 +265,18 @@ RSpec.describe 'Conversations API', type: :request do | ||||
|  | ||||
|         # TODO: remove this spec when we remove the condition check in controller | ||||
|         # Added for backwards compatibility for bot status | ||||
|         it 'creates a conversation as pending if status is specified as bot' do | ||||
|           allow(Rails.configuration.dispatcher).to receive(:dispatch) | ||||
|           post "/api/v1/accounts/#{account.id}/conversations", | ||||
|                headers: agent.create_new_auth_token, | ||||
|                params: { source_id: contact_inbox.source_id, status: 'bot' }, | ||||
|                as: :json | ||||
|         # remove this in subsequent release | ||||
|         # it 'creates a conversation as pending if status is specified as bot' do | ||||
|         #   allow(Rails.configuration.dispatcher).to receive(:dispatch) | ||||
|         #   post "/api/v1/accounts/#{account.id}/conversations", | ||||
|         #        headers: agent.create_new_auth_token, | ||||
|         #        params: { source_id: contact_inbox.source_id, status: 'bot' }, | ||||
|         #        as: :json | ||||
|  | ||||
|           expect(response).to have_http_status(:success) | ||||
|           response_data = JSON.parse(response.body, symbolize_names: true) | ||||
|           expect(response_data[:status]).to eq('pending') | ||||
|         end | ||||
|         #   expect(response).to have_http_status(:success) | ||||
|         #   response_data = JSON.parse(response.body, symbolize_names: true) | ||||
|         #   expect(response_data[:status]).to eq('pending') | ||||
|         # end | ||||
|  | ||||
|         it 'creates a new conversation with message when message is passed' do | ||||
|           allow(Rails.configuration.dispatcher).to receive(:dispatch) | ||||
| @@ -408,17 +409,18 @@ RSpec.describe 'Conversations API', type: :request do | ||||
|  | ||||
|       # TODO: remove this spec when we remove the condition check in controller | ||||
|       # Added for backwards compatibility for bot status | ||||
|       it 'toggles the conversation status to pending status when parameter bot is passed' do | ||||
|         expect(conversation.status).to eq('open') | ||||
|       # remove in next release | ||||
|       # it 'toggles the conversation status to pending status when parameter bot is passed' do | ||||
|       #   expect(conversation.status).to eq('open') | ||||
|  | ||||
|         post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status", | ||||
|              headers: agent.create_new_auth_token, | ||||
|              params: { status: 'bot' }, | ||||
|              as: :json | ||||
|       #   post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status", | ||||
|       #        headers: agent.create_new_auth_token, | ||||
|       #        params: { status: 'bot' }, | ||||
|       #        as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expect(conversation.reload.status).to eq('pending') | ||||
|       end | ||||
|       #   expect(response).to have_http_status(:success) | ||||
|       #   expect(conversation.reload.status).to eq('pending') | ||||
|       # end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Sojan Jose
					Sojan Jose