mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	feat: Add campaigns in web widget (#2227)
* add campaign store(getter, actions and mutations) * add campaign store module * add get campaigns api * add fetch campaign action widget load * add specs * code cleanup * trigger campaig api fixes * integrate campaign trigger action * code cleanup * revert changes * trigger api fixes * review fixes * code beautification * chore: Fix multiple campaigns being send because of race condition * chore: rubocop * chore: Fix specs * disable campaigns Co-authored-by: Nithin David Thomas <webofnithin@gmail.com> Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
		| @@ -5,10 +5,12 @@ class Campaigns::CampaignConversationBuilder | ||||
|     @contact_inbox = ContactInbox.find(@contact_inbox_id) | ||||
|     @campaign = @contact_inbox.inbox.campaigns.find_by!(display_id: campaign_display_id) | ||||
|  | ||||
|     # We won't send campaigns if a conversation is already present | ||||
|     return if @contact_inbox.conversations.present? | ||||
|  | ||||
|     ActiveRecord::Base.transaction do | ||||
|       @contact_inbox.lock! | ||||
|  | ||||
|       # We won't send campaigns if a conversation is already present | ||||
|       return if @contact_inbox.reload.conversations.present? | ||||
|  | ||||
|       @conversation = ::Conversation.create!(conversation_params) | ||||
|       Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform | ||||
|     end | ||||
|   | ||||
| @@ -73,6 +73,7 @@ export default { | ||||
|   methods: { | ||||
|     ...mapActions('appConfig', ['setWidgetColor']), | ||||
|     ...mapActions('conversation', ['fetchOldConversations', 'setUserLastSeen']), | ||||
|     ...mapActions('campaign', ['fetchCampaigns']), | ||||
|     ...mapActions('agent', ['fetchAvailableAgents']), | ||||
|     scrollConversationToBottom() { | ||||
|       const container = this.$el.querySelector('.conversation-wrap'); | ||||
| @@ -149,6 +150,7 @@ export default { | ||||
|           this.fetchOldConversations().then(() => this.setUnreadView()); | ||||
|           this.setPopoutDisplay(message.showPopoutButton); | ||||
|           this.fetchAvailableAgents(websiteToken); | ||||
|           this.fetchCampaigns(websiteToken); | ||||
|           this.setHideMessageBubble(message.hideMessageBubble); | ||||
|           this.$store.dispatch('contacts/get'); | ||||
|         } else if (message.event === 'widget-visible') { | ||||
|   | ||||
							
								
								
									
										23
									
								
								app/javascript/widget/api/campaign.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/javascript/widget/api/campaign.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import endPoints from 'widget/api/endPoints'; | ||||
| import { API } from 'widget/helpers/axios'; | ||||
|  | ||||
| const getCampaigns = async websiteToken => { | ||||
|   const urlData = endPoints.getCampaigns(websiteToken); | ||||
|   const result = await API.get(urlData.url, { params: urlData.params }); | ||||
|   return result; | ||||
| }; | ||||
|  | ||||
| const triggerCampaign = async ({ campaignId }) => { | ||||
|   const { websiteToken } = window.chatwootWebChannel; | ||||
|   const urlData = endPoints.triggerCampaign(websiteToken, campaignId); | ||||
|  | ||||
|   await API.post( | ||||
|     urlData.url, | ||||
|     { ...urlData.data }, | ||||
|     { | ||||
|       params: urlData.params, | ||||
|     } | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export { getCampaigns, triggerCampaign }; | ||||
| @@ -64,6 +64,24 @@ const getAvailableAgents = token => ({ | ||||
|     website_token: token, | ||||
|   }, | ||||
| }); | ||||
| const getCampaigns = token => ({ | ||||
|   url: '/api/v1/widget/campaigns', | ||||
|   params: { | ||||
|     website_token: token, | ||||
|   }, | ||||
| }); | ||||
| const triggerCampaign = (token, campaignId) => ({ | ||||
|   url: '/api/v1/widget/events', | ||||
|   data: { | ||||
|     name: 'campaign.triggered', | ||||
|     event_info: { | ||||
|       campaign_id: campaignId, | ||||
|     }, | ||||
|   }, | ||||
|   params: { | ||||
|     website_token: token, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| export default { | ||||
|   createConversation, | ||||
| @@ -72,4 +90,6 @@ export default { | ||||
|   getConversation, | ||||
|   updateMessage, | ||||
|   getAvailableAgents, | ||||
|   getCampaigns, | ||||
|   triggerCampaign, | ||||
| }; | ||||
|   | ||||
							
								
								
									
										14
									
								
								app/javascript/widget/helpers/campaignTimer.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/javascript/widget/helpers/campaignTimer.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import { triggerCampaign } from 'widget/api/campaign'; | ||||
| const startTimer = async ({ allCampaigns }) => { | ||||
|   allCampaigns.forEach(campaign => { | ||||
|     const { | ||||
|       trigger_rules: { time_on_page: timeOnPage }, | ||||
|       id: campaignId, | ||||
|     } = campaign; | ||||
|     setTimeout(async () => { | ||||
|       await triggerCampaign({ campaignId }); | ||||
|     }, timeOnPage * 1000); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export { startTimer }; | ||||
| @@ -9,9 +9,9 @@ import conversationLabels from 'widget/store/modules/conversationLabels'; | ||||
| import events from 'widget/store/modules/events'; | ||||
| import globalConfig from 'shared/store/globalConfig'; | ||||
| import message from 'widget/store/modules/message'; | ||||
| import campaign from 'widget/store/modules/campaign'; | ||||
|  | ||||
| Vue.use(Vuex); | ||||
|  | ||||
| export default new Vuex.Store({ | ||||
|   modules: { | ||||
|     agent, | ||||
| @@ -23,5 +23,6 @@ export default new Vuex.Store({ | ||||
|     events, | ||||
|     globalConfig, | ||||
|     message, | ||||
|     campaign, | ||||
|   }, | ||||
| }); | ||||
|   | ||||
							
								
								
									
										51
									
								
								app/javascript/widget/store/modules/campaign.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								app/javascript/widget/store/modules/campaign.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| import Vue from 'vue'; | ||||
| import { getCampaigns } from 'widget/api/campaign'; | ||||
| import { startTimer } from 'widget/helpers/campaignTimer'; | ||||
|  | ||||
| const state = { | ||||
|   records: [], | ||||
|   uiFlags: { | ||||
|     isError: false, | ||||
|     hasFetched: false, | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export const getters = { | ||||
|   getHasFetched: $state => $state.uiFlags.hasFetched, | ||||
|   fetchCampaigns: $state => $state.records, | ||||
| }; | ||||
|  | ||||
| export const actions = { | ||||
|   fetchCampaigns: async ({ commit }, websiteToken) => { | ||||
|     try { | ||||
|       const { data } = await getCampaigns(websiteToken); | ||||
|       startTimer({ allCampaigns: data }); | ||||
|       commit('setCampaigns', data); | ||||
|       commit('setError', false); | ||||
|       commit('setHasFetched', true); | ||||
|     } catch (error) { | ||||
|       commit('setError', true); | ||||
|       commit('setHasFetched', true); | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export const mutations = { | ||||
|   setCampaigns($state, data) { | ||||
|     Vue.set($state, 'records', data); | ||||
|   }, | ||||
|   setError($state, value) { | ||||
|     Vue.set($state.uiFlags, 'isError', value); | ||||
|   }, | ||||
|   setHasFetched($state, value) { | ||||
|     Vue.set($state.uiFlags, 'hasFetched', value); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state, | ||||
|   getters, | ||||
|   actions, | ||||
|   mutations, | ||||
| }; | ||||
| @@ -0,0 +1,28 @@ | ||||
| import { API } from 'widget/helpers/axios'; | ||||
| import { actions } from '../../campaign'; | ||||
| import { campaigns } from './data'; | ||||
|  | ||||
| const commit = jest.fn(); | ||||
| jest.mock('widget/helpers/axios'); | ||||
|  | ||||
| describe('#actions', () => { | ||||
|   describe('#fetchCampaigns', () => { | ||||
|     it('sends correct actions if API is success', async () => { | ||||
|       API.get.mockResolvedValue({ data: campaigns }); | ||||
|       await actions.fetchCampaigns({ commit }, 'XDsafmADasd'); | ||||
|       expect(commit.mock.calls).toEqual([ | ||||
|         ['setCampaigns', campaigns], | ||||
|         ['setError', false], | ||||
|         ['setHasFetched', true], | ||||
|       ]); | ||||
|     }); | ||||
|     it('sends correct actions if API is error', async () => { | ||||
|       API.get.mockRejectedValue({ message: 'Authentication required' }); | ||||
|       await actions.fetchCampaigns({ commit }, 'XDsafmADasd'); | ||||
|       expect(commit.mock.calls).toEqual([ | ||||
|         ['setError', true], | ||||
|         ['setHasFetched', true], | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										86
									
								
								app/javascript/widget/store/modules/specs/campaign/data.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								app/javascript/widget/store/modules/specs/campaign/data.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | ||||
| export const campaigns = [ | ||||
|   { | ||||
|     id: 1, | ||||
|     title: 'Welcome', | ||||
|     description: null, | ||||
|     account_id: 1, | ||||
|     inbox: { | ||||
|       id: 37, | ||||
|       channel_id: 1, | ||||
|       name: 'Chatwoot', | ||||
|       channel_type: 'Channel::WebWidget', | ||||
|     }, | ||||
|     sender: { | ||||
|       account_id: 1, | ||||
|       availability_status: 'offline', | ||||
|       confirmed: true, | ||||
|       email: 'sojan@chatwoot.com', | ||||
|       available_name: 'Sojan', | ||||
|       id: 10, | ||||
|       name: 'Sojan', | ||||
|     }, | ||||
|     message: 'Hey, What brings you today', | ||||
|     enabled: true, | ||||
|     trigger_rules: { | ||||
|       url: 'https://github.com', | ||||
|       time_on_page: 10, | ||||
|     }, | ||||
|     created_at: '2021-05-03T04:53:36.354Z', | ||||
|     updated_at: '2021-05-03T04:53:36.354Z', | ||||
|   }, | ||||
|   { | ||||
|     id: 11, | ||||
|     title: 'Onboarding Campaign', | ||||
|     description: null, | ||||
|     account_id: 1, | ||||
|     inbox: { | ||||
|       id: 37, | ||||
|       channel_id: 1, | ||||
|       name: 'GitX', | ||||
|       channel_type: 'Channel::WebWidget', | ||||
|     }, | ||||
|     sender: { | ||||
|       account_id: 1, | ||||
|       availability_status: 'offline', | ||||
|       confirmed: true, | ||||
|       email: 'sojan@chatwoot.com', | ||||
|       available_name: 'Sojan', | ||||
|       id: 10, | ||||
|     }, | ||||
|     message: 'Begin your onboarding campaign with a welcome message', | ||||
|     enabled: true, | ||||
|     trigger_rules: { | ||||
|       url: 'https://chatwoot.com', | ||||
|       time_on_page: '20', | ||||
|     }, | ||||
|     created_at: '2021-05-03T08:15:35.828Z', | ||||
|     updated_at: '2021-05-03T08:15:35.828Z', | ||||
|   }, | ||||
|   { | ||||
|     id: 12, | ||||
|     title: 'Thanks', | ||||
|     description: null, | ||||
|     account_id: 1, | ||||
|     inbox: { | ||||
|       id: 37, | ||||
|       channel_id: 1, | ||||
|       name: 'Chatwoot', | ||||
|       channel_type: 'Channel::WebWidget', | ||||
|     }, | ||||
|     sender: { | ||||
|       account_id: 1, | ||||
|       availability_status: 'offline', | ||||
|       confirmed: true, | ||||
|       email: 'nithin@chatwoot.com', | ||||
|       available_name: 'Nithin', | ||||
|     }, | ||||
|     message: 'Thanks for coming to the show. How may I help you?', | ||||
|     enabled: false, | ||||
|     trigger_rules: { | ||||
|       url: 'https://noshow.com', | ||||
|       time_on_page: 10, | ||||
|     }, | ||||
|     created_at: '2021-05-03T10:22:51.025Z', | ||||
|     updated_at: '2021-05-03T10:22:51.025Z', | ||||
|   }, | ||||
| ]; | ||||
| @@ -0,0 +1,96 @@ | ||||
| import { getters } from '../../campaign'; | ||||
| import { campaigns } from './data'; | ||||
|  | ||||
| describe('#getters', () => { | ||||
|   it('fetchCampaigns', () => { | ||||
|     const state = { | ||||
|       records: campaigns, | ||||
|     }; | ||||
|     expect(getters.fetchCampaigns(state)).toEqual([ | ||||
|       { | ||||
|         id: 1, | ||||
|         title: 'Welcome', | ||||
|         description: null, | ||||
|         account_id: 1, | ||||
|         inbox: { | ||||
|           id: 37, | ||||
|           channel_id: 1, | ||||
|           name: 'Chatwoot', | ||||
|           channel_type: 'Channel::WebWidget', | ||||
|         }, | ||||
|         sender: { | ||||
|           account_id: 1, | ||||
|           availability_status: 'offline', | ||||
|           confirmed: true, | ||||
|           email: 'sojan@chatwoot.com', | ||||
|           available_name: 'Sojan', | ||||
|           id: 10, | ||||
|           name: 'Sojan', | ||||
|         }, | ||||
|         message: 'Hey, What brings you today', | ||||
|         enabled: true, | ||||
|         trigger_rules: { | ||||
|           url: 'https://github.com', | ||||
|           time_on_page: 10, | ||||
|         }, | ||||
|         created_at: '2021-05-03T04:53:36.354Z', | ||||
|         updated_at: '2021-05-03T04:53:36.354Z', | ||||
|       }, | ||||
|       { | ||||
|         id: 11, | ||||
|         title: 'Onboarding Campaign', | ||||
|         description: null, | ||||
|         account_id: 1, | ||||
|         inbox: { | ||||
|           id: 37, | ||||
|           channel_id: 1, | ||||
|           name: 'GitX', | ||||
|           channel_type: 'Channel::WebWidget', | ||||
|         }, | ||||
|         sender: { | ||||
|           account_id: 1, | ||||
|           availability_status: 'offline', | ||||
|           confirmed: true, | ||||
|           email: 'sojan@chatwoot.com', | ||||
|           available_name: 'Sojan', | ||||
|           id: 10, | ||||
|         }, | ||||
|         message: 'Begin your onboarding campaign with a welcome message', | ||||
|         enabled: true, | ||||
|         trigger_rules: { | ||||
|           url: 'https://chatwoot.com', | ||||
|           time_on_page: '20', | ||||
|         }, | ||||
|         created_at: '2021-05-03T08:15:35.828Z', | ||||
|         updated_at: '2021-05-03T08:15:35.828Z', | ||||
|       }, | ||||
|       { | ||||
|         id: 12, | ||||
|         title: 'Thanks', | ||||
|         description: null, | ||||
|         account_id: 1, | ||||
|         inbox: { | ||||
|           id: 37, | ||||
|           channel_id: 1, | ||||
|           name: 'Chatwoot', | ||||
|           channel_type: 'Channel::WebWidget', | ||||
|         }, | ||||
|         sender: { | ||||
|           account_id: 1, | ||||
|           availability_status: 'offline', | ||||
|           confirmed: true, | ||||
|           email: 'nithin@chatwoot.com', | ||||
|           available_name: 'Nithin', | ||||
|         }, | ||||
|         message: 'Thanks for coming to the show. How may I help you?', | ||||
|         enabled: false, | ||||
|         trigger_rules: { | ||||
|           url: 'https://noshow.com', | ||||
|           time_on_page: 10, | ||||
|         }, | ||||
|         created_at: '2021-05-03T10:22:51.025Z', | ||||
|         updated_at: '2021-05-03T10:22:51.025Z', | ||||
|       }, | ||||
|     ]); | ||||
|   }); | ||||
| }); | ||||
| @@ -0,0 +1,28 @@ | ||||
| import { mutations } from '../../campaign'; | ||||
| import { campaigns } from './data'; | ||||
|  | ||||
| describe('#mutations', () => { | ||||
|   describe('#setCampagins', () => { | ||||
|     it('set campaign records', () => { | ||||
|       const state = { records: [] }; | ||||
|       mutations.setCampaigns(state, campaigns); | ||||
|       expect(state.records).toEqual(campaigns); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('#setError', () => { | ||||
|     it('set error flag', () => { | ||||
|       const state = { records: [], uiFlags: {} }; | ||||
|       mutations.setError(state, true); | ||||
|       expect(state.uiFlags.isError).toEqual(true); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('#setHasFetched', () => { | ||||
|     it('set fetched flag', () => { | ||||
|       const state = { records: [], uiFlags: {} }; | ||||
|       mutations.setHasFetched(state, true); | ||||
|       expect(state.uiFlags.hasFetched).toEqual(true); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -6,7 +6,7 @@ class CampaignListener < BaseListener | ||||
|     return if campaign_display_id.blank? | ||||
|  | ||||
|     ::Campaigns::CampaignConversationBuilder.new( | ||||
|       contact_inbox: contact_inbox.id, | ||||
|       contact_inbox_id: contact_inbox.id, | ||||
|       campaign_display_id: campaign_display_id, | ||||
|       conversation_additional_attributes: event.data[:event_info].except(:campaign_id) | ||||
|     ).perform | ||||
|   | ||||
| @@ -3,9 +3,11 @@ class MessageTemplates::HookExecutionService | ||||
|  | ||||
|   def perform | ||||
|     return if inbox.agent_bot_inbox&.active? | ||||
|     return if conversation.campaign.present? | ||||
|  | ||||
|     # TODO: let's see whether this is needed and remove this and related logic if not | ||||
|     # ::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message? | ||||
|  | ||||
|     ::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting? | ||||
|     ::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if should_send_email_collect? | ||||
|   end | ||||
|   | ||||
| @@ -23,7 +23,7 @@ describe CampaignListener do | ||||
|     context 'when params contain campaign id' do | ||||
|       it 'triggers campaign conversation builder' do | ||||
|         expect(Campaigns::CampaignConversationBuilder).to receive(:new) | ||||
|           .with({ contact_inbox: contact_inbox.id, campaign_display_id: campaign.display_id, conversation_additional_attributes: {} }).once | ||||
|           .with({ contact_inbox_id: contact_inbox.id, campaign_display_id: campaign.display_id, conversation_additional_attributes: {} }).once | ||||
|         listener.campaign_triggered(event) | ||||
|       end | ||||
|     end | ||||
|   | ||||
| @@ -39,6 +39,18 @@ describe ::MessageTemplates::HookExecutionService do | ||||
|       expect(::MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation) | ||||
|     end | ||||
|  | ||||
|     it 'doesnot calls ::MessageTemplates::Template::EmailCollect on campaign conversations' do | ||||
|       contact = create(:contact, email: nil) | ||||
|       conversation = create(:conversation, contact: contact, campaign: create(:campaign)) | ||||
|  | ||||
|       allow(::MessageTemplates::Template::EmailCollect).to receive(:new).and_return(true) | ||||
|  | ||||
|       # described class gets called in message after commit | ||||
|       message = create(:message, conversation: conversation) | ||||
|  | ||||
|       expect(::MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation) | ||||
|     end | ||||
|  | ||||
|     it 'doesnot calls ::MessageTemplates::Template::Greeting if greeting_message is empty' do | ||||
|       contact = create(:contact, email: nil) | ||||
|       conversation = create(:conversation, contact: contact) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Muhsin Keloth
					Muhsin Keloth