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:
Muhsin Keloth 2021-05-10 13:01:00 +05:30 committed by GitHub
parent db31bfcee4
commit 3fc646f330
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 371 additions and 6 deletions

View file

@ -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

View file

@ -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') {

View 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 };

View file

@ -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,
};

View 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 };

View file

@ -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,
},
});

View 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,
};

View file

@ -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],
]);
});
});
});

View 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',
},
];

View file

@ -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',
},
]);
});
});

View file

@ -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);
});
});
});

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)