diff --git a/app/builders/campaigns/campaign_conversation_builder.rb b/app/builders/campaigns/campaign_conversation_builder.rb index 3a9291a80..48a3cebd4 100644 --- a/app/builders/campaigns/campaign_conversation_builder.rb +++ b/app/builders/campaigns/campaign_conversation_builder.rb @@ -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 diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index e0e08911c..08f171eed 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -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') { diff --git a/app/javascript/widget/api/campaign.js b/app/javascript/widget/api/campaign.js new file mode 100644 index 000000000..9515a8190 --- /dev/null +++ b/app/javascript/widget/api/campaign.js @@ -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 }; diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js index 6d856ee48..b68d9b4ed 100755 --- a/app/javascript/widget/api/endPoints.js +++ b/app/javascript/widget/api/endPoints.js @@ -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, }; diff --git a/app/javascript/widget/helpers/campaignTimer.js b/app/javascript/widget/helpers/campaignTimer.js new file mode 100644 index 000000000..1cf71298f --- /dev/null +++ b/app/javascript/widget/helpers/campaignTimer.js @@ -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 }; diff --git a/app/javascript/widget/store/index.js b/app/javascript/widget/store/index.js index cf844fd3e..f2ab1e3ae 100755 --- a/app/javascript/widget/store/index.js +++ b/app/javascript/widget/store/index.js @@ -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, }, }); diff --git a/app/javascript/widget/store/modules/campaign.js b/app/javascript/widget/store/modules/campaign.js new file mode 100644 index 000000000..b4f7df7da --- /dev/null +++ b/app/javascript/widget/store/modules/campaign.js @@ -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, +}; diff --git a/app/javascript/widget/store/modules/specs/campaign/actions.spec.js b/app/javascript/widget/store/modules/specs/campaign/actions.spec.js new file mode 100644 index 000000000..48a641f00 --- /dev/null +++ b/app/javascript/widget/store/modules/specs/campaign/actions.spec.js @@ -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], + ]); + }); + }); +}); diff --git a/app/javascript/widget/store/modules/specs/campaign/data.js b/app/javascript/widget/store/modules/specs/campaign/data.js new file mode 100644 index 000000000..0b079d44b --- /dev/null +++ b/app/javascript/widget/store/modules/specs/campaign/data.js @@ -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', + }, +]; diff --git a/app/javascript/widget/store/modules/specs/campaign/getters.spec.js b/app/javascript/widget/store/modules/specs/campaign/getters.spec.js new file mode 100644 index 000000000..b0a1d29aa --- /dev/null +++ b/app/javascript/widget/store/modules/specs/campaign/getters.spec.js @@ -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', + }, + ]); + }); +}); diff --git a/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js b/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js new file mode 100644 index 000000000..63e4b4ae3 --- /dev/null +++ b/app/javascript/widget/store/modules/specs/campaign/mutations.spec.js @@ -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); + }); + }); +}); diff --git a/app/listeners/campaign_listener.rb b/app/listeners/campaign_listener.rb index ed082357f..1405911c2 100644 --- a/app/listeners/campaign_listener.rb +++ b/app/listeners/campaign_listener.rb @@ -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 diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb index 6d4c9ddd7..53c334f88 100644 --- a/app/services/message_templates/hook_execution_service.rb +++ b/app/services/message_templates/hook_execution_service.rb @@ -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 diff --git a/spec/listeners/campaign_listener_spec.rb b/spec/listeners/campaign_listener_spec.rb index bf5ed77c0..bfc5df08f 100644 --- a/spec/listeners/campaign_listener_spec.rb +++ b/spec/listeners/campaign_listener_spec.rb @@ -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 diff --git a/spec/services/message_templates/hook_execution_service_spec.rb b/spec/services/message_templates/hook_execution_service_spec.rb index a35ccd25d..2b8748daf 100644 --- a/spec/services/message_templates/hook_execution_service_spec.rb +++ b/spec/services/message_templates/hook_execution_service_spec.rb @@ -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)