mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	Feature: Website SDK (#653)
Add SDK functions Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
		
							
								
								
									
										47
									
								
								app/actions/contact_identify_action.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								app/actions/contact_identify_action.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| class ContactIdentifyAction | ||||
|   pattr_initialize [:contact!, :params!] | ||||
|  | ||||
|   def perform | ||||
|     ActiveRecord::Base.transaction do | ||||
|       @contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact) | ||||
|       @contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact) | ||||
|       update_contact | ||||
|     end | ||||
|     @contact | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def account | ||||
|     @account ||= @contact.account | ||||
|   end | ||||
|  | ||||
|   def existing_identified_contact | ||||
|     return if params[:identifier].blank? | ||||
|  | ||||
|     @existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier]) | ||||
|   end | ||||
|  | ||||
|   def existing_email_contact | ||||
|     return if params[:email].blank? | ||||
|  | ||||
|     @existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email]) | ||||
|   end | ||||
|  | ||||
|   def merge_contacts?(existing_contact, _contact) | ||||
|     existing_contact && existing_contact.id != @contact.id | ||||
|   end | ||||
|  | ||||
|   def update_contact | ||||
|     @contact.update!(params.slice(:name, :email, :identifier)) | ||||
|     ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? | ||||
|   end | ||||
|  | ||||
|   def merge_contact(base_contact, merge_contact) | ||||
|     ContactMergeAction.new( | ||||
|       account: account, | ||||
|       base_contact: base_contact, | ||||
|       mergee_contact: merge_contact | ||||
|     ).perform | ||||
|   end | ||||
| end | ||||
| @@ -9,6 +9,7 @@ class ContactMergeAction | ||||
|       merge_contact_inboxes | ||||
|       remove_mergee_contact | ||||
|     end | ||||
|     @base_contact | ||||
|   end | ||||
|  | ||||
|   private | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
| require 'open-uri' | ||||
|  | ||||
| # This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` | ||||
| # Assumptions | ||||
| # 1. Incase of an outgoing message which is echo, source_id will NOT be nil, | ||||
| @@ -36,9 +34,7 @@ class Messages::MessageBuilder | ||||
|     return if contact.present? | ||||
|  | ||||
|     @contact = Contact.create!(contact_params.except(: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) | ||||
|  | ||||
|     ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url] | ||||
|     @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) | ||||
|   end | ||||
|  | ||||
|   | ||||
							
								
								
									
										18
									
								
								app/controllers/api/v1/widget/contacts_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/controllers/api/v1/widget/contacts_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController | ||||
|   before_action :set_web_widget | ||||
|   before_action :set_contact | ||||
|  | ||||
|   def update | ||||
|     contact_identify_action = ContactIdentifyAction.new( | ||||
|       contact: @contact, | ||||
|       params: permitted_params.to_h.deep_symbolize_keys | ||||
|     ) | ||||
|     render json: contact_identify_action.perform | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def permitted_params | ||||
|     params.permit(:website_token, :identifier, :email, :name, :avatar_url) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										24
									
								
								app/controllers/api/v1/widget/labels_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								app/controllers/api/v1/widget/labels_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| class Api::V1::Widget::LabelsController < Api::V1::Widget::BaseController | ||||
|   before_action :set_web_widget | ||||
|   before_action :set_contact | ||||
|  | ||||
|   def create | ||||
|     conversation.label_list.add(permitted_params[:label]) | ||||
|     conversation.save! | ||||
|  | ||||
|     head :no_content | ||||
|   end | ||||
|  | ||||
|   def destroy | ||||
|     conversation.label_list.remove(permitted_params[:id]) | ||||
|     conversation.save! | ||||
|  | ||||
|     head :no_content | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def permitted_params | ||||
|     params.permit(:id, :label, :website_token) | ||||
|   end | ||||
| end | ||||
| @@ -17,7 +17,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController | ||||
|   def update | ||||
|     @message.update!(input_submitted_email: contact_email) | ||||
|     update_contact(contact_email) | ||||
|     head :no_content | ||||
|   rescue StandardError => e | ||||
|     render json: { error: @contact.errors, message: e.message }.to_json, status: 500 | ||||
|   end | ||||
| @@ -96,7 +95,11 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController | ||||
|   def update_contact(email) | ||||
|     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 | ||||
|       @contact = ::ContactMergeAction.new( | ||||
|         account: @account, | ||||
|         base_contact: contact_with_email, | ||||
|         mergee_contact: @contact | ||||
|       ).perform | ||||
|     else | ||||
|       @contact.update!( | ||||
|         email: email, | ||||
|   | ||||
| @@ -1,233 +1,62 @@ | ||||
| import Cookies from 'js-cookie'; | ||||
| import { IFrameHelper } from '../sdk/IFrameHelper'; | ||||
| import { onBubbleClick } from '../sdk/bubbleHelpers'; | ||||
|  | ||||
| import { SDK_CSS } from '../widget/assets/scss/sdk'; | ||||
| /* eslint-disable no-param-reassign */ | ||||
| const bubbleImg = | ||||
|   'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg=='; | ||||
| const runSDK = ({ baseUrl, websiteToken }) => { | ||||
|   const chatwootSettings = window.chatwootSettings || {}; | ||||
|   window.$chatwoot = { | ||||
|     baseUrl, | ||||
|     hasLoaded: false, | ||||
|     hideMessageBubble: chatwootSettings.hideMessageBubble || false, | ||||
|     isOpen: false, | ||||
|     position: chatwootSettings.position || 'right', | ||||
|     websiteToken, | ||||
|  | ||||
| const body = document.getElementsByTagName('body')[0]; | ||||
| const holder = document.createElement('div'); | ||||
|  | ||||
| const bubbleHolder = document.createElement('div'); | ||||
| const chatBubble = document.createElement('div'); | ||||
| const closeBubble = document.createElement('div'); | ||||
|  | ||||
| const notification_bubble = document.createElement('span'); | ||||
| const bodyOverFlowStyle = document.body.style.overflow; | ||||
|  | ||||
| function loadCSS() { | ||||
|   const css = document.createElement('style'); | ||||
|   css.type = 'text/css'; | ||||
|   css.innerHTML = `${SDK_CSS}`; | ||||
|   document.body.appendChild(css); | ||||
| } | ||||
|  | ||||
| function wootOn(elm, event, fn) { | ||||
|   if (document.addEventListener) { | ||||
|     elm.addEventListener(event, fn, false); | ||||
|   } else if (document.attachEvent) { | ||||
|     // <= IE 8 loses scope so need to apply, we add this to object so we | ||||
|     // can detach later (can't detach anonymous functions) | ||||
|     // eslint-disable-next-line | ||||
|     elm[event + fn] = function() { | ||||
|       // eslint-disable-next-line | ||||
|       return fn.apply(elm, arguments); | ||||
|     }; | ||||
|     elm.attachEvent(`on${event}`, elm[event + fn]); | ||||
|   } | ||||
| } | ||||
|  | ||||
| function classHelper(classes, action, elm) { | ||||
|   let search; | ||||
|   let replace; | ||||
|   let i; | ||||
|   let has = false; | ||||
|   if (classes) { | ||||
|     // Trim any whitespace | ||||
|     const classarray = classes.split(/\s+/); | ||||
|     for (i = 0; i < classarray.length; i += 1) { | ||||
|       search = new RegExp(`\\b${classarray[i]}\\b`, 'g'); | ||||
|       replace = new RegExp(` *${classarray[i]}\\b`, 'g'); | ||||
|       if (action === 'remove') { | ||||
|         // eslint-disable-next-line | ||||
|         elm.className = elm.className.replace(replace, ''); | ||||
|       } else if (action === 'toggle') { | ||||
|         // eslint-disable-next-line | ||||
|         elm.className = elm.className.match(search) | ||||
|           ? elm.className.replace(replace, '') | ||||
|           : `${elm.className} ${classarray[i]}`; | ||||
|       } else if (action === 'has') { | ||||
|         if (elm.className.match(search)) { | ||||
|           has = true; | ||||
|           break; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   return has; | ||||
| } | ||||
|  | ||||
| function addClass(elm, classes) { | ||||
|   if (classes) { | ||||
|     elm.className += ` ${classes}`; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Toggle class | ||||
| function toggleClass(elm, classes) { | ||||
|   classHelper(classes, 'toggle', elm); | ||||
| } | ||||
|  | ||||
| const createBubbleIcon = ({ className, src, target }) => { | ||||
|   target.className = className; | ||||
|   const bubbleIcon = document.createElement('img'); | ||||
|   bubbleIcon.src = src; | ||||
|   target.appendChild(bubbleIcon); | ||||
|   return target; | ||||
| }; | ||||
|  | ||||
| function createBubbleHolder() { | ||||
|   addClass(bubbleHolder, 'woot--bubble-holder'); | ||||
|   body.appendChild(bubbleHolder); | ||||
| } | ||||
|  | ||||
| function createNotificationBubble() { | ||||
|   addClass(notification_bubble, 'woot--notification'); | ||||
|   return notification_bubble; | ||||
| } | ||||
|  | ||||
| function bubbleClickCallback() { | ||||
|   toggleClass(chatBubble, 'woot--hide'); | ||||
|   toggleClass(closeBubble, 'woot--hide'); | ||||
|   toggleClass(holder, 'woot--hide'); | ||||
| } | ||||
|  | ||||
| function onClickChatBubble() { | ||||
|   wootOn(bubbleHolder, 'click', bubbleClickCallback); | ||||
| } | ||||
|  | ||||
| function disableScroll() { | ||||
|   document.body.style.overflow = 'hidden'; | ||||
| } | ||||
|  | ||||
| function enableScroll() { | ||||
|   document.body.style.overflow = bodyOverFlowStyle; | ||||
| } | ||||
|  | ||||
| const IFrameHelper = { | ||||
|   createFrame: ({ baseUrl, websiteToken }) => { | ||||
|     const iframe = document.createElement('iframe'); | ||||
|     const cwCookie = Cookies.get('cw_conversation'); | ||||
|     let widgetUrl = `${baseUrl}/widget?website_token=${websiteToken}`; | ||||
|     if (cwCookie) { | ||||
|       widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`; | ||||
|     } | ||||
|     iframe.src = widgetUrl; | ||||
|  | ||||
|     iframe.id = 'chatwoot_live_chat_widget'; | ||||
|     iframe.style.visibility = 'hidden'; | ||||
|     holder.className = 'woot-widget-holder woot--hide'; | ||||
|     holder.appendChild(iframe); | ||||
|     body.appendChild(holder); | ||||
|     IFrameHelper.initPostMessageCommunication(); | ||||
|     IFrameHelper.initLocationListener(); | ||||
|     IFrameHelper.initWindowSizeListener(); | ||||
|     toggle() { | ||||
|       onBubbleClick(); | ||||
|     }, | ||||
|   getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'), | ||||
|   sendMessage: (key, value) => { | ||||
|     const element = IFrameHelper.getAppFrame(); | ||||
|     element.contentWindow.postMessage( | ||||
|       `chatwoot-widget:${JSON.stringify({ event: key, ...value })}`, | ||||
|       '*' | ||||
|     ); | ||||
|   }, | ||||
|   events: { | ||||
|     loaded: message => { | ||||
|       Cookies.set('cw_conversation', message.config.authToken); | ||||
|       IFrameHelper.sendMessage('config-set', {}); | ||||
|       IFrameHelper.onLoad(message.config.channelConfig); | ||||
|       IFrameHelper.setCurrentUrl(); | ||||
|       IFrameHelper.toggleCloseButton(); | ||||
|     }, | ||||
|     set_auth_token: message => { | ||||
|       Cookies.set('cw_conversation', message.authToken); | ||||
|     }, | ||||
|     toggleBubble: () => { | ||||
|       bubbleClickCallback(); | ||||
|     }, | ||||
|   }, | ||||
|   initPostMessageCommunication: () => { | ||||
|     window.onmessage = e => { | ||||
|       if ( | ||||
|         typeof e.data !== 'string' || | ||||
|         e.data.indexOf('chatwoot-widget:') !== 0 | ||||
|       ) { | ||||
|         return; | ||||
|       } | ||||
|       const message = JSON.parse(e.data.replace('chatwoot-widget:', '')); | ||||
|       if (typeof IFrameHelper.events[message.event] === 'function') { | ||||
|         IFrameHelper.events[message.event](message); | ||||
|       } | ||||
|     }; | ||||
|   }, | ||||
|   initLocationListener: () => { | ||||
|     window.onhashchange = () => { | ||||
|       IFrameHelper.setCurrentUrl(); | ||||
|     }; | ||||
|   }, | ||||
|   initWindowSizeListener: () => { | ||||
|     wootOn(window, 'resize', () => { | ||||
|       IFrameHelper.toggleCloseButton(); | ||||
|  | ||||
|     setUser(identifier, user) { | ||||
|       if (typeof identifier === 'string' || typeof identifier === 'number') { | ||||
|         window.$chatwoot.identifier = identifier; | ||||
|         window.$chatwoot.user = user || {}; | ||||
|         IFrameHelper.sendMessage('set-user', { | ||||
|           identifier, | ||||
|           user: window.$chatwoot.user, | ||||
|         }); | ||||
|   }, | ||||
|   onLoad: ({ widget_color: widgetColor }) => { | ||||
|     const iframe = IFrameHelper.getAppFrame(); | ||||
|     iframe.style.visibility = ''; | ||||
|     iframe.setAttribute('id', `chatwoot_live_chat_widget`); | ||||
|     iframe.onmouseenter = disableScroll; | ||||
|     iframe.onmouseleave = enableScroll; | ||||
|  | ||||
|     loadCSS(); | ||||
|     createBubbleHolder(); | ||||
|  | ||||
|     const chatIcon = createBubbleIcon({ | ||||
|       className: 'woot-widget-bubble', | ||||
|       src: bubbleImg, | ||||
|       target: chatBubble, | ||||
|     }); | ||||
|  | ||||
|     const closeIcon = closeBubble; | ||||
|     closeIcon.className = 'woot-widget-bubble woot--close woot--hide'; | ||||
|  | ||||
|     chatIcon.style.background = widgetColor; | ||||
|     closeIcon.style.background = widgetColor; | ||||
|  | ||||
|     bubbleHolder.appendChild(chatIcon); | ||||
|     bubbleHolder.appendChild(closeIcon); | ||||
|     bubbleHolder.appendChild(createNotificationBubble()); | ||||
|     onClickChatBubble(); | ||||
|   }, | ||||
|   setCurrentUrl: () => { | ||||
|     IFrameHelper.sendMessage('set-current-url', { | ||||
|       refererURL: window.location.href, | ||||
|     }); | ||||
|   }, | ||||
|   toggleCloseButton: () => { | ||||
|     if (window.matchMedia('(max-width: 668px)').matches) { | ||||
|       IFrameHelper.sendMessage('toggle-close-button', { showClose: true }); | ||||
|       } else { | ||||
|       IFrameHelper.sendMessage('toggle-close-button', { showClose: false }); | ||||
|         throw new Error('Identifier should be a string or a number'); | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     setLabel(label = '') { | ||||
|       IFrameHelper.sendMessage('set-label', { label }); | ||||
|     }, | ||||
|  | ||||
|     removeLabel(label = '') { | ||||
|       IFrameHelper.sendMessage('remove-label', { label }); | ||||
|     }, | ||||
|  | ||||
|     reset() { | ||||
|       if (window.$chatwoot.isOpen) { | ||||
|         onBubbleClick(); | ||||
|       } | ||||
|  | ||||
|       Cookies.remove('cw_conversation'); | ||||
|       const iframe = IFrameHelper.getAppFrame(); | ||||
|       iframe.src = IFrameHelper.getUrl({ | ||||
|         baseUrl: window.$chatwoot.baseUrl, | ||||
|         websiteToken: window.$chatwoot.websiteToken, | ||||
|       }); | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
| function loadIframe({ baseUrl, websiteToken }) { | ||||
|   IFrameHelper.createFrame({ | ||||
|     baseUrl, | ||||
|     websiteToken, | ||||
|   }); | ||||
| } | ||||
| }; | ||||
|  | ||||
| window.chatwootSDK = { | ||||
|   run: loadIframe, | ||||
|   run: runSDK, | ||||
| }; | ||||
|   | ||||
							
								
								
									
										63
									
								
								app/javascript/sdk/DOMHelpers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								app/javascript/sdk/DOMHelpers.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| import { SDK_CSS } from '../widget/assets/scss/sdk'; | ||||
|  | ||||
| export const loadCSS = () => { | ||||
|   const css = document.createElement('style'); | ||||
|   css.type = 'text/css'; | ||||
|   css.innerHTML = `${SDK_CSS}`; | ||||
|   document.body.appendChild(css); | ||||
| }; | ||||
|  | ||||
| export const wootOn = (elm, event, fn) => { | ||||
|   if (document.addEventListener) { | ||||
|     elm.addEventListener(event, fn, false); | ||||
|   } else if (document.attachEvent) { | ||||
|     // <= IE 8 loses scope so need to apply, we add this to object so we | ||||
|     // can detach later (can't detach anonymous functions) | ||||
|     // eslint-disable-next-line | ||||
|     elm[event + fn] = function() { | ||||
|       // eslint-disable-next-line | ||||
|       return fn.apply(elm, arguments); | ||||
|     }; | ||||
|     elm.attachEvent(`on${event}`, elm[event + fn]); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export const classHelper = (classes, action, elm) => { | ||||
|   let search; | ||||
|   let replace; | ||||
|   let i; | ||||
|   let has = false; | ||||
|   if (classes) { | ||||
|     // Trim any whitespace | ||||
|     const classarray = classes.split(/\s+/); | ||||
|     for (i = 0; i < classarray.length; i += 1) { | ||||
|       search = new RegExp(`\\b${classarray[i]}\\b`, 'g'); | ||||
|       replace = new RegExp(` *${classarray[i]}\\b`, 'g'); | ||||
|       if (action === 'remove') { | ||||
|         // eslint-disable-next-line | ||||
|         elm.className = elm.className.replace(replace, ''); | ||||
|       } else if (action === 'toggle') { | ||||
|         // eslint-disable-next-line | ||||
|         elm.className = elm.className.match(search) | ||||
|           ? elm.className.replace(replace, '') | ||||
|           : `${elm.className} ${classarray[i]}`; | ||||
|       } else if (action === 'has') { | ||||
|         if (elm.className.match(search)) { | ||||
|           has = true; | ||||
|           break; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   return has; | ||||
| }; | ||||
|  | ||||
| export const addClass = (elm, classes) => { | ||||
|   if (classes) { | ||||
|     elm.className += ` ${classes}`; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export const toggleClass = (elm, classes) => { | ||||
|   classHelper(classes, 'toggle', elm); | ||||
| }; | ||||
							
								
								
									
										134
									
								
								app/javascript/sdk/IFrameHelper.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								app/javascript/sdk/IFrameHelper.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,134 @@ | ||||
| import Cookies from 'js-cookie'; | ||||
| import { wootOn, loadCSS } from './DOMHelpers'; | ||||
| import { | ||||
|   body, | ||||
|   widgetHolder, | ||||
|   createBubbleHolder, | ||||
|   disableScroll, | ||||
|   enableScroll, | ||||
|   createBubbleIcon, | ||||
|   bubbleImg, | ||||
|   chatBubble, | ||||
|   closeBubble, | ||||
|   bubbleHolder, | ||||
|   createNotificationBubble, | ||||
|   onClickChatBubble, | ||||
|   onBubbleClick, | ||||
| } from './bubbleHelpers'; | ||||
|  | ||||
| export const IFrameHelper = { | ||||
|   getUrl({ baseUrl, websiteToken }) { | ||||
|     return `${baseUrl}/widget?website_token=${websiteToken}`; | ||||
|   }, | ||||
|   createFrame: ({ baseUrl, websiteToken }) => { | ||||
|     const iframe = document.createElement('iframe'); | ||||
|     const cwCookie = Cookies.get('cw_conversation'); | ||||
|     let widgetUrl = IFrameHelper.getUrl({ baseUrl, websiteToken }); | ||||
|     if (cwCookie) { | ||||
|       widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`; | ||||
|     } | ||||
|     iframe.src = widgetUrl; | ||||
|  | ||||
|     iframe.id = 'chatwoot_live_chat_widget'; | ||||
|     iframe.style.visibility = 'hidden'; | ||||
|     widgetHolder.className = 'woot-widget-holder woot--hide'; | ||||
|     widgetHolder.appendChild(iframe); | ||||
|     body.appendChild(widgetHolder); | ||||
|     IFrameHelper.initPostMessageCommunication(); | ||||
|     IFrameHelper.initLocationListener(); | ||||
|     IFrameHelper.initWindowSizeListener(); | ||||
|   }, | ||||
|   getAppFrame: () => document.getElementById('chatwoot_live_chat_widget'), | ||||
|   sendMessage: (key, value) => { | ||||
|     const element = IFrameHelper.getAppFrame(); | ||||
|     element.contentWindow.postMessage( | ||||
|       `chatwoot-widget:${JSON.stringify({ event: key, ...value })}`, | ||||
|       '*' | ||||
|     ); | ||||
|   }, | ||||
|   initLocationListener: () => { | ||||
|     window.onhashchange = () => { | ||||
|       IFrameHelper.setCurrentUrl(); | ||||
|     }; | ||||
|   }, | ||||
|   initPostMessageCommunication: () => { | ||||
|     window.onmessage = e => { | ||||
|       if ( | ||||
|         typeof e.data !== 'string' || | ||||
|         e.data.indexOf('chatwoot-widget:') !== 0 | ||||
|       ) { | ||||
|         return; | ||||
|       } | ||||
|       const message = JSON.parse(e.data.replace('chatwoot-widget:', '')); | ||||
|       if (typeof IFrameHelper.events[message.event] === 'function') { | ||||
|         IFrameHelper.events[message.event](message); | ||||
|       } | ||||
|     }; | ||||
|   }, | ||||
|   initWindowSizeListener: () => { | ||||
|     wootOn(window, 'resize', () => { | ||||
|       IFrameHelper.toggleCloseButton(); | ||||
|     }); | ||||
|   }, | ||||
|   events: { | ||||
|     loaded: message => { | ||||
|       Cookies.set('cw_conversation', message.config.authToken, { | ||||
|         expires: 365, | ||||
|       }); | ||||
|       window.$chatwoot.hasLoaded = true; | ||||
|       IFrameHelper.sendMessage('config-set', {}); | ||||
|       IFrameHelper.onLoad(message.config.channelConfig); | ||||
|       IFrameHelper.setCurrentUrl(); | ||||
|       IFrameHelper.toggleCloseButton(); | ||||
|  | ||||
|       if (window.$chatwoot.user) { | ||||
|         IFrameHelper.sendMessage('set-user', window.$chatwoot.user); | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     toggleBubble: () => { | ||||
|       onBubbleClick(); | ||||
|     }, | ||||
|   }, | ||||
|   onLoad: ({ widget_color: widgetColor }) => { | ||||
|     const iframe = IFrameHelper.getAppFrame(); | ||||
|     iframe.style.visibility = ''; | ||||
|     iframe.setAttribute('id', `chatwoot_live_chat_widget`); | ||||
|     iframe.onmouseenter = disableScroll; | ||||
|     iframe.onmouseleave = enableScroll; | ||||
|  | ||||
|     loadCSS(); | ||||
|     createBubbleHolder(); | ||||
|  | ||||
|     if (!window.$chatwoot.hideMessageBubble) { | ||||
|       const chatIcon = createBubbleIcon({ | ||||
|         className: 'woot-widget-bubble', | ||||
|         src: bubbleImg, | ||||
|         target: chatBubble, | ||||
|       }); | ||||
|  | ||||
|       const closeIcon = closeBubble; | ||||
|       closeIcon.className = 'woot-widget-bubble woot--close woot--hide'; | ||||
|  | ||||
|       chatIcon.style.background = widgetColor; | ||||
|       closeIcon.style.background = widgetColor; | ||||
|  | ||||
|       bubbleHolder.appendChild(chatIcon); | ||||
|       bubbleHolder.appendChild(closeIcon); | ||||
|       bubbleHolder.appendChild(createNotificationBubble()); | ||||
|       onClickChatBubble(); | ||||
|     } | ||||
|   }, | ||||
|   setCurrentUrl: () => { | ||||
|     IFrameHelper.sendMessage('set-current-url', { | ||||
|       refererURL: window.location.href, | ||||
|     }); | ||||
|   }, | ||||
|   toggleCloseButton: () => { | ||||
|     if (window.matchMedia('(max-width: 668px)').matches) { | ||||
|       IFrameHelper.sendMessage('toggle-close-button', { showClose: true }); | ||||
|     } else { | ||||
|       IFrameHelper.sendMessage('toggle-close-button', { showClose: false }); | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										51
									
								
								app/javascript/sdk/bubbleHelpers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								app/javascript/sdk/bubbleHelpers.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| import { addClass, toggleClass, wootOn } from './DOMHelpers'; | ||||
|  | ||||
| export const bubbleImg = | ||||
|   'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg=='; | ||||
|  | ||||
| export const body = document.getElementsByTagName('body')[0]; | ||||
| export const widgetHolder = document.createElement('div'); | ||||
|  | ||||
| export const bubbleHolder = document.createElement('div'); | ||||
| export const chatBubble = document.createElement('div'); | ||||
| export const closeBubble = document.createElement('div'); | ||||
|  | ||||
| export const notificationBubble = document.createElement('span'); | ||||
| const bodyOverFlowStyle = document.body.style.overflow; | ||||
|  | ||||
| export const createBubbleIcon = ({ className, src, target }) => { | ||||
|   target.className = className; | ||||
|   const bubbleIcon = document.createElement('img'); | ||||
|   bubbleIcon.src = src; | ||||
|   target.appendChild(bubbleIcon); | ||||
|   return target; | ||||
| }; | ||||
|  | ||||
| export const createBubbleHolder = () => { | ||||
|   addClass(bubbleHolder, 'woot--bubble-holder'); | ||||
|   body.appendChild(bubbleHolder); | ||||
| }; | ||||
|  | ||||
| export const createNotificationBubble = () => { | ||||
|   addClass(notificationBubble, 'woot--notification'); | ||||
|   return notificationBubble; | ||||
| }; | ||||
|  | ||||
| export const onBubbleClick = () => { | ||||
|   window.$chatwoot.isOpen = !window.$chatwoot.isOpen; | ||||
|   toggleClass(chatBubble, 'woot--hide'); | ||||
|   toggleClass(closeBubble, 'woot--hide'); | ||||
|   toggleClass(widgetHolder, 'woot--hide'); | ||||
| }; | ||||
|  | ||||
| export const onClickChatBubble = () => { | ||||
|   wootOn(bubbleHolder, 'click', onBubbleClick); | ||||
| }; | ||||
|  | ||||
| export const disableScroll = () => { | ||||
|   document.body.style.overflow = 'hidden'; | ||||
| }; | ||||
|  | ||||
| export const enableScroll = () => { | ||||
|   document.body.style.overflow = bodyOverFlowStyle; | ||||
| }; | ||||
| @@ -2,8 +2,8 @@ import { createConsumer } from '@rails/actioncable'; | ||||
|  | ||||
| class BaseActionCableConnector { | ||||
|   constructor(app, pubsubToken) { | ||||
|     const consumer = createConsumer(); | ||||
|     consumer.subscriptions.create( | ||||
|     this.consumer = createConsumer(); | ||||
|     this.consumer.subscriptions.create( | ||||
|       { | ||||
|         channel: 'RoomChannel', | ||||
|         pubsub_token: pubsubToken, | ||||
| @@ -16,6 +16,10 @@ class BaseActionCableConnector { | ||||
|     this.events = {}; | ||||
|   } | ||||
|  | ||||
|   disconnect() { | ||||
|     this.consumer.disconnect(); | ||||
|   } | ||||
|  | ||||
|   onReceived = ({ event, data } = {}) => { | ||||
|     if (this.events[event] && typeof this.events[event] === 'function') { | ||||
|       this.events[event](data); | ||||
|   | ||||
| @@ -47,6 +47,12 @@ export default { | ||||
|         window.refererURL = message.refererURL; | ||||
|       } else if (message.event === 'toggle-close-button') { | ||||
|         this.isMobile = message.showClose; | ||||
|       } else if (message.event === 'set-label') { | ||||
|         this.$store.dispatch('conversationLabels/create', message.label); | ||||
|       } else if (message.event === 'remove-label') { | ||||
|         this.$store.dispatch('conversationLabels/destroy', message.label); | ||||
|       } else if (message.event === 'set-user') { | ||||
|         this.$store.dispatch('contacts/update', message); | ||||
|       } | ||||
|     }); | ||||
|   }, | ||||
|   | ||||
| @@ -1,10 +0,0 @@ | ||||
| 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; | ||||
| }; | ||||
							
								
								
									
										12
									
								
								app/javascript/widget/api/contacts.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/javascript/widget/api/contacts.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { API } from 'widget/helpers/axios'; | ||||
|  | ||||
| const buildUrl = endPoint => `/api/v1/${endPoint}${window.location.search}`; | ||||
|  | ||||
| export default { | ||||
|   update(identifier, userObject) { | ||||
|     return API.patch(buildUrl('widget/contact'), { | ||||
|       identifier, | ||||
|       ...userObject, | ||||
|     }); | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										12
									
								
								app/javascript/widget/api/conversationLabels.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/javascript/widget/api/conversationLabels.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { API } from 'widget/helpers/axios'; | ||||
|  | ||||
| const buildUrl = endPoint => `/api/v1/${endPoint}${window.location.search}`; | ||||
|  | ||||
| export default { | ||||
|   create(label) { | ||||
|     return API.post(buildUrl('widget/labels'), { label }); | ||||
|   }, | ||||
|   destroy(label) { | ||||
|     return API.delete(buildUrl(`widget/labels/${label}`)); | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										11
									
								
								app/javascript/widget/api/message.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										11
									
								
								app/javascript/widget/api/message.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import authEndPoint from 'widget/api/endPoints'; | ||||
| import { API } from 'widget/helpers/axios'; | ||||
|  | ||||
| export default { | ||||
|   update: ({ messageId, email }) => { | ||||
|     const urlData = authEndPoint.updateContact(messageId); | ||||
|     return API.patch(urlData.url, { | ||||
|       contact: { email }, | ||||
|     }); | ||||
|   }, | ||||
| }; | ||||
| @@ -53,7 +53,7 @@ export default { | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters({ | ||||
|       uiFlags: 'contact/getUIFlags', | ||||
|       uiFlags: 'message/getUIFlags', | ||||
|       widgetColor: 'appConfig/getWidgetColor', | ||||
|     }), | ||||
|     hasSubmitted() { | ||||
| @@ -71,7 +71,7 @@ export default { | ||||
|   }, | ||||
|   methods: { | ||||
|     onSubmit() { | ||||
|       this.$store.dispatch('contact/updateContactAttributes', { | ||||
|       this.$store.dispatch('message/updateContactAttributes', { | ||||
|         email: this.email, | ||||
|         messageId: this.messageId, | ||||
|       }); | ||||
|   | ||||
| @@ -13,4 +13,13 @@ class ActionCableConnector extends BaseActionCableConnector { | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export const refreshActionCableConnector = pubsubToken => { | ||||
|   window.chatwootPubsubToken = pubsubToken; | ||||
|   window.actionCable.disconnect(); | ||||
|   window.actionCable = new ActionCableConnector( | ||||
|     window.WOOT_WIDGET, | ||||
|     window.chatwootPubsubToken | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default ActionCableConnector; | ||||
|   | ||||
| @@ -1,17 +1,21 @@ | ||||
| import Vue from 'vue'; | ||||
| import Vuex from 'vuex'; | ||||
| import appConfig from 'widget/store/modules/appConfig'; | ||||
| import contact from 'widget/store/modules/contact'; | ||||
| import conversation from 'widget/store/modules/conversation'; | ||||
| import agent from 'widget/store/modules/agent'; | ||||
| import appConfig from 'widget/store/modules/appConfig'; | ||||
| import contacts from 'widget/store/modules/contacts'; | ||||
| import conversation from 'widget/store/modules/conversation'; | ||||
| import conversationLabels from 'widget/store/modules/conversationLabels'; | ||||
| import message from 'widget/store/modules/message'; | ||||
|  | ||||
| Vue.use(Vuex); | ||||
|  | ||||
| export default new Vuex.Store({ | ||||
|   modules: { | ||||
|     appConfig, | ||||
|     contact, | ||||
|     conversation, | ||||
|     agent, | ||||
|     appConfig, | ||||
|     message, | ||||
|     contacts, | ||||
|     conversation, | ||||
|     conversationLabels, | ||||
|   }, | ||||
| }); | ||||
|   | ||||
							
								
								
									
										28
									
								
								app/javascript/widget/store/modules/contacts.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/javascript/widget/store/modules/contacts.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import ContactsAPI from '../../api/contacts'; | ||||
| import { refreshActionCableConnector } from '../../helpers/actionCable'; | ||||
|  | ||||
| export const actions = { | ||||
|   update: async (_, { identifier, user: userObject }) => { | ||||
|     try { | ||||
|       const user = { | ||||
|         email: userObject.email, | ||||
|         name: userObject.name, | ||||
|         avatar_url: userObject.avatar_url, | ||||
|       }; | ||||
|       const { | ||||
|         data: { pubsub_token: pubsubToken }, | ||||
|       } = await ContactsAPI.update(identifier, user); | ||||
|       refreshActionCableConnector(pubsubToken); | ||||
|     } catch (error) { | ||||
|       // Ingore error | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state: {}, | ||||
|   getters: {}, | ||||
|   actions, | ||||
|   mutations: {}, | ||||
| }; | ||||
							
								
								
									
										32
									
								
								app/javascript/widget/store/modules/conversationLabels.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								app/javascript/widget/store/modules/conversationLabels.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| import conversationLabels from '../../api/conversationLabels'; | ||||
|  | ||||
| const state = {}; | ||||
|  | ||||
| export const getters = {}; | ||||
|  | ||||
| export const actions = { | ||||
|   create: async (_, label) => { | ||||
|     try { | ||||
|       await conversationLabels.create(label); | ||||
|     } catch (error) { | ||||
|       // Ingore error | ||||
|     } | ||||
|   }, | ||||
|   destroy: async (_, label) => { | ||||
|     try { | ||||
|       await conversationLabels.destroy(label); | ||||
|     } catch (error) { | ||||
|       // Ingore error | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export const mutations = {}; | ||||
|  | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state, | ||||
|   getters, | ||||
|   actions, | ||||
|   mutations, | ||||
| }; | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { updateContact } from 'widget/api/contact'; | ||||
| import MessageAPI from 'widget/api/message'; | ||||
| import { refreshActionCableConnector } from '../../helpers/actionCable'; | ||||
| 
 | ||||
| const state = { | ||||
|   uiFlags: { | ||||
| @@ -14,7 +15,11 @@ const actions = { | ||||
|   updateContactAttributes: async ({ commit }, { email, messageId }) => { | ||||
|     commit('toggleUpdateStatus', true); | ||||
|     try { | ||||
|       await updateContact({ email, messageId }); | ||||
|       const { | ||||
|         data: { | ||||
|           contact: { pubsub_token: pubsubToken }, | ||||
|         }, | ||||
|       } = await MessageAPI.update({ email, messageId }); | ||||
|       commit( | ||||
|         'conversation/updateMessage', | ||||
|         { | ||||
| @@ -23,6 +28,7 @@ const actions = { | ||||
|         }, | ||||
|         { root: true } | ||||
|       ); | ||||
|       refreshActionCableConnector(pubsubToken); | ||||
|     } catch (error) { | ||||
|       // Ignore error
 | ||||
|     } | ||||
							
								
								
									
										8
									
								
								app/jobs/contact_avatar_job.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/jobs/contact_avatar_job.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| class ContactAvatarJob < ApplicationJob | ||||
|   queue_as :default | ||||
|  | ||||
|   def perform(contact, avatar_url) | ||||
|     avatar_resource = LocalResource.new(avatar_url) | ||||
|     contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) | ||||
|   end | ||||
| end | ||||
| @@ -3,7 +3,7 @@ | ||||
| # Table name: accounts | ||||
| # | ||||
| #  id         :integer          not null, primary key | ||||
| #  locale     :integer          default("English") | ||||
| #  locale     :integer          default("eng") | ||||
| #  name       :string           not null | ||||
| #  created_at :datetime         not null | ||||
| #  updated_at :datetime         not null | ||||
|   | ||||
| @@ -5,6 +5,7 @@ | ||||
| #  id                    :integer          not null, primary key | ||||
| #  additional_attributes :jsonb | ||||
| #  email                 :string | ||||
| #  identifier            :string | ||||
| #  name                  :string | ||||
| #  phone_number          :string | ||||
| #  pubsub_token          :string | ||||
| @@ -16,6 +17,8 @@ | ||||
| # | ||||
| #  index_contacts_on_account_id         (account_id) | ||||
| #  index_contacts_on_pubsub_token       (pubsub_token) UNIQUE | ||||
| #  uniq_email_per_account_contact       (email,account_id) UNIQUE | ||||
| #  uniq_identifier_per_account_contact  (identifier,account_id) UNIQUE | ||||
| # | ||||
|  | ||||
| class Contact < ApplicationRecord | ||||
| @@ -23,6 +26,8 @@ class Contact < ApplicationRecord | ||||
|   include Avatarable | ||||
|   include AvailabilityStatusable | ||||
|   validates :account_id, presence: true | ||||
|   validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false } | ||||
|   validates :identifier, allow_blank: true, uniqueness: { scope: [:account_id] } | ||||
|  | ||||
|   belongs_to :account | ||||
|   has_many :conversations, dependent: :destroy | ||||
| @@ -30,6 +35,8 @@ class Contact < ApplicationRecord | ||||
|   has_many :inboxes, through: :contact_inboxes | ||||
|   has_many :messages, dependent: :destroy | ||||
|  | ||||
|   before_validation :downcase_email | ||||
|  | ||||
|   def get_source_id(inbox_id) | ||||
|     contact_inboxes.find_by!(inbox_id: inbox_id).source_id | ||||
|   end | ||||
| @@ -49,4 +56,8 @@ class Contact < ApplicationRecord | ||||
|       name: name | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def downcase_email | ||||
|     email.downcase! if email.present? | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -17,6 +17,10 @@ class MessageTemplates::HookExecutionService | ||||
|   end | ||||
|  | ||||
|   def should_send_email_collect? | ||||
|     conversation.inbox.web_widget? && first_message_from_contact? | ||||
|     !contact_has_email? && conversation.inbox.web_widget? && first_message_from_contact? | ||||
|   end | ||||
|  | ||||
|   def contact_has_email? | ||||
|     contact.email | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -30,11 +30,6 @@ class Twitter::WebhooksBaseService | ||||
|       user['id'], user['name'], additional_contact_attributes(user) | ||||
|     ) | ||||
|     @contact = @contact_inbox.contact | ||||
|     avatar_resource = LocalResource.new(user['profile_image_url']) | ||||
|     @contact.avatar.attach( | ||||
|       io: avatar_resource.file, | ||||
|       filename: avatar_resource.tmp_filename, | ||||
|       content_type: avatar_resource.encoding | ||||
|     ) | ||||
|     ContactAvatarJob.perform_later(@contact, user['profile_image_url']) if user['profile_image_url'] | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										1
									
								
								app/views/api/v1/widget/messages/update.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/views/api/v1/widget/messages/update.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| json.contact @contact | ||||
| @@ -1,6 +1,11 @@ | ||||
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" /> | ||||
|  | ||||
| <script> | ||||
|  | ||||
| window.chatwootSettings = { | ||||
|   hideMessageBubble: false, | ||||
| }; | ||||
|  | ||||
| (function(d,t) { | ||||
|   var BASE_URL = ''; | ||||
|   var g=d.createElement(t),s=d.getElementsByTagName(t)[0]; | ||||
|   | ||||
| @@ -103,8 +103,10 @@ Rails.application.routes.draw do | ||||
|       resource :profile, only: [:show, :update] | ||||
|  | ||||
|       namespace :widget do | ||||
|         resources :messages, only: [:index, :create, :update] | ||||
|         resource :contact, only: [:update] | ||||
|         resources :inbox_members, only: [:index] | ||||
|         resources :labels, only: [:create, :destroy] | ||||
|         resources :messages, only: [:index, :create, :update] | ||||
|       end | ||||
|  | ||||
|       resources :webhooks, only: [] do | ||||
|   | ||||
							
								
								
									
										7
									
								
								db/migrate/20200331095710_add_identifier_to_contact.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								db/migrate/20200331095710_add_identifier_to_contact.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| class AddIdentifierToContact < ActiveRecord::Migration[6.0] | ||||
|   def change | ||||
|     add_column :contacts, :identifier, :string, index: true, default: nil | ||||
|     add_index :contacts, ['identifier', :account_id], unique: true, name: 'uniq_identifier_per_account_contact' | ||||
|     add_index :contacts, ['email', :account_id], unique: true, name: 'uniq_email_per_account_contact' | ||||
|   end | ||||
| end | ||||
| @@ -10,7 +10,7 @@ | ||||
| # | ||||
| # It's strongly recommended that you check this file into your version control system. | ||||
|  | ||||
| ActiveRecord::Schema.define(version: 2020_03_25_210612) do | ||||
| ActiveRecord::Schema.define(version: 2020_03_31_095710) do | ||||
|  | ||||
|   # These are extensions that must be enabled in order to support this database | ||||
|   enable_extension "plpgsql" | ||||
| @@ -157,7 +157,10 @@ ActiveRecord::Schema.define(version: 2020_03_25_210612) do | ||||
|     t.datetime "updated_at", null: false | ||||
|     t.string "pubsub_token" | ||||
|     t.jsonb "additional_attributes" | ||||
|     t.string "identifier" | ||||
|     t.index ["account_id"], name: "index_contacts_on_account_id" | ||||
|     t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true | ||||
|     t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true | ||||
|     t.index ["pubsub_token"], name: "index_contacts_on_pubsub_token", unique: true | ||||
|   end | ||||
|  | ||||
|   | ||||
							
								
								
									
										45
									
								
								spec/actions/contact_identify_action_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								spec/actions/contact_identify_action_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| describe ::ContactIdentifyAction do | ||||
|   subject(:contact_identify) { described_class.new(contact: contact, params: params).perform } | ||||
|  | ||||
|   let!(:account) { create(:account) } | ||||
|   let!(:contact) { create(:contact, account: account) } | ||||
|   let(:params) { { name: 'test', identifier: 'test_id' } } | ||||
|  | ||||
|   describe '#perform' do | ||||
|     it 'updates the contact' do | ||||
|       expect(ContactAvatarJob).not_to receive(:perform_later).with(contact, params[:avatar_url]) | ||||
|       contact_identify | ||||
|       expect(contact.reload.name).to eq 'test' | ||||
|       expect(contact.reload.identifier).to eq 'test_id' | ||||
|     end | ||||
|  | ||||
|     it 'enques avatar job when avatar url parameter is passed' do | ||||
|       params = { name: 'test', avatar_url: 'https://via.placeholder.com/250x250.png' } | ||||
|       expect(ContactAvatarJob).to receive(:perform_later).with(contact, params[:avatar_url]).once | ||||
|       described_class.new(contact: contact, params: params).perform | ||||
|     end | ||||
|  | ||||
|     context 'when contact with same identifier exists' do | ||||
|       it 'merges the current contact to identified contact' do | ||||
|         existing_identified_contact = create(:contact, account: account, identifier: 'test_id') | ||||
|         result = contact_identify | ||||
|         expect(result.id).to eq existing_identified_contact.id | ||||
|         expect(result.name).to eq params[:name] | ||||
|         expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when contact with same email exists' do | ||||
|       it 'merges the current contact to email contact' do | ||||
|         existing_email_contact = create(:contact, account: account, email: 'test@test.com') | ||||
|         params = { email: 'test@test.com' } | ||||
|         result = described_class.new(contact: contact, params: params).perform | ||||
|         expect(result.id).to eq existing_email_contact.id | ||||
|         expect(result.name).to eq existing_email_contact.name | ||||
|         expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										42
									
								
								spec/controllers/api/v1/widget/contacts_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								spec/controllers/api/v1/widget/contacts_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe '/api/v1/widget/contacts', 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(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } } | ||||
|   let(:token) { ::Widget::TokenService.new(payload: payload).generate_token } | ||||
|  | ||||
|   describe 'PATCH /api/v1/widget/contact' do | ||||
|     let(:params) { { website_token: web_widget.website_token, identifier: 'test' } } | ||||
|  | ||||
|     context 'with invalid website token' do | ||||
|       it 'returns unauthorized' do | ||||
|         patch '/api/v1/widget/contact', params: { website_token: '' } | ||||
|         expect(response).to have_http_status(:not_found) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'with correct website token' do | ||||
|       let(:identify_action) { double } | ||||
|  | ||||
|       before do | ||||
|         allow(ContactIdentifyAction).to receive(:new).and_return(identify_action) | ||||
|         allow(identify_action).to receive(:perform) | ||||
|       end | ||||
|  | ||||
|       it 'calls contact identify' do | ||||
|         patch '/api/v1/widget/contact', | ||||
|               params: params, | ||||
|               headers: { 'X-Auth-Token' => token }, | ||||
|               as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expected_params = { contact: contact, params: params } | ||||
|         expect(ContactIdentifyAction).to have_received(:new).with(expected_params) | ||||
|         expect(identify_action).to have_received(:perform) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										63
									
								
								spec/controllers/api/v1/widget/labels_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								spec/controllers/api/v1/widget/labels_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe '/api/v1/widget/labels', 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 } | ||||
|  | ||||
|   describe 'POST /api/v1/widget/labels' do | ||||
|     let(:params) { { website_token: web_widget.website_token, label: 'customer-support' } } | ||||
|  | ||||
|     context 'with correct website token' do | ||||
|       it 'returns the list of labels' do | ||||
|         post '/api/v1/widget/labels', | ||||
|              params: params, | ||||
|              headers: { 'X-Auth-Token' => token }, | ||||
|              as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expect(conversation.reload.label_list.count).to eq 1 | ||||
|         expect(conversation.reload.label_list.first).to eq 'customer-support' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'with invalid website token' do | ||||
|       it 'returns the list of labels' do | ||||
|         post '/api/v1/widget/labels', params: { website_token: '' } | ||||
|         expect(response).to have_http_status(:not_found) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'DELETE /api/v1/widget/labels' do | ||||
|     before do | ||||
|       conversation.label_list.add('customer-support') | ||||
|       conversation.save! | ||||
|     end | ||||
|  | ||||
|     let(:params) { { website_token: web_widget.website_token, label: 'customer-support' } } | ||||
|  | ||||
|     context 'with correct website token' do | ||||
|       it 'returns the list of labels' do | ||||
|         delete "/api/v1/widget/labels/#{params[:label]}", | ||||
|                params: params, | ||||
|                headers: { 'X-Auth-Token' => token }, | ||||
|                as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expect(conversation.reload.label_list.count).to eq 0 | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'with invalid website token' do | ||||
|       it 'returns the list of labels' do | ||||
|         delete "/api/v1/widget/labels/#{params[:label]}", params: { website_token: '' } | ||||
|         expect(response).to have_http_status(:not_found) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -3,7 +3,7 @@ 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) { create(:contact, account: account, email: nil) } | ||||
|   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 } } | ||||
|   | ||||
| @@ -6,7 +6,10 @@ describe ::MessageFinder do | ||||
|   let!(:account) { create(:account) } | ||||
|   let!(:user) { create(:user, account: account) } | ||||
|   let!(:inbox) { create(:inbox, account: account) } | ||||
|   let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) } | ||||
|   let!(:contact) { create(:contact, email: nil) } | ||||
|   let!(:conversation) do | ||||
|     create(:conversation, account: account, inbox: inbox, assignee: user, contact: contact) | ||||
|   end | ||||
|  | ||||
|   before do | ||||
|     create(:message, account: account, inbox: inbox, conversation: conversation) | ||||
|   | ||||
| @@ -3,7 +3,10 @@ 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) | ||||
|       contact = create(:contact, email: nil) | ||||
|       conversation = create(:conversation, contact: contact) | ||||
|       message = create(:message, conversation: conversation) | ||||
|  | ||||
|       # this hook will only get executed for conversations with out any template messages | ||||
|       message.conversation.messages.template.destroy_all | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Pranav Raj S
					Pranav Raj S