[Feature] Email collect message hooks (#331)

- Add email collect hook on creating conversation
- Merge contact if it already exist
This commit is contained in:
Sojan Jose
2020-01-09 13:06:40 +05:30
committed by Pranav Raj S
parent 59d4eaeca7
commit 722f540b03
68 changed files with 1111 additions and 544 deletions

View File

@@ -4,13 +4,13 @@ require:
- rubocop-rspec - rubocop-rspec
inherit_from: .rubocop_todo.yml inherit_from: .rubocop_todo.yml
Metrics/LineLength: Layout/LineLength:
Max: 150 Max: 150
Metrics/ClassLength: Metrics/ClassLength:
Max: 125 Max: 125
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 15 Max: 15
Documentation: Style/Documentation:
Enabled: false Enabled: false
Style/FrozenStringLiteralComment: Style/FrozenStringLiteralComment:
Enabled: false Enabled: false

View File

@@ -6,8 +6,7 @@ require 'open-uri'
# based on this we are showing "not sent from chatwoot" message in frontend # based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages. # Hence there is no need to set user_id in message for outgoing echo messages.
module Messages class Messages::MessageBuilder
class MessageBuilder
attr_reader :response attr_reader :response
def initialize(response, inbox, outgoing_echo = false) def initialize(response, inbox, outgoing_echo = false)
@@ -40,7 +39,7 @@ module Messages
avatar_resource = LocalResource.new(contact_params[:remote_avatar_url]) avatar_resource = LocalResource.new(contact_params[:remote_avatar_url])
@contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) @contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
end end
def build_message def build_message
@@ -58,7 +57,14 @@ module Messages
end end
def conversation def conversation
@conversation ||= Conversation.find_by(conversation_params) || Conversation.create!(conversation_params) @conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
end end
def attachment_params(attachment) def attachment_params(attachment)
@@ -134,4 +140,3 @@ module Messages
} }
end end
end end
end

View File

@@ -1,6 +1,4 @@
module Api class Api::V1::InboxMembersController < Api::BaseController
module V1
class InboxMembersController < Api::BaseController
before_action :fetch_inbox, only: [:create, :show] before_action :fetch_inbox, only: [:create, :show]
before_action :current_agents_ids, only: [:create] before_action :current_agents_ids, only: [:create]
@@ -51,5 +49,3 @@ module Api
@inbox = current_account.inboxes.find(params[:inbox_id]) @inbox = current_account.inboxes.find(params[:inbox_id])
end end
end end
end
end

View File

@@ -0,0 +1,29 @@
class Api::V1::Widget::BaseController < ApplicationController
private
def conversation
@conversation ||= @contact_inbox.conversations.find_by(
inbox_id: auth_token_params[:inbox_id]
)
end
def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers[header_name]).decode_token
end
def header_name
'X-Auth-Token'
end
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@account = @web_widget.account
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox.contact
end
end

View File

@@ -1,6 +1,8 @@
class Api::V1::Widget::MessagesController < ActionController::Base class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
skip_before_action :verify_authenticity_token before_action :set_web_widget
before_action :set_contact
before_action :set_conversation, only: [:create] before_action :set_conversation, only: [:create]
before_action :set_message, only: [:update]
def index def index
@messages = conversation.nil? ? [] : message_finder.perform @messages = conversation.nil? ? [] : message_finder.perform
@@ -9,17 +11,19 @@ class Api::V1::Widget::MessagesController < ActionController::Base
def create def create
@message = conversation.messages.new(message_params) @message = conversation.messages.new(message_params)
@message.save! @message.save!
render json: @message
end
def update
@message.update!(input_submitted_email: permitted_params[:contact][:email])
update_contact(permitted_params[:contact][:email])
head :no_content
rescue StandardError => e
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
end end
private private
def conversation
@conversation ||= ::Conversation.find_by(
contact_id: cookie_params[:contact_id],
inbox_id: cookie_params[:inbox_id]
)
end
def set_conversation def set_conversation
@conversation = ::Conversation.create!(conversation_params) if conversation.nil? @conversation = ::Conversation.create!(conversation_params) if conversation.nil?
end end
@@ -37,7 +41,8 @@ class Api::V1::Widget::MessagesController < ActionController::Base
{ {
account_id: inbox.account_id, account_id: inbox.account_id,
inbox_id: inbox.id, inbox_id: inbox.id,
contact_id: cookie_params[:contact_id], contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: { additional_attributes: {
browser: browser_params, browser: browser_params,
referer: permitted_params[:message][:referer_url], referer: permitted_params[:message][:referer_url],
@@ -63,13 +68,7 @@ class Api::V1::Widget::MessagesController < ActionController::Base
end end
def inbox def inbox
@inbox ||= ::Inbox.find_by(id: cookie_params[:inbox_id]) @inbox ||= ::Inbox.find_by(id: auth_token_params[:inbox_id])
end
def cookie_params
@cookie_params ||= JWT.decode(
request.headers[header_name], secret_key, true, algorithm: 'HS256'
).first.symbolize_keys
end end
def message_finder_params def message_finder_params
@@ -83,15 +82,27 @@ class Api::V1::Widget::MessagesController < ActionController::Base
@message_finder ||= MessageFinder.new(conversation, message_finder_params) @message_finder ||= MessageFinder.new(conversation, message_finder_params)
end end
def header_name def update_contact(email)
'X-Auth-Token' contact_with_email = @account.contacts.find_by(email: email)
if contact_with_email
::ContactMergeAction.new(account: @account, base_contact: contact_with_email, mergee_contact: @contact).perform
else
@contact.update!(
email: permitted_params[:contact][:email],
name: contact_name
)
end
end
def contact_name
permitted_params[:contact][:email].split('@')[0]
end end
def permitted_params def permitted_params
params.permit(:before, message: [:content, :referer_url, :timestamp]) params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp])
end end
def secret_key def set_message
Rails.application.secrets.secret_key_base @message = @web_widget.inbox.messages.find(permitted_params[:id])
end end
end end

View File

@@ -4,66 +4,47 @@ class WidgetsController < ActionController::Base
before_action :set_contact before_action :set_contact
before_action :build_contact before_action :build_contact
def index
render
end
private private
def set_contact
return if cookie_params[:source_id].nil?
contact_inbox = ::ContactInbox.find_by(
inbox_id: @web_widget.inbox.id,
source_id: cookie_params[:source_id]
)
@contact = contact_inbox ? contact_inbox.contact : nil
end
def set_token
@token = conversation_token
end
def set_web_widget def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
end end
def set_token
@token = permitted_params[:cw_conversation]
@auth_token_params = if @token.present?
::Widget::TokenService.new(token: @token).decode_token
else
{}
end
end
def set_contact
return if @auth_token_params[:source_id].nil?
contact_inbox = ::ContactInbox.find_by(
inbox_id: @web_widget.inbox.id,
source_id: @auth_token_params[:source_id]
)
@contact = contact_inbox ? contact_inbox.contact : nil
end
def build_contact def build_contact
return if @contact.present? return if @contact.present?
contact_inbox = @web_widget.create_contact_inbox contact_inbox = @web_widget.create_contact_inbox
@contact = contact_inbox.contact @contact = contact_inbox.contact
payload = { payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
source_id: contact_inbox.source_id, @token = ::Widget::TokenService.new(payload: payload).generate_token
contact_id: @contact.id,
inbox_id: @web_widget.inbox.id
}
@token = JWT.encode payload, secret_key, 'HS256'
end
def cookie_params
return @cookie_params if @cookie_params.present?
if conversation_token.present?
begin
@cookie_params = JWT.decode(
conversation_token, secret_key, true, algorithm: 'HS256'
).first.symbolize_keys
rescue StandardError
@cookie_params = {}
end
return @cookie_params
end
{}
end
def conversation_token
permitted_params[:cw_conversation]
end end
def permitted_params def permitted_params
params.permit(:website_token, :cw_conversation) params.permit(:website_token, :cw_conversation)
end end
def secret_key
Rails.application.secrets.secret_key_base
end
end end

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,3 +0,0 @@
<template>
<span class="spinner small"></span>
</template>

View File

@@ -12,7 +12,7 @@
</template> </template>
<script> <script>
import Spinner from '../Spinner'; import Spinner from 'shared/components/Spinner';
export default { export default {
components: { components: {

View File

@@ -15,7 +15,7 @@
/* eslint no-console: 0 */ /* eslint no-console: 0 */
/* global bus */ /* global bus */
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Spinner from '../Spinner'; import Spinner from 'shared/components/Spinner';
export default { export default {
props: ['conversationId'], props: ['conversationId'],

View File

@@ -1,6 +1,7 @@
/* eslint no-plusplus: 0 */ /* eslint no-plusplus: 0 */
/* eslint-env browser */ /* eslint-env browser */
import Spinner from 'shared/components/Spinner';
import Bar from './widgets/chart/BarChart'; import Bar from './widgets/chart/BarChart';
import Code from './Code'; import Code from './Code';
import LoadingState from './widgets/LoadingState'; import LoadingState from './widgets/LoadingState';
@@ -8,7 +9,6 @@ import Modal from './Modal';
import ModalHeader from './ModalHeader'; import ModalHeader from './ModalHeader';
import ReportStatsCard from './widgets/ReportStatsCard'; import ReportStatsCard from './widgets/ReportStatsCard';
import SidemenuIcon from './SidemenuIcon'; import SidemenuIcon from './SidemenuIcon';
import Spinner from './Spinner';
import SubmitButton from './buttons/FormSubmitButton'; import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs'; import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem'; import TabsItem from './ui/Tabs/TabsItem';

View File

@@ -74,13 +74,13 @@ export default {
return this.formatMessage(this.data.content); return this.formatMessage(this.data.content);
}, },
alignBubble() { alignBubble() {
return this.data.message_type === 1 ? 'right' : 'left'; return !this.data.message_type ? 'left' : 'right';
}, },
readableTime() { readableTime() {
return this.messageStamp(this.data.created_at); return this.messageStamp(this.data.created_at);
}, },
isBubble() { isBubble() {
return this.data.message_type === 1 || this.data.message_type === 0; return [0, 1, 3].includes(this.data.message_type);
}, },
isPrivate() { isPrivate() {
return this.data.private; return this.data.private;

View File

@@ -120,7 +120,7 @@ const IFrameHelper = {
createFrame: ({ baseUrl, websiteToken }) => { createFrame: ({ baseUrl, websiteToken }) => {
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
const cwCookie = Cookies.get('cw_conversation'); const cwCookie = Cookies.get('cw_conversation');
let widgetUrl = `${baseUrl}/widgets?website_token=${websiteToken}`; let widgetUrl = `${baseUrl}/widget?website_token=${websiteToken}`;
if (cwCookie) { if (cwCookie) {
widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`; widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`;
} }
@@ -143,6 +143,18 @@ const IFrameHelper = {
); );
}, },
initPostMessageCommunication: () => { initPostMessageCommunication: () => {
const events = {
loaded: message => {
Cookies.set('cw_conversation', message.config.authToken);
IFrameHelper.sendMessage('config-set', {});
IFrameHelper.onLoad(message.config.channelConfig);
IFrameHelper.setCurrentUrl();
},
set_auth_token: message => {
Cookies.set('cw_conversation', message.authToken);
},
};
window.onmessage = e => { window.onmessage = e => {
if ( if (
typeof e.data !== 'string' || typeof e.data !== 'string' ||
@@ -151,11 +163,8 @@ const IFrameHelper = {
return; return;
} }
const message = JSON.parse(e.data.replace('chatwoot-widget:', '')); const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
if (message.event === 'loaded') { if (typeof events[message.event] === 'function') {
Cookies.set('cw_conversation', message.config.authToken); events[message.event](message);
IFrameHelper.sendMessage('config-set', {});
IFrameHelper.onLoad(message.config.channelConfig);
IFrameHelper.setCurrentUrl();
} }
}; };
}, },
@@ -195,7 +204,6 @@ const IFrameHelper = {
onClickChatBubble(); onClickChatBubble();
}, },
setCurrentUrl: () => { setCurrentUrl: () => {
console.log(IFrameHelper.getAppFrame(), document);
IFrameHelper.sendMessage('set-current-url', { IFrameHelper.sendMessage('set-current-url', {
refererURL: window.location.href, refererURL: window.location.href,
}); });

View File

@@ -1,9 +1,12 @@
import Vue from 'vue'; import Vue from 'vue';
import Vuelidate from 'vuelidate';
import store from '../widget/store'; import store from '../widget/store';
import App from '../widget/App.vue'; import App from '../widget/App.vue';
import router from '../widget/router'; import router from '../widget/router';
import ActionCableConnector from '../widget/helpers/actionCable'; import ActionCableConnector from '../widget/helpers/actionCable';
Vue.use(Vuelidate);
Vue.config.productionTip = false; Vue.config.productionTip = false;
window.onload = () => { window.onload = () => {
window.WOOT_WIDGET = new Vue({ window.WOOT_WIDGET = new Vue({

View File

@@ -1,12 +0,0 @@
import authEndPoint from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios';
const createContact = async (inboxId, accountId) => {
const urlData = authEndPoint.createContact(inboxId, accountId);
const result = await API.post(urlData.url, urlData.params);
return result;
};
export default {
createContact,
};

View File

@@ -0,0 +1,10 @@
import authEndPoint from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios';
export const updateContact = async ({ messageId, email }) => {
const urlData = authEndPoint.updateContact(messageId);
const result = await API.patch(urlData.url, {
contact: { email },
});
return result;
};

View File

@@ -14,7 +14,12 @@ const getConversation = ({ before }) => ({
params: { before }, params: { before },
}); });
const updateContact = id => ({
url: `/api/v1/widget/messages/${id}${window.location.search}`,
});
export default { export default {
sendMessage, sendMessage,
getConversation, getConversation,
updateContact,
}; };

View File

@@ -9,6 +9,7 @@ $input-height: $space-two * 2;
appearance: none; appearance: none;
background: $color-white; background: $color-white;
border: 1px solid $color-border;
border-radius: $border-radius; border-radius: $border-radius;
box-sizing: border-box; box-sizing: border-box;
color: $color-body; color: $color-body;

View File

@@ -57,6 +57,9 @@ $color-background-light: #fafafa;
$color-white: #fff; $color-white: #fff;
$color-body: #3c4858; $color-body: #3c4858;
$color-heading: #1f2d3d; $color-heading: #1f2d3d;
$color-error: #ff4949;
// Thumbnail // Thumbnail
$thumbnail-radius: 4rem; $thumbnail-radius: 4rem;
@@ -89,3 +92,8 @@ $footer-height: 11.2rem;
$header-expanded-height: $space-medium * 10; $header-expanded-height: $space-medium * 10;
$font-family: 'Inter', -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; $font-family: 'Inter', -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
$ionicons-font-path: '~ionicons/fonts';
$spinkit-spinner-color: $color-white !default;
$spinkit-spinner-margin: 0 0 0 1.6rem !default;
$spinkit-size: 1.6rem !default;

View File

@@ -4,6 +4,8 @@
@import 'mixins'; @import 'mixins';
@import 'forms'; @import 'forms';
@import 'shared/assets/fonts/inter'; @import 'shared/assets/fonts/inter';
@import '~ionicons/scss/ionicons';
@import '~spinkit/scss/spinners/7-three-bounce';
html, html,
body { body {

View File

@@ -9,7 +9,13 @@
/> />
</div> </div>
<div class="message-wrap"> <div class="message-wrap">
<AgentMessageBubble :message="message" /> <AgentMessageBubble
:content-type="contentType"
:message-content-attributes="messageContentAttributes"
:message-id="messageId"
:message-type="messageType"
:message="message"
/>
<p v-if="showAvatar" class="agent-name"> <p v-if="showAvatar" class="agent-name">
{{ agentName }} {{ agentName }}
</p> </p>
@@ -32,7 +38,22 @@ export default {
avatarUrl: String, avatarUrl: String,
agentName: String, agentName: String,
showAvatar: Boolean, showAvatar: Boolean,
createdAt: Number, contentType: {
type: String,
default: '',
},
messageContentAttributes: {
type: Object,
default: () => {},
},
messageType: {
type: Number,
default: 1,
},
messageId: {
type: Number,
default: 0,
},
}, },
}; };
</script> </script>

View File

@@ -1,20 +1,42 @@
<template> <template>
<div class="chat-bubble agent" v-html="formatMessage(message)"></div> <div class="chat-bubble agent">
<span v-html="formatMessage(message)"></span>
<email-input
v-if="shouldShowInput"
:message-id="messageId"
:message-content-attributes="messageContentAttributes"
/>
</div>
</template> </template>
<script> <script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import EmailInput from './template/EmailInput';
export default { export default {
name: 'AgentMessageBubble', name: 'AgentMessageBubble',
components: {
EmailInput,
},
mixins: [messageFormatterMixin], mixins: [messageFormatterMixin],
props: { props: {
message: String, message: String,
contentType: String,
messageType: Number,
messageId: Number,
messageContentAttributes: {
type: Object,
default: () => {},
},
},
computed: {
shouldShowInput() {
return this.contentType === 'input_email' && this.messageType === 3;
},
}, },
}; };
</script> </script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss"> <style lang="scss">
@import '~widget/assets/scss/variables.scss'; @import '~widget/assets/scss/variables.scss';

View File

@@ -7,9 +7,13 @@
<AgentMessage <AgentMessage
v-else v-else
:agent-name="agentName" :agent-name="agentName"
:avatar-url="avatarUrl"
:content-type="message.content_type"
:message-content-attributes="message.content_attributes"
:message-id="message.id"
:message-type="message.message_type"
:message="message.content" :message="message.content"
:show-avatar="message.showAvatar" :show-avatar="message.showAvatar"
:avatar-url="avatarUrl"
/> />
</template> </template>
@@ -32,9 +36,18 @@ export default {
return this.message.message_type === MESSAGE_TYPE.INCOMING; return this.message.message_type === MESSAGE_TYPE.INCOMING;
}, },
agentName() { agentName() {
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
return 'Bot';
}
return this.message.sender ? this.message.sender.name : ''; return this.message.sender ? this.message.sender.name : '';
}, },
avatarUrl() { avatarUrl() {
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
// eslint-disable-next-line
return require('dashboard/assets/images/chatwoot_bot.png');
}
return this.message.sender ? this.message.sender.avatar_url : ''; return this.message.sender ? this.message.sender.avatar_url : '';
}, },
}, },

View File

@@ -6,7 +6,7 @@
@click="onClick" @click="onClick"
> >
<span v-if="!loading" class="icon-holder"> <span v-if="!loading" class="icon-holder">
<img src="~widget/assets/images/message-send.svg" /> <i class="ion-android-send" />
</span> </span>
<spinner v-else size="small" /> <spinner v-else size="small" />
</button> </button>
@@ -51,6 +51,7 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
fill: $color-white; fill: $color-white;
font-size: $font-size-big;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
} }
} }

View File

@@ -0,0 +1,115 @@
<template>
<div>
<form
v-if="!hasSubmitted"
class="email-input-group"
@submit.prevent="onSubmit()"
>
<input
v-model.trim="email"
class="form-input small"
placeholder="Please enter your email"
:class="{ error: $v.email.$error }"
@input="$v.email.$touch"
/>
<button
class="button"
:disabled="$v.email.$invalid"
:style="{ background: widgetColor, borderColor: widgetColor }"
>
<i v-if="!uiFlags.isUpdating" class="ion-android-arrow-forward" />
<spinner v-else />
</button>
</form>
<span v-else>
<i>{{ messageContentAttributes.submitted_email }}</i>
</span>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner';
import { required, email } from 'vuelidate/lib/validators';
export default {
components: {
Spinner,
},
props: {
messageId: {
type: Number,
required: true,
},
messageContentAttributes: {
type: Object,
default: () => {},
},
},
data() {
return {
email: '',
};
},
computed: {
...mapGetters({
uiFlags: 'contact/getUIFlags',
widgetColor: 'appConfig/getWidgetColor',
}),
hasSubmitted() {
return (
this.messageContentAttributes &&
this.messageContentAttributes.submitted_email
);
},
},
validations: {
email: {
required,
email,
},
},
methods: {
onSubmit() {
this.$store.dispatch('contact/updateContactAttributes', {
email: this.email,
messageId: this.messageId,
});
},
},
};
</script>
<style lang="scss" scoped>
@import '~widget/assets/scss/variables.scss';
.email-input-group {
display: flex;
margin: $space-small 0;
min-width: 200px;
input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
&.error {
border-color: $color-error;
}
}
.button {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
font-size: $font-size-large;
height: auto;
margin-left: -1px;
.spinner {
display: block;
padding: 0;
height: auto;
width: auto;
}
}
}
</style>

View File

@@ -9,4 +9,6 @@ export const MESSAGE_STATUS = {
export const MESSAGE_TYPE = { export const MESSAGE_TYPE = {
INCOMING: 0, INCOMING: 0,
OUTGOING: 1, OUTGOING: 1,
ACTIVITY: 2,
TEMPLATE: 3,
}; };

View File

@@ -1,13 +1,15 @@
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import conversation from 'widget/store/modules/conversation';
import appConfig from 'widget/store/modules/appConfig'; import appConfig from 'widget/store/modules/appConfig';
import contact from 'widget/store/modules/contact';
import conversation from 'widget/store/modules/conversation';
Vue.use(Vuex); Vue.use(Vuex);
export default new Vuex.Store({ export default new Vuex.Store({
modules: { modules: {
appConfig, appConfig,
contact,
conversation, conversation,
}, },
}); });

View File

@@ -0,0 +1,45 @@
import { updateContact } from 'widget/api/contact';
const state = {
uiFlags: {
isUpdating: false,
},
};
const getters = {
getUIFlags: $state => $state.uiFlags,
};
const actions = {
updateContactAttributes: async ({ commit }, { email, messageId }) => {
commit('toggleUpdateStatus', true);
try {
await updateContact({ email, messageId });
commit(
'conversation/updateMessage',
{
id: messageId,
content_attributes: { submitted_email: email },
},
{ root: true }
);
} catch (error) {
// Ignore error
}
commit('toggleUpdateStatus', false);
},
};
const mutations = {
toggleUpdateStatus($state, status) {
$state.uiFlags.isUpdating = status;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -135,6 +135,13 @@ export const mutations = {
payload.map(message => Vue.set($state.conversations, message.id, message)); payload.map(message => Vue.set($state.conversations, message.id, message));
}, },
updateMessage($state, { id, content_attributes }) {
$state.conversations[id] = {
...$state.conversations[id],
content_attributes,
};
},
}; };
export default { export default {

View File

@@ -17,8 +17,7 @@
# index_channel_facebook_pages_on_page_id_and_account_id (page_id,account_id) UNIQUE # index_channel_facebook_pages_on_page_id_and_account_id (page_id,account_id) UNIQUE
# #
module Channel class Channel::FacebookPage < ApplicationRecord
class FacebookPage < ApplicationRecord
include Avatarable include Avatarable
self.table_name = 'channel_facebook_pages' self.table_name = 'channel_facebook_pages'
@@ -44,4 +43,3 @@ module Channel
true true
end end
end end
end

View File

@@ -16,8 +16,7 @@
# index_channel_web_widgets_on_website_token (website_token) UNIQUE # index_channel_web_widgets_on_website_token (website_token) UNIQUE
# #
module Channel class Channel::WebWidget < ApplicationRecord
class WebWidget < ApplicationRecord
self.table_name = 'channel_web_widgets' self.table_name = 'channel_web_widgets'
validates :website_name, presence: true validates :website_name, presence: true
@@ -45,4 +44,3 @@ module Channel
end end
end end
end end
end

View File

@@ -29,4 +29,6 @@ class ContactInbox < ApplicationRecord
belongs_to :contact belongs_to :contact
belongs_to :inbox belongs_to :inbox
has_many :conversations, dependent: :destroy
end end

View File

@@ -13,6 +13,7 @@
# account_id :integer not null # account_id :integer not null
# assignee_id :integer # assignee_id :integer
# contact_id :bigint # contact_id :bigint
# contact_inbox_id :bigint
# display_id :integer not null # display_id :integer not null
# inbox_id :integer not null # inbox_id :integer not null
# #
@@ -20,6 +21,11 @@
# #
# index_conversations_on_account_id (account_id) # index_conversations_on_account_id (account_id)
# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE # index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE
# index_conversations_on_contact_inbox_id (contact_inbox_id)
#
# Foreign Keys
#
# fk_rails_... (contact_inbox_id => contact_inboxes.id)
# #
class Conversation < ApplicationRecord class Conversation < ApplicationRecord
@@ -38,6 +44,7 @@ class Conversation < ApplicationRecord
belongs_to :inbox belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true belongs_to :assignee, class_name: 'User', optional: true
belongs_to :contact belongs_to :contact
belongs_to :contact_inbox
has_many :messages, dependent: :destroy, autosave: true has_many :messages, dependent: :destroy, autosave: true

View File

@@ -49,6 +49,10 @@ class Inbox < ApplicationRecord
channel.class.name.to_s == 'Channel::FacebookPage' channel.class.name.to_s == 'Channel::FacebookPage'
end end
def web_widget?
channel.class.name.to_s == 'Channel::WebWidget'
end
def next_available_agent def next_available_agent
user_id = Redis::Alfred.rpoplpush(round_robin_key, round_robin_key) user_id = Redis::Alfred.rpoplpush(round_robin_key, round_robin_key)
account.users.find_by(id: user_id) account.users.find_by(id: user_id)

View File

@@ -4,6 +4,8 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# content :text # content :text
# content_attributes :json
# content_type :integer default("text")
# message_type :integer not null # message_type :integer not null
# private :boolean default(FALSE) # private :boolean default(FALSE)
# status :integer default("sent") # status :integer default("sent")
@@ -27,8 +29,10 @@ class Message < ApplicationRecord
validates :inbox_id, presence: true validates :inbox_id, presence: true
validates :conversation_id, presence: true validates :conversation_id, presence: true
enum message_type: [:incoming, :outgoing, :activity] enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
enum status: [:sent, :delivered, :read, :failed] enum content_type: { text: 0, input: 1, input_textarea: 2, input_email: 3 }
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
store :content_attributes, accessors: [:submitted_email], coder: JSON, prefix: :input
# .succ is a hack to avoid https://makandracards.com/makandra/1057-why-two-ruby-time-objects-are-not-equal-although-they-appear-to-be # .succ is a hack to avoid https://makandracards.com/makandra/1057-why-two-ruby-time-objects-are-not-equal-although-they-appear-to-be
scope :unread_since, ->(datetime) { where('EXTRACT(EPOCH FROM created_at) > (?)', datetime.to_i.succ) } scope :unread_since, ->(datetime) { where('EXTRACT(EPOCH FROM created_at) > (?)', datetime.to_i.succ) }
@@ -44,7 +48,8 @@ class Message < ApplicationRecord
after_create :reopen_conversation, after_create :reopen_conversation,
:dispatch_event, :dispatch_event,
:send_reply :send_reply,
:execute_message_template_hooks
def channel_token def channel_token
@token ||= inbox.channel.try(:page_access_token) @token ||= inbox.channel.try(:page_access_token)
@@ -81,4 +86,8 @@ class Message < ApplicationRecord
Rails.configuration.dispatcher.dispatch(CONVERSATION_REOPENED, Time.zone.now, conversation: conversation) Rails.configuration.dispatcher.dispatch(CONVERSATION_REOPENED, Time.zone.now, conversation: conversation)
end end
end end
def execute_message_template_hooks
::MessageTemplates::HookExecutionService.new(message: self).perform
end
end end

View File

@@ -1,5 +1,4 @@
module Conversations class Conversations::EventDataPresenter < SimpleDelegator
class EventDataPresenter < SimpleDelegator
def lock_data def lock_data
{ id: display_id, locked: locked? } { id: display_id, locked: locked? }
end end
@@ -34,4 +33,3 @@ module Conversations
} }
end end
end end
end

View File

View File

@@ -0,0 +1,20 @@
class MessageTemplates::HookExecutionService
pattr_initialize [:message!]
def perform
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if should_send_email_collect?
end
private
delegate :inbox, :conversation, to: :message
delegate :contact, to: :conversation
def first_message_from_contact?
conversation.messages.outgoing.count.zero? && conversation.messages.template.count.zero?
end
def should_send_email_collect?
conversation.inbox.web_widget? && first_message_from_contact?
end
end

View File

@@ -0,0 +1,56 @@
class MessageTemplates::Template::EmailCollect
pattr_initialize [:conversation!]
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(typical_reply_message_params)
conversation.messages.create!(ways_to_reach_you_message_params)
conversation.messages.create!(email_input_box_template_message_params)
end
rescue StandardError => e
Raven.capture_exception(e)
true
end
private
delegate :contact, :account, to: :conversation
delegate :inbox, to: :message
def typical_reply_message_params
content = I18n.t('conversations.templates.typical_reply_message_body',
account_name: account.name)
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content: content
}
end
def ways_to_reach_you_message_params
content = I18n.t('conversations.templates.ways_to_reach_you_message_body',
account_name: account.name)
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content: content
}
end
def email_input_box_template_message_params
content = I18n.t('conversations.templates.email_input_box_message_body',
account_name: account.name)
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content_type: :input_email,
content: content
}
end
end

View File

@@ -0,0 +1,21 @@
class Widget::TokenService
pattr_initialize [:payload, :token]
def generate_token
JWT.encode payload, secret_key, 'HS256'
end
def decode_token
JWT.decode(
token, secret_key, true, algorithm: 'HS256'
).first.symbolize_keys
rescue StandardError
{}
end
private
def secret_key
Rails.application.secrets.secret_key_base
end
end

View File

@@ -2,6 +2,8 @@ json.array! @messages do |message|
json.id message.id json.id message.id
json.content message.content json.content message.content
json.message_type message.message_type_before_type_cast json.message_type message.message_type_before_type_cast
json.content_type message.content_type
json.content_attributes message.content_attributes
json.created_at message.created_at.to_i json.created_at message.created_at.to_i
json.conversation_id message. conversation_id json.conversation_id message. conversation_id
json.attachment message.attachment.push_event_data if message.attachment json.attachment message.attachment.push_event_data if message.attachment

View File

@@ -29,7 +29,7 @@ Rails.application.configure do
config.cache_store = :null_store config.cache_store = :null_store
# Raise exceptions instead of rendering exception templates. # Raise exceptions instead of rendering exception templates.
config.action_dispatch.show_exceptions = false config.action_dispatch.show_exceptions = true
# Disable request forgery protection in test environment. # Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false config.action_controller.allow_forgery_protection = false

View File

@@ -50,3 +50,7 @@ en:
assignee: assignee:
assigned: "Assigned to %{assignee_name} by %{user_name}" assigned: "Assigned to %{assignee_name} by %{user_name}"
removed: "Conversation unassigned by %{user_name}" removed: "Conversation unassigned by %{user_name}"
templates:
typical_reply_message_body: "%{account_name} typically replies in a few hours."
ways_to_reach_you_message_body: "Give the team a way to reach you."
email_input_box_message_body: "Get notified by email"

View File

@@ -11,7 +11,7 @@ Rails.application.routes.draw do
match '/status', to: 'home#status', via: [:get] match '/status', to: 'home#status', via: [:get]
resources :widgets, only: [:index] resource :widget, only: [:show]
namespace :api, defaults: { format: 'json' } do namespace :api, defaults: { format: 'json' } do
namespace :v1 do namespace :v1 do
@@ -25,7 +25,7 @@ Rails.application.routes.draw do
end end
namespace :widget do namespace :widget do
resources :messages, only: [:index, :create] resources :messages, only: [:index, :create, :update]
resources :inboxes, only: [:create, :update] resources :inboxes, only: [:create, :update]
end end

View File

@@ -0,0 +1,6 @@
class AddTemplateTypeToMessages < ActiveRecord::Migration[6.0]
def change
add_column :messages, :content_type, :integer, default: '0'
add_column :messages, :content_attributes, :json, default: {}
end
end

View File

@@ -0,0 +1,14 @@
class AddContactInboxToConversation < ActiveRecord::Migration[6.0]
def change
add_reference(:conversations, :contact_inbox, foreign_key: true, index: true)
::Conversation.all.each do |conversation|
contact_inbox = ::ContactInbox.find_by(
contact_id: conversation.contact_id,
inbox_id: conversation.inbox_id
)
conversation.update!(contact_inbox_id: contact_inbox.id) if contact_inbox
end
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_12_27_191631) do ActiveRecord::Schema.define(version: 2020_01_07_164449) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -123,8 +123,10 @@ ActiveRecord::Schema.define(version: 2019_12_27_191631) do
t.datetime "agent_last_seen_at" t.datetime "agent_last_seen_at"
t.boolean "locked", default: false t.boolean "locked", default: false
t.jsonb "additional_attributes" t.jsonb "additional_attributes"
t.bigint "contact_inbox_id"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id"], name: "index_conversations_on_account_id" t.index ["account_id"], name: "index_conversations_on_account_id"
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"
end end
create_table "inbox_members", id: :serial, force: :cascade do |t| create_table "inbox_members", id: :serial, force: :cascade do |t|
@@ -157,6 +159,8 @@ ActiveRecord::Schema.define(version: 2019_12_27_191631) do
t.integer "user_id" t.integer "user_id"
t.integer "status", default: 0 t.integer "status", default: 0
t.string "fb_id" t.string "fb_id"
t.integer "content_type", default: 0
t.json "content_attributes", default: {}
t.index ["conversation_id"], name: "index_messages_on_conversation_id" t.index ["conversation_id"], name: "index_messages_on_conversation_id"
end end
@@ -186,11 +190,9 @@ ActiveRecord::Schema.define(version: 2019_12_27_191631) do
t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context" t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context"
t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy" t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy"
t.index ["taggable_id"], name: "index_taggings_on_taggable_id" t.index ["taggable_id"], name: "index_taggings_on_taggable_id"
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable_type_and_taggable_id"
t.index ["taggable_type"], name: "index_taggings_on_taggable_type" t.index ["taggable_type"], name: "index_taggings_on_taggable_type"
t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type" t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type"
t.index ["tagger_id"], name: "index_taggings_on_tagger_id" t.index ["tagger_id"], name: "index_taggings_on_tagger_id"
t.index ["tagger_type", "tagger_id"], name: "index_taggings_on_tagger_type_and_tagger_id"
end end
create_table "tags", id: :serial, force: :cascade do |t| create_table "tags", id: :serial, force: :cascade do |t|
@@ -243,5 +245,6 @@ ActiveRecord::Schema.define(version: 2019_12_27_191631) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "contact_inboxes", "contacts" add_foreign_key "contact_inboxes", "contacts"
add_foreign_key "contact_inboxes", "inboxes" add_foreign_key "contact_inboxes", "inboxes"
add_foreign_key "conversations", "contact_inboxes"
add_foreign_key "users", "users", column: "inviter_id", on_delete: :nullify add_foreign_key "users", "users", column: "inviter_id", on_delete: :nullify
end end

View File

@@ -10,6 +10,6 @@ inbox = Inbox.create!(channel: web_widget, account: account, name: 'Acme Support
InboxMember.create!(user: user, inbox: inbox) InboxMember.create!(user: user, inbox: inbox)
contact = Contact.create!(name: 'jane', email: 'jane@example.com', phone_number: '0000', account: account) contact = Contact.create!(name: 'jane', email: 'jane@example.com', phone_number: '0000', account: account)
ContactInbox.create!(inbox: inbox, contact: contact, source_id: user.id) contact_inbox = ContactInbox.create!(inbox: inbox, contact: contact, source_id: user.id)
conversation = Conversation.create!(account: account, inbox: inbox, status: :open, assignee: user, contact: contact) conversation = Conversation.create!(account: account, inbox: inbox, status: :open, assignee: user, contact: contact, contact_inbox: contact_inbox)
Message.create!(content: 'Hello', account: account, inbox: inbox, conversation: conversation, message_type: :incoming) Message.create!(content: 'Hello', account: account, inbox: inbox, conversation: conversation, message_type: :incoming)

View File

@@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module Integrations class Integrations::Facebook::DeliveryStatus
module Facebook
class DeliveryStatus
def initialize(params) def initialize(params)
@params = params @params = params
end end
@@ -30,5 +28,3 @@ module Integrations
conversation.save! conversation.save!
end end
end end
end
end

View File

@@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module Integrations class Integrations::Facebook::MessageCreator
module Facebook
class MessageCreator
attr_reader :response attr_reader :response
def initialize(response) def initialize(response)
@@ -43,5 +41,3 @@ module Integrations
end end
end end
end end
end
end

View File

@@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module Integrations class Integrations::Facebook::MessageParser
module Facebook
class MessageParser
def initialize(response_json) def initialize(response_json)
@response = response_json @response = response_json
end end
@@ -47,8 +45,6 @@ module Integrations
app_id && app_id == ENV['FB_APP_ID'].to_i app_id && app_id == ENV['FB_APP_ID'].to_i
end end
end end
end
end
# Sample Reponse # Sample Reponse
# { # {

View File

@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Integrations
module Widget
class Integrations::Widget::IncomingMessageBuilder class Integrations::Widget::IncomingMessageBuilder
# params = { # params = {
# contact_id: 1, # contact_id: 1,
@@ -57,5 +55,3 @@ module Integrations
} }
end end
end end
end
end

View File

@@ -9,9 +9,7 @@ describe ::ContactMergeAction do
before do before do
2.times.each { create(:conversation, contact: base_contact) } 2.times.each { create(:conversation, contact: base_contact) }
2.times.each { create(:contact_inbox, contact: base_contact) }
2.times.each { create(:conversation, contact: mergee_contact) } 2.times.each { create(:conversation, contact: mergee_contact) }
2.times.each { create(:contact_inbox, contact: mergee_contact) }
end end
describe '#perform' do describe '#perform' do

View File

@@ -1,6 +1,6 @@
require 'rails_helper' require 'rails_helper'
describe ::Messages::MessageBuilder do describe ::Messages::IncomingMessageBuilder do
subject(:message_builder) { described_class.new(incoming_fb_text_message, facebook_channel.inbox).perform } subject(:message_builder) { described_class.new(incoming_fb_text_message, facebook_channel.inbox).perform }
let!(:facebook_channel) { create(:channel_facebook_page) } let!(:facebook_channel) { create(:channel_facebook_page) }

View File

@@ -0,0 +1,84 @@
require 'rails_helper'
RSpec.describe '/api/v1/widget/messages', type: :request do
let(:account) { create(:account) }
let(:web_widget) { create(:channel_widget, account: account) }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
let(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) }
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
let(:token) { ::Widget::TokenService.new(payload: payload).generate_token }
before do
2.times.each { create(:message, account: account, inbox: web_widget.inbox, conversation: conversation) }
end
describe 'GET /api/v1/widget/messages' do
context 'when get request is made' do
it 'returns messages in conversation' do
get api_v1_widget_messages_url,
params: { website_token: web_widget.website_token },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
# 2 messages created + 3 messages by the template hook
expect(json_response.length).to eq(5)
end
end
end
describe 'POST /api/v1/widget/messages' do
context 'when post request is made' do
it 'creates message in conversation' do
message_params = { content: 'hello world' }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['content']).to eq(message_params[:content])
end
end
end
describe 'PUT /api/v1/widget/messages' do
context 'when put request is made with non existing email' do
it 'updates message in conversation and creates a new contact' do
message = create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
email = Faker::Internet.email
contact_params = { email: email }
put api_v1_widget_message_url(message.id),
params: { website_token: web_widget.website_token, contact: contact_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
message.reload
expect(message.input_submitted_email).to eq(email)
expect(message.conversation.contact.email).to eq(email)
end
end
context 'when put request is made with existing email' do
it 'updates message in conversation and deletes the current contact' do
message = create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
email = Faker::Internet.email
create(:contact, account: account, email: email)
contact_params = { email: email }
put api_v1_widget_message_url(message.id),
params: { website_token: web_widget.website_token, contact: contact_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
message.reload
expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end

View File

@@ -1,12 +1,14 @@
require 'rails_helper' require 'rails_helper'
describe WidgetTestsController, type: :controller do describe '/widget_tests', type: :request do
let(:channel_widget) { create(:channel_widget) } before do
create(:channel_widget)
end
describe '#index' do describe 'GET /widget_tests' do
it 'renders the page correctly' do it 'renders the page correctly' do
get :index get widget_tests_url
expect(response.status).to eq 200 expect(response).to be_successful
end end
end end
end end

View File

@@ -0,0 +1,17 @@
require 'rails_helper'
describe '/widget', type: :request do
let(:web_widget) { create(:channel_widget) }
describe 'GET /widget' do
it 'renders the page correctly when called with website_token' do
get widget_url(website_token: web_widget.website_token)
expect(response).to be_successful
end
it 'returns 404 when called with out website_token' do
get widget_url
expect(response.status).to eq(404)
end
end
end

View File

@@ -6,5 +6,8 @@ FactoryBot.define do
sequence(:website_url) { |n| "https://example-#{n}.com" } sequence(:website_url) { |n| "https://example-#{n}.com" }
sequence(:widget_color, &:to_s) sequence(:widget_color, &:to_s)
account account
after(:create) do |channel_widget|
create(:inbox, channel: channel_widget, account: channel_widget.account)
end
end end
end end

View File

@@ -16,6 +16,7 @@ FactoryBot.define do
channel: create(:channel_widget, account: conversation.account) channel: create(:channel_widget, account: conversation.account)
) )
conversation.contact ||= create(:contact, account: conversation.account) conversation.contact ||= create(:contact, account: conversation.account)
conversation.contact_inbox ||= create(:contact_inbox, contact: conversation.contact, inbox: conversation.inbox)
end end
end end
end end

View File

@@ -3,7 +3,11 @@
FactoryBot.define do FactoryBot.define do
factory :inbox do factory :inbox do
account account
name { 'Inbox' }
channel { FactoryBot.build(:channel_widget, account: account) } channel { FactoryBot.build(:channel_widget, account: account) }
name { 'Inbox' }
after(:create) do |inbox|
inbox.channel.save!
end
end end
end end

View File

@@ -5,9 +5,13 @@ FactoryBot.define do
content { 'Message' } content { 'Message' }
status { 'sent' } status { 'sent' }
message_type { 'incoming' } message_type { 'incoming' }
account content_type { 'text' }
inbox account { create(:account) }
conversation
user after(:build) do |message|
message.user ||= create(:user, account: message.account)
message.conversation ||= create(:conversation, account: message.account)
message.inbox ||= create(:inbox, account: message.account)
end
end end
end end

View File

@@ -21,7 +21,7 @@ describe ::MessageFinder do
it 'filter conversations by status' do it 'filter conversations by status' do
result = message_finder.perform result = message_finder.perform
expect(result.count).to be 4 expect(result.count).to be 7
end end
end end
@@ -30,7 +30,7 @@ describe ::MessageFinder do
it 'filter conversations by status' do it 'filter conversations by status' do
result = message_finder.perform result = message_finder.perform
expect(result.count).to be 2 expect(result.count).to be 5
end end
end end
@@ -40,7 +40,7 @@ describe ::MessageFinder do
it 'filter conversations by status' do it 'filter conversations by status' do
result = message_finder.perform result = message_finder.perform
expect(result.count).to be 4 expect(result.count).to be 7
end end
end end
end end

View File

@@ -57,19 +57,17 @@ RSpec.describe Conversation, type: :model do
expect(Rails.configuration.dispatcher).to have_received(:dispatch) expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation) .with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation)
# create_activity
expect(conversation.messages.pluck(:content)).to eq(
[
"Conversation was marked resolved by #{old_assignee.name}",
"Assigned to #{new_assignee.name} by #{old_assignee.name}"
]
)
# send_email_notification_to_assignee # send_email_notification_to_assignee
expect(AssignmentMailer).to have_received(:conversation_assigned).with(conversation, new_assignee) expect(AssignmentMailer).to have_received(:conversation_assigned).with(conversation, new_assignee)
expect(assignment_mailer).to have_received(:deliver_later) if ENV.fetch('SMTP_ADDRESS', nil).present? expect(assignment_mailer).to have_received(:deliver_later) if ENV.fetch('SMTP_ADDRESS', nil).present?
end end
it 'creates conversation activities' do
# create_activity
expect(conversation.messages.pluck(:content)).to include("Conversation was marked resolved by #{old_assignee.name}")
expect(conversation.messages.pluck(:content)).to include("Assigned to #{new_assignee.name} by #{old_assignee.name}")
end
end end
describe '.after_create' do describe '.after_create' do
@@ -169,7 +167,7 @@ RSpec.describe Conversation, type: :model do
end end
it 'returns unread messages' do it 'returns unread messages' do
expect(unread_messages).to contain_exactly(message) expect(unread_messages).to include(message)
end end
end end

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Message, type: :model do
context 'with validations' do
it { is_expected.to validate_presence_of(:inbox_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
it { is_expected.to validate_presence_of(:account_id) }
end
context 'when message is created' do
let(:message) { build(:message) }
it 'triggers ::MessageTemplates::HookExecutionService' do
hook_execution_service = double
allow(::MessageTemplates::HookExecutionService).to receive(:new).and_return(hook_execution_service)
allow(hook_execution_service).to receive(:perform).and_return(true)
message.save!
expect(::MessageTemplates::HookExecutionService).to have_received(:new).with(message: message)
expect(hook_execution_service).to have_received(:perform)
end
end
end

View File

@@ -14,7 +14,8 @@ describe Facebook::SendReplyService do
let!(:facebook_channel) { create(:facebook_page, account: account) } let!(:facebook_channel) { create(:facebook_page, account: account) }
let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) } let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) }
let!(:contact) { create(:contact, account: account) } let!(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, contact: contact, inbox: facebook_inbox) } let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: facebook_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: facebook_inbox, contact_inbox: contact_inbox) }
describe '#perform' do describe '#perform' do
context 'without reply' do context 'without reply' do
@@ -41,7 +42,6 @@ describe Facebook::SendReplyService do
context 'with reply' do context 'with reply' do
it 'if message is sent from chatwoot and is outgoing' do it 'if message is sent from chatwoot and is outgoing' do
create(:contact_inbox, contact: contact, inbox: facebook_inbox)
create(:message, message_type: :incoming, inbox: facebook_inbox, account: account, conversation: conversation) create(:message, message_type: :incoming, inbox: facebook_inbox, account: account, conversation: conversation)
create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation) create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
expect(bot).to have_received(:deliver) expect(bot).to have_received(:deliver)

View File

@@ -0,0 +1,20 @@
require 'rails_helper'
describe ::MessageTemplates::HookExecutionService do
context 'when it is a first message from web widget' do
it 'calls ::MessageTemplates::Template::EmailCollect' do
message = create(:message)
# this hook will only get executed for conversations with out any template messages
message.conversation.messages.template.destroy_all
email_collect_service = double
allow(::MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
allow(email_collect_service).to receive(:perform).and_return(true)
described_class.new(message: message).perform
expect(::MessageTemplates::Template::EmailCollect).to have_received(:new).with(conversation: message.conversation)
expect(email_collect_service).to have_received(:perform)
end
end
end

View File

@@ -0,0 +1,12 @@
require 'rails_helper'
describe ::MessageTemplates::Template::EmailCollect do
context 'when this hook is called' do
let(:conversation) { create(:conversation) }
it 'creates the email collect messages' do
described_class.new(conversation: conversation).perform
expect(conversation.messages.count).to eq(3)
end
end
end