From 722f540b0310236a8143f9ed677fcd6660a399df Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 9 Jan 2020 13:06:40 +0530 Subject: [PATCH] [Feature] Email collect message hooks (#331) - Add email collect hook on creating conversation - Merge contact if it already exist --- .rubocop.yml | 4 +- app/builders/messages/message_builder.rb | 241 +++++++++--------- .../api/v1/inbox_members_controller.rb | 96 ++++--- .../api/v1/widget/base_controller.rb | 29 +++ .../api/v1/widget/messages_controller.rb | 55 ++-- app/controllers/widgets_controller.rb | 71 ++---- .../dashboard/assets/images/chatwoot_bot.png | Bin 0 -> 2165 bytes .../dashboard/components/Spinner.vue | 3 - .../components/buttons/FormSubmitButton.vue | 2 +- .../components/buttons/ResolveButton.vue | 2 +- app/javascript/dashboard/components/index.js | 2 +- .../widgets/conversation/Message.vue | 4 +- app/javascript/packs/sdk.js | 22 +- app/javascript/packs/widget.js | 3 + app/javascript/widget/api/auth.js | 12 - app/javascript/widget/api/contact.js | 10 + app/javascript/widget/api/endPoints.js | 5 + app/javascript/widget/assets/scss/_forms.scss | 1 + .../widget/assets/scss/_variables.scss | 8 + app/javascript/widget/assets/scss/woot.scss | 2 + .../widget/components/AgentMessage.vue | 25 +- .../widget/components/AgentMessageBubble.vue | 26 +- .../widget/components/ChatMessage.vue | 15 +- .../widget/components/ChatSendButton.vue | 3 +- .../widget/components/template/EmailInput.vue | 115 +++++++++ app/javascript/widget/helpers/constants.js | 2 + app/javascript/widget/store/index.js | 4 +- .../widget/store/modules/contact.js | 45 ++++ .../widget/store/modules/conversation.js | 7 + app/models/channel/facebook_page.rb | 36 ++- app/models/channel/web_widget.rb | 44 ++-- app/models/contact_inbox.rb | 2 + app/models/conversation.rb | 7 + app/models/inbox.rb | 4 + app/models/message.rb | 39 +-- .../conversations/event_data_presenter.rb | 58 ++--- app/services/contact/merge_service.rb | 0 .../hook_execution_service.rb | 20 ++ .../template/email_collect.rb | 56 ++++ app/services/widget/token_service.rb | 21 ++ .../v1/widget/messages/index.json.jbuilder | 2 + .../widgets/{index.html.erb => show.html.erb} | 0 config/environments/test.rb | 2 +- config/locales/en.yml | 4 + config/routes.rb | 4 +- ...130164019_add_template_type_to_messages.rb | 6 + ...64449_add_contact_inbox_to_conversation.rb | 14 + db/schema.rb | 9 +- db/seeds.rb | 6 +- lib/integrations/facebook/delivery_status.rb | 44 ++-- lib/integrations/facebook/message_creator.rb | 68 +++-- lib/integrations/facebook/message_parser.rb | 70 +++-- .../widget/incoming_message_builder.rb | 102 ++++---- spec/actions/contact_merge_action_spec.rb | 2 - ...ec.rb => incoming_message_builder_spec.rb} | 2 +- .../api/v1/widget/messages_controller_spec.rb | 84 ++++++ .../widget_tests_controller_spec.rb | 12 +- spec/controllers/widgets_controller_spec.rb | 17 ++ spec/factories/channel/channel_widget.rb | 3 + spec/factories/conversations.rb | 1 + spec/factories/inboxes.rb | 6 +- spec/factories/messages.rb | 12 +- spec/finders/message_finder_spec.rb | 6 +- spec/models/conversation_spec.rb | 16 +- spec/models/message_spec.rb | 26 ++ .../facebook/send_reply_service_spec.rb | 4 +- .../hook_execution_service_spec.rb | 20 ++ .../template/email_collect_spec.rb | 12 + 68 files changed, 1111 insertions(+), 544 deletions(-) create mode 100644 app/controllers/api/v1/widget/base_controller.rb create mode 100644 app/javascript/dashboard/assets/images/chatwoot_bot.png delete mode 100644 app/javascript/dashboard/components/Spinner.vue delete mode 100755 app/javascript/widget/api/auth.js create mode 100755 app/javascript/widget/api/contact.js create mode 100644 app/javascript/widget/components/template/EmailInput.vue create mode 100644 app/javascript/widget/store/modules/contact.js create mode 100644 app/services/contact/merge_service.rb create mode 100644 app/services/message_templates/hook_execution_service.rb create mode 100644 app/services/message_templates/template/email_collect.rb create mode 100644 app/services/widget/token_service.rb rename app/views/widgets/{index.html.erb => show.html.erb} (100%) create mode 100644 db/migrate/20191130164019_add_template_type_to_messages.rb create mode 100644 db/migrate/20200107164449_add_contact_inbox_to_conversation.rb rename spec/builders/messages/{message_builder_spec.rb => incoming_message_builder_spec.rb} (96%) create mode 100644 spec/controllers/api/v1/widget/messages_controller_spec.rb create mode 100644 spec/controllers/widgets_controller_spec.rb create mode 100644 spec/models/message_spec.rb create mode 100644 spec/services/message_templates/hook_execution_service_spec.rb create mode 100644 spec/services/message_templates/template/email_collect_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index bc34a343b..b007be1d5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,13 +4,13 @@ require: - rubocop-rspec inherit_from: .rubocop_todo.yml -Metrics/LineLength: +Layout/LineLength: Max: 150 Metrics/ClassLength: Max: 125 RSpec/ExampleLength: Max: 15 -Documentation: +Style/Documentation: Enabled: false Style/FrozenStringLiteralComment: Enabled: false diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 6611cfe5b..8cad3f87b 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -6,132 +6,137 @@ require 'open-uri' # 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. -module Messages - class MessageBuilder - attr_reader :response +class Messages::MessageBuilder + attr_reader :response - def initialize(response, inbox, outgoing_echo = false) - @response = response - @inbox = inbox - @sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id) - @message_type = (outgoing_echo ? :outgoing : :incoming) + def initialize(response, inbox, outgoing_echo = false) + @response = response + @inbox = inbox + @sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id) + @message_type = (outgoing_echo ? :outgoing : :incoming) + end + + def perform + ActiveRecord::Base.transaction do + build_contact + build_message end + rescue StandardError => e + Raven.capture_exception(e) + true + end - def perform - ActiveRecord::Base.transaction do - build_contact - build_message - end - rescue StandardError => e - Raven.capture_exception(e) - true - end + private - private + def contact + @contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact + end - def contact - @contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact - end + def build_contact + return if contact.present? - def build_contact - 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) - @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) + @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) + end - ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) - end - - def build_message - @message = conversation.messages.create!(message_params) - (response.attachments || []).each do |attachment| - attachment_obj = @message.build_attachment(attachment_params(attachment).except(:remote_file_url)) - attachment_obj.save! - attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] - end - end - - def attach_file(attachment, file_url) - file_resource = LocalResource.new(file_url) - attachment.file.attach(io: file_resource.file, filename: file_resource.tmp_filename, content_type: file_resource.encoding) - end - - def conversation - @conversation ||= Conversation.find_by(conversation_params) || Conversation.create!(conversation_params) - end - - def attachment_params(attachment) - file_type = attachment['type'].to_sym - params = { file_type: file_type, account_id: @message.account_id } - - if [:image, :file, :audio, :video].include? file_type - params.merge!(file_type_params(attachment)) - elsif file_type == :location - params.merge!(location_params(attachment)) - elsif file_type == :fallback - params.merge!(fallback_params(attachment)) - end - - params - end - - def file_type_params(attachment) - { - external_url: attachment['payload']['url'], - remote_file_url: attachment['payload']['url'] - } - end - - def location_params(attachment) - lat = attachment['payload']['coordinates']['lat'] - long = attachment['payload']['coordinates']['long'] - { - external_url: attachment['url'], - coordinates_lat: lat, - coordinates_long: long, - fallback_title: attachment['title'] - } - end - - def fallback_params(attachment) - { - fallback_title: attachment['title'], - external_url: attachment['url'] - } - end - - def conversation_params - { - account_id: @inbox.account_id, - inbox_id: @inbox.id, - contact_id: contact.id - } - end - - def message_params - { - account_id: conversation.account_id, - inbox_id: conversation.inbox_id, - message_type: @message_type, - content: response.content, - fb_id: response.identifier - } - end - - def contact_params - begin - k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? - result = k.get_object(@sender_id) || {} - rescue Exception => e - result = {} - Raven.capture_exception(e) - end - { - name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}", - account_id: @inbox.account_id, - remote_avatar_url: result['profile_pic'] || '' - } + def build_message + @message = conversation.messages.create!(message_params) + (response.attachments || []).each do |attachment| + attachment_obj = @message.build_attachment(attachment_params(attachment).except(:remote_file_url)) + attachment_obj.save! + attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] end end + + def attach_file(attachment, file_url) + file_resource = LocalResource.new(file_url) + attachment.file.attach(io: file_resource.file, filename: file_resource.tmp_filename, content_type: file_resource.encoding) + end + + def conversation + @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 + + def attachment_params(attachment) + file_type = attachment['type'].to_sym + params = { file_type: file_type, account_id: @message.account_id } + + if [:image, :file, :audio, :video].include? file_type + params.merge!(file_type_params(attachment)) + elsif file_type == :location + params.merge!(location_params(attachment)) + elsif file_type == :fallback + params.merge!(fallback_params(attachment)) + end + + params + end + + def file_type_params(attachment) + { + external_url: attachment['payload']['url'], + remote_file_url: attachment['payload']['url'] + } + end + + def location_params(attachment) + lat = attachment['payload']['coordinates']['lat'] + long = attachment['payload']['coordinates']['long'] + { + external_url: attachment['url'], + coordinates_lat: lat, + coordinates_long: long, + fallback_title: attachment['title'] + } + end + + def fallback_params(attachment) + { + fallback_title: attachment['title'], + external_url: attachment['url'] + } + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: contact.id + } + end + + def message_params + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: @message_type, + content: response.content, + fb_id: response.identifier + } + end + + def contact_params + begin + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + result = k.get_object(@sender_id) || {} + rescue Exception => e + result = {} + Raven.capture_exception(e) + end + { + name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}", + account_id: @inbox.account_id, + remote_avatar_url: result['profile_pic'] || '' + } + end end diff --git a/app/controllers/api/v1/inbox_members_controller.rb b/app/controllers/api/v1/inbox_members_controller.rb index f5e8a6a10..ed47f409a 100644 --- a/app/controllers/api/v1/inbox_members_controller.rb +++ b/app/controllers/api/v1/inbox_members_controller.rb @@ -1,55 +1,51 @@ -module Api - module V1 - class InboxMembersController < Api::BaseController - before_action :fetch_inbox, only: [:create, :show] - before_action :current_agents_ids, only: [:create] +class Api::V1::InboxMembersController < Api::BaseController + before_action :fetch_inbox, only: [:create, :show] + before_action :current_agents_ids, only: [:create] - def create - # update also done via same action - if @inbox - begin - update_agents_list - head :ok - rescue StandardError => e - Rails.logger.debug "Rescued: #{e.inspect}" - render_could_not_create_error('Could not add agents to inbox') - end - else - render_not_found_error('Agents or inbox not found') - end - end - - def show - @agents = current_account.users.where(id: @inbox.members.pluck(:user_id)) - end - - private - - def update_agents_list - # get all the user_ids which the inbox currently has as members. - # get the list of user_ids from params - # the missing ones are the agents which are to be deleted from the inbox - # the new ones are the agents which are to be added to the inbox - - agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) } - agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) } - end - - def agents_to_be_added_ids - params[:user_ids] - @current_agents_ids - end - - def agents_to_be_removed_ids - @current_agents_ids - params[:user_ids] - end - - def current_agents_ids - @current_agents_ids = @inbox.members.pluck(:id) - end - - def fetch_inbox - @inbox = current_account.inboxes.find(params[:inbox_id]) + def create + # update also done via same action + if @inbox + begin + update_agents_list + head :ok + rescue StandardError => e + Rails.logger.debug "Rescued: #{e.inspect}" + render_could_not_create_error('Could not add agents to inbox') end + else + render_not_found_error('Agents or inbox not found') end end + + def show + @agents = current_account.users.where(id: @inbox.members.pluck(:user_id)) + end + + private + + def update_agents_list + # get all the user_ids which the inbox currently has as members. + # get the list of user_ids from params + # the missing ones are the agents which are to be deleted from the inbox + # the new ones are the agents which are to be added to the inbox + + agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) } + agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) } + end + + def agents_to_be_added_ids + params[:user_ids] - @current_agents_ids + end + + def agents_to_be_removed_ids + @current_agents_ids - params[:user_ids] + end + + def current_agents_ids + @current_agents_ids = @inbox.members.pluck(:id) + end + + def fetch_inbox + @inbox = current_account.inboxes.find(params[:inbox_id]) + end end diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb new file mode 100644 index 000000000..eb19e2bdd --- /dev/null +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a3e83663e..903e4b26e 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -1,6 +1,8 @@ -class Api::V1::Widget::MessagesController < ActionController::Base - skip_before_action :verify_authenticity_token +class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController + before_action :set_web_widget + before_action :set_contact before_action :set_conversation, only: [:create] + before_action :set_message, only: [:update] def index @messages = conversation.nil? ? [] : message_finder.perform @@ -9,17 +11,19 @@ class Api::V1::Widget::MessagesController < ActionController::Base def create @message = conversation.messages.new(message_params) @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 private - def conversation - @conversation ||= ::Conversation.find_by( - contact_id: cookie_params[:contact_id], - inbox_id: cookie_params[:inbox_id] - ) - end - def set_conversation @conversation = ::Conversation.create!(conversation_params) if conversation.nil? end @@ -37,7 +41,8 @@ class Api::V1::Widget::MessagesController < ActionController::Base { account_id: inbox.account_id, inbox_id: inbox.id, - contact_id: cookie_params[:contact_id], + contact_id: @contact.id, + contact_inbox_id: @contact_inbox.id, additional_attributes: { browser: browser_params, referer: permitted_params[:message][:referer_url], @@ -63,13 +68,7 @@ class Api::V1::Widget::MessagesController < ActionController::Base end def inbox - @inbox ||= ::Inbox.find_by(id: cookie_params[:inbox_id]) - end - - def cookie_params - @cookie_params ||= JWT.decode( - request.headers[header_name], secret_key, true, algorithm: 'HS256' - ).first.symbolize_keys + @inbox ||= ::Inbox.find_by(id: auth_token_params[:inbox_id]) end def message_finder_params @@ -83,15 +82,27 @@ class Api::V1::Widget::MessagesController < ActionController::Base @message_finder ||= MessageFinder.new(conversation, message_finder_params) end - def header_name - 'X-Auth-Token' + 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 + else + @contact.update!( + email: permitted_params[:contact][:email], + name: contact_name + ) + end + end + + def contact_name + permitted_params[:contact][:email].split('@')[0] end 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 - def secret_key - Rails.application.secrets.secret_key_base + def set_message + @message = @web_widget.inbox.messages.find(permitted_params[:id]) end end diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 711a5cca4..60b522831 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -4,66 +4,47 @@ class WidgetsController < ActionController::Base before_action :set_contact before_action :build_contact + def index + render + end + 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 @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) 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 return if @contact.present? contact_inbox = @web_widget.create_contact_inbox @contact = contact_inbox.contact - payload = { - source_id: contact_inbox.source_id, - 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] + payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id } + @token = ::Widget::TokenService.new(payload: payload).generate_token end def permitted_params params.permit(:website_token, :cw_conversation) end - - def secret_key - Rails.application.secrets.secret_key_base - end end diff --git a/app/javascript/dashboard/assets/images/chatwoot_bot.png b/app/javascript/dashboard/assets/images/chatwoot_bot.png new file mode 100644 index 0000000000000000000000000000000000000000..4b5a2d686e7f86f29e4fac5142bffac3afaecb0a GIT binary patch literal 2165 zcmV-*2#WWKP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91GoS+i1ONa40RR91GXMYp0M89gSO5SA-$_J4RA>dw8e41>RrK81M|VpL z6jBAH3RI{xJfb0}5ik&Ax`s-8&@M_*5;S~if>DD3T|+SR#~A-4#G0V6R1gt@B`PRr zC4_*Y;blQu-cS@MbRXTF>p8QXb#``U=iZ@EPtwkPoO>SkaUS+_HkdwiTFc2 z4bvHeYT&#I7z_u_O7S~@bBHs>y8zfF4*VE?k1)nsOIgvbFY4I!z7T`m3=vY`)bT_O zSJnBz*pr;|VrhDer5J}@IBt!iZtjtrJ`JKsF z{4?B2kBbt&X*|Xb`@l1IYuNvr2rVIHLK~6~sHybkzT8qG!ewyS4aSuDE#aasoyI6m z2sP2}IXspAsecq9>G%K+>V{A>_73iff|Fmc6BT)b18YP@3gS6UNT2mHw)Th$vzt>{T-3CJ$F| z5{x}^Wz2`>QK-YT46!YKMbxht*^nZQGyG6pZnCpRB*Aj~RTWuDI~;N^oPP&`JV%dLRAab76HxJ?%D$W$w6#j0cn){gON{zG=02z~(V zHN|=^pi)79b(icw@*`Go@S+MkF`W4e9b+NrMCVFj&o5_Gu)>^HVNJgfO-$l^I1j7@ z@39Kw8G6aHlS4E^0zg zZ~;Dc{Ba7|CA&_mc8Y#)2gvflv#+8xqf2aYpfLV+fHWTl9k|ok5j?uW^0hmdU7gSY zyxdr_Goe?r-=$NFVCsm%XNr(1?~wsKDn~E^V68HP&SPRl`G^zHZB41gzQ4P~{>-(HDfeLXI1m-1o19Um3lJ+h zt*6rxdnH3bQTPsRnlMxQ9?#B9E5cT7B^Yfq)drx{?>x&^;|sO*q&Y0ymdK{og>dyg zl%n(DVL{A_GO$MP5cyKhL6?2E0=BfJr4%6<@bWksnZZ(;GW23a4?Qx%Z91BQednCF zn+(lkf2;86K{wf%w~6>@%Fxpr+KxsU#uTx<-i;F>-iscLI`<^MhM#)Aq1` zKgpnPGoYt8te=$;tQ#khQr-RMB_%L1p%r=U+c;iDnL3=RWVk%a5*;n)1c%ngURG7U zO79f?QmRX#3?){SkB9;tb~+=pwPaU>c%WPPxFsB1r1Q(+Jg1p^uBq|gG5j5;BglLy zL!`ut@(~K-?ST9UncwwzMXO&3+Tq%_vl|{y;J$5j?7WVX$8lCPIjM)Ie?}W29DM4; zj9B*>5D!FJ;D7 zY!3&Qnt5jpfU$!fL$M=bZ2RAo8GO}JA6RB3kEJq%xUD|098cI=rI((EBj0M9KN_BrAqLzLcg;SIurP8{Q^44)o2vZUiAau8KJFZw<5Yk zLdp;_y&=-m8?NsNeekXS(hnN=LbKJf<7>Ok%mcR8`SlgmG()a`d1R(*S*Kq* zp+h7;BB}_{5ndT1Q+j&i_+mv5J-GE;T|XM>8OtcpCJIqn>HsVT9(qC>lN0D!fo|jJ5*u+UtyYb0QIKl~ r - - diff --git a/app/javascript/dashboard/components/buttons/FormSubmitButton.vue b/app/javascript/dashboard/components/buttons/FormSubmitButton.vue index b0cf8cdad..c6ec19321 100644 --- a/app/javascript/dashboard/components/buttons/FormSubmitButton.vue +++ b/app/javascript/dashboard/components/buttons/FormSubmitButton.vue @@ -12,7 +12,7 @@ diff --git a/app/javascript/widget/components/AgentMessageBubble.vue b/app/javascript/widget/components/AgentMessageBubble.vue index 54319761a..8017a8854 100755 --- a/app/javascript/widget/components/AgentMessageBubble.vue +++ b/app/javascript/widget/components/AgentMessageBubble.vue @@ -1,20 +1,42 @@ - diff --git a/app/javascript/widget/helpers/constants.js b/app/javascript/widget/helpers/constants.js index 605ba2c58..d5862729b 100755 --- a/app/javascript/widget/helpers/constants.js +++ b/app/javascript/widget/helpers/constants.js @@ -9,4 +9,6 @@ export const MESSAGE_STATUS = { export const MESSAGE_TYPE = { INCOMING: 0, OUTGOING: 1, + ACTIVITY: 2, + TEMPLATE: 3, }; diff --git a/app/javascript/widget/store/index.js b/app/javascript/widget/store/index.js index 91f6877ef..b63d25414 100755 --- a/app/javascript/widget/store/index.js +++ b/app/javascript/widget/store/index.js @@ -1,13 +1,15 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import conversation from 'widget/store/modules/conversation'; import appConfig from 'widget/store/modules/appConfig'; +import contact from 'widget/store/modules/contact'; +import conversation from 'widget/store/modules/conversation'; Vue.use(Vuex); export default new Vuex.Store({ modules: { appConfig, + contact, conversation, }, }); diff --git a/app/javascript/widget/store/modules/contact.js b/app/javascript/widget/store/modules/contact.js new file mode 100644 index 000000000..8987df242 --- /dev/null +++ b/app/javascript/widget/store/modules/contact.js @@ -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, +}; diff --git a/app/javascript/widget/store/modules/conversation.js b/app/javascript/widget/store/modules/conversation.js index f0bc6cb39..81a19fd13 100755 --- a/app/javascript/widget/store/modules/conversation.js +++ b/app/javascript/widget/store/modules/conversation.js @@ -135,6 +135,13 @@ export const mutations = { 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 { diff --git a/app/models/channel/facebook_page.rb b/app/models/channel/facebook_page.rb index 7390e5c47..f1004859a 100644 --- a/app/models/channel/facebook_page.rb +++ b/app/models/channel/facebook_page.rb @@ -17,31 +17,29 @@ # index_channel_facebook_pages_on_page_id_and_account_id (page_id,account_id) UNIQUE # -module Channel - class FacebookPage < ApplicationRecord - include Avatarable +class Channel::FacebookPage < ApplicationRecord + include Avatarable - self.table_name = 'channel_facebook_pages' + self.table_name = 'channel_facebook_pages' - validates :account_id, presence: true - validates :page_id, uniqueness: { scope: :account_id } - has_one_attached :avatar - belongs_to :account + validates :account_id, presence: true + validates :page_id, uniqueness: { scope: :account_id } + has_one_attached :avatar + belongs_to :account - has_one :inbox, as: :channel, dependent: :destroy + has_one :inbox, as: :channel, dependent: :destroy - before_destroy :unsubscribe + before_destroy :unsubscribe - def name - 'Facebook' - end + def name + 'Facebook' + end - private + private - def unsubscribe - Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token) - rescue => e - true - end + def unsubscribe + Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token) + rescue => e + true end end diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb index 3d63aee8a..97b16eef2 100644 --- a/app/models/channel/web_widget.rb +++ b/app/models/channel/web_widget.rb @@ -16,33 +16,31 @@ # index_channel_web_widgets_on_website_token (website_token) UNIQUE # -module Channel - class WebWidget < ApplicationRecord - self.table_name = 'channel_web_widgets' +class Channel::WebWidget < ApplicationRecord + self.table_name = 'channel_web_widgets' - validates :website_name, presence: true - validates :website_url, presence: true - validates :widget_color, presence: true + validates :website_name, presence: true + validates :website_url, presence: true + validates :widget_color, presence: true - belongs_to :account - has_one :inbox, as: :channel, dependent: :destroy - has_secure_token :website_token + belongs_to :account + has_one :inbox, as: :channel, dependent: :destroy + has_secure_token :website_token - def name - 'Website' - end + def name + 'Website' + end - def create_contact_inbox - ActiveRecord::Base.transaction do - contact = inbox.account.contacts.create!(name: ::Haikunator.haikunate(1000)) - ::ContactInbox.create!( - contact_id: contact.id, - inbox_id: inbox.id, - source_id: SecureRandom.uuid - ) - rescue StandardError => e - Rails.logger e - end + def create_contact_inbox + ActiveRecord::Base.transaction do + contact = inbox.account.contacts.create!(name: ::Haikunator.haikunate(1000)) + ::ContactInbox.create!( + contact_id: contact.id, + inbox_id: inbox.id, + source_id: SecureRandom.uuid + ) + rescue StandardError => e + Rails.logger e end end end diff --git a/app/models/contact_inbox.rb b/app/models/contact_inbox.rb index 42c0584a9..c79786e1e 100644 --- a/app/models/contact_inbox.rb +++ b/app/models/contact_inbox.rb @@ -29,4 +29,6 @@ class ContactInbox < ApplicationRecord belongs_to :contact belongs_to :inbox + + has_many :conversations, dependent: :destroy end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index e86b0124b..2fa1205a6 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -13,6 +13,7 @@ # account_id :integer not null # assignee_id :integer # contact_id :bigint +# contact_inbox_id :bigint # display_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_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 @@ -38,6 +44,7 @@ class Conversation < ApplicationRecord belongs_to :inbox belongs_to :assignee, class_name: 'User', optional: true belongs_to :contact + belongs_to :contact_inbox has_many :messages, dependent: :destroy, autosave: true diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 2d2b61db9..28d6a2d51 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -49,6 +49,10 @@ class Inbox < ApplicationRecord channel.class.name.to_s == 'Channel::FacebookPage' end + def web_widget? + channel.class.name.to_s == 'Channel::WebWidget' + end + def next_available_agent user_id = Redis::Alfred.rpoplpush(round_robin_key, round_robin_key) account.users.find_by(id: user_id) diff --git a/app/models/message.rb b/app/models/message.rb index 35a7269c9..a40c76efd 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -2,18 +2,20 @@ # # Table name: messages # -# id :integer not null, primary key -# content :text -# message_type :integer not null -# private :boolean default(FALSE) -# status :integer default("sent") -# created_at :datetime not null -# updated_at :datetime not null -# account_id :integer not null -# conversation_id :integer not null -# fb_id :string -# inbox_id :integer not null -# user_id :integer +# id :integer not null, primary key +# content :text +# content_attributes :json +# content_type :integer default("text") +# message_type :integer not null +# private :boolean default(FALSE) +# status :integer default("sent") +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# conversation_id :integer not null +# fb_id :string +# inbox_id :integer not null +# user_id :integer # # Indexes # @@ -27,8 +29,10 @@ class Message < ApplicationRecord validates :inbox_id, presence: true validates :conversation_id, presence: true - enum message_type: [:incoming, :outgoing, :activity] - enum status: [:sent, :delivered, :read, :failed] + enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 } + 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 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, :dispatch_event, - :send_reply + :send_reply, + :execute_message_template_hooks def channel_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) end end + + def execute_message_template_hooks + ::MessageTemplates::HookExecutionService.new(message: self).perform + end end diff --git a/app/presenters/conversations/event_data_presenter.rb b/app/presenters/conversations/event_data_presenter.rb index 32ecde01f..8c1bc0f1e 100644 --- a/app/presenters/conversations/event_data_presenter.rb +++ b/app/presenters/conversations/event_data_presenter.rb @@ -1,37 +1,35 @@ -module Conversations - class EventDataPresenter < SimpleDelegator - def lock_data - { id: display_id, locked: locked? } - end +class Conversations::EventDataPresenter < SimpleDelegator + def lock_data + { id: display_id, locked: locked? } + end - def push_data - { - id: display_id, - inbox_id: inbox_id, - messages: push_messages, - meta: push_meta, - status: status_before_type_cast.to_i, - unread_count: unread_incoming_messages.count, - **push_timestamps - } - end + def push_data + { + id: display_id, + inbox_id: inbox_id, + messages: push_messages, + meta: push_meta, + status: status_before_type_cast.to_i, + unread_count: unread_incoming_messages.count, + **push_timestamps + } + end - private + private - def push_messages - [messages.chat.last&.push_event_data].compact - end + def push_messages + [messages.chat.last&.push_event_data].compact + end - def push_meta - { sender: contact.push_event_data, assignee: assignee } - end + def push_meta + { sender: contact.push_event_data, assignee: assignee } + end - def push_timestamps - { - agent_last_seen_at: agent_last_seen_at.to_i, - user_last_seen_at: user_last_seen_at.to_i, - timestamp: created_at.to_i - } - end + def push_timestamps + { + agent_last_seen_at: agent_last_seen_at.to_i, + user_last_seen_at: user_last_seen_at.to_i, + timestamp: created_at.to_i + } end end diff --git a/app/services/contact/merge_service.rb b/app/services/contact/merge_service.rb new file mode 100644 index 000000000..e69de29bb diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb new file mode 100644 index 000000000..071e12a64 --- /dev/null +++ b/app/services/message_templates/hook_execution_service.rb @@ -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 diff --git a/app/services/message_templates/template/email_collect.rb b/app/services/message_templates/template/email_collect.rb new file mode 100644 index 000000000..07004081e --- /dev/null +++ b/app/services/message_templates/template/email_collect.rb @@ -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 diff --git a/app/services/widget/token_service.rb b/app/services/widget/token_service.rb new file mode 100644 index 000000000..07247fe2a --- /dev/null +++ b/app/services/widget/token_service.rb @@ -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 diff --git a/app/views/api/v1/widget/messages/index.json.jbuilder b/app/views/api/v1/widget/messages/index.json.jbuilder index 57bab734c..1f423beb1 100644 --- a/app/views/api/v1/widget/messages/index.json.jbuilder +++ b/app/views/api/v1/widget/messages/index.json.jbuilder @@ -2,6 +2,8 @@ json.array! @messages do |message| json.id message.id json.content message.content 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.conversation_id message. conversation_id json.attachment message.attachment.push_event_data if message.attachment diff --git a/app/views/widgets/index.html.erb b/app/views/widgets/show.html.erb similarity index 100% rename from app/views/widgets/index.html.erb rename to app/views/widgets/show.html.erb diff --git a/config/environments/test.rb b/config/environments/test.rb index 16bfd6192..29018c894 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -29,7 +29,7 @@ Rails.application.configure do config.cache_store = :null_store # 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. config.action_controller.allow_forgery_protection = false diff --git a/config/locales/en.yml b/config/locales/en.yml index 3306c76c5..b11a27bda 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -50,3 +50,7 @@ en: assignee: assigned: "Assigned to %{assignee_name} 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" diff --git a/config/routes.rb b/config/routes.rb index 4a61500c1..35dd8ebdd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,7 +11,7 @@ Rails.application.routes.draw do match '/status', to: 'home#status', via: [:get] - resources :widgets, only: [:index] + resource :widget, only: [:show] namespace :api, defaults: { format: 'json' } do namespace :v1 do @@ -25,7 +25,7 @@ Rails.application.routes.draw do end namespace :widget do - resources :messages, only: [:index, :create] + resources :messages, only: [:index, :create, :update] resources :inboxes, only: [:create, :update] end diff --git a/db/migrate/20191130164019_add_template_type_to_messages.rb b/db/migrate/20191130164019_add_template_type_to_messages.rb new file mode 100644 index 000000000..ec4c0e949 --- /dev/null +++ b/db/migrate/20191130164019_add_template_type_to_messages.rb @@ -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 diff --git a/db/migrate/20200107164449_add_contact_inbox_to_conversation.rb b/db/migrate/20200107164449_add_contact_inbox_to_conversation.rb new file mode 100644 index 000000000..47ab3cf7a --- /dev/null +++ b/db/migrate/20200107164449_add_contact_inbox_to_conversation.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index c3880cf94..9fb5ea94e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "plpgsql" @@ -123,8 +123,10 @@ ActiveRecord::Schema.define(version: 2019_12_27_191631) do t.datetime "agent_last_seen_at" t.boolean "locked", default: false 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"], name: "index_conversations_on_account_id" + t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id" end 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 "status", default: 0 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" 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", "tagger_id", "context"], name: "taggings_idy" 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 ["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_type", "tagger_id"], name: "index_taggings_on_tagger_type_and_tagger_id" end 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 "contact_inboxes", "contacts" add_foreign_key "contact_inboxes", "inboxes" + add_foreign_key "conversations", "contact_inboxes" add_foreign_key "users", "users", column: "inviter_id", on_delete: :nullify end diff --git a/db/seeds.rb b/db/seeds.rb index 1ba0711e4..aed828286 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -10,6 +10,6 @@ inbox = Inbox.create!(channel: web_widget, account: account, name: 'Acme Support InboxMember.create!(user: user, inbox: inbox) contact = Contact.create!(name: 'jane', email: 'jane@example.com', phone_number: '0000', account: account) -ContactInbox.create!(inbox: inbox, contact: contact, source_id: user.id) -conversation = Conversation.create!(account: account, inbox: inbox, status: :open, assignee: user, contact: contact) -Message.create!(content: 'Hello', account: account, inbox: inbox, conversation: conversation, message_type: :incoming) \ No newline at end of file +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, contact_inbox: contact_inbox) +Message.create!(content: 'Hello', account: account, inbox: inbox, conversation: conversation, message_type: :incoming) diff --git a/lib/integrations/facebook/delivery_status.rb b/lib/integrations/facebook/delivery_status.rb index ab0774dad..173d514ab 100644 --- a/lib/integrations/facebook/delivery_status.rb +++ b/lib/integrations/facebook/delivery_status.rb @@ -1,34 +1,30 @@ # frozen_string_literal: true -module Integrations - module Facebook - class DeliveryStatus - def initialize(params) - @params = params - end +class Integrations::Facebook::DeliveryStatus + def initialize(params) + @params = params + end - def perform - update_message_status - end + def perform + update_message_status + end - private + private - def sender_id - @params.sender['id'] - end + def sender_id + @params.sender['id'] + end - def contact - ::ContactInbox.find_by(source_id: sender_id).contact - end + def contact + ::ContactInbox.find_by(source_id: sender_id).contact + end - def conversation - @conversation ||= ::Conversation.find_by(contact_id: contact.id) - end + def conversation + @conversation ||= ::Conversation.find_by(contact_id: contact.id) + end - def update_message_status - conversation.user_last_seen_at = @params.at - conversation.save! - end - end + def update_message_status + conversation.user_last_seen_at = @params.at + conversation.save! end end diff --git a/lib/integrations/facebook/message_creator.rb b/lib/integrations/facebook/message_creator.rb index c04796f71..abf6c93e2 100644 --- a/lib/integrations/facebook/message_creator.rb +++ b/lib/integrations/facebook/message_creator.rb @@ -1,47 +1,43 @@ # frozen_string_literal: true -module Integrations - module Facebook - class MessageCreator - attr_reader :response +class Integrations::Facebook::MessageCreator + attr_reader :response - def initialize(response) - @response = response - end + def initialize(response) + @response = response + end - def perform - # begin - if outgoing_message_via_echo? - create_outgoing_message - else - create_incoming_message - end - # rescue => e - # Raven.capture_exception(e) - # end - end + def perform + # begin + if outgoing_message_via_echo? + create_outgoing_message + else + create_incoming_message + end + # rescue => e + # Raven.capture_exception(e) + # end + end - private + private - def outgoing_message_via_echo? - response.echo? && !response.sent_from_chatwoot_app? - # this means that it is an outgoing message from page, but not sent from chatwoot. - # User can send from fb page directly on mobile messenger, so this case should be handled as outgoing message - end + def outgoing_message_via_echo? + response.echo? && !response.sent_from_chatwoot_app? + # this means that it is an outgoing message from page, but not sent from chatwoot. + # User can send from fb page directly on mobile messenger, so this case should be handled as outgoing message + end - def create_outgoing_message - Channel::FacebookPage.where(page_id: response.sender_id).each do |page| - mb = Messages::Outgoing::EchoBuilder.new(response, page.inbox, true) - mb.perform - end - end + def create_outgoing_message + Channel::FacebookPage.where(page_id: response.sender_id).each do |page| + mb = Messages::Outgoing::EchoBuilder.new(response, page.inbox, true) + mb.perform + end + end - def create_incoming_message - Channel::FacebookPage.where(page_id: response.recipient_id).each do |page| - mb = Messages::IncomingMessageBuilder.new(response, page.inbox) - mb.perform - end - end + def create_incoming_message + Channel::FacebookPage.where(page_id: response.recipient_id).each do |page| + mb = Messages::IncomingMessageBuilder.new(response, page.inbox) + mb.perform end end end diff --git a/lib/integrations/facebook/message_parser.rb b/lib/integrations/facebook/message_parser.rb index 5e8e39286..6ddad68f9 100644 --- a/lib/integrations/facebook/message_parser.rb +++ b/lib/integrations/facebook/message_parser.rb @@ -1,52 +1,48 @@ # frozen_string_literal: true -module Integrations - module Facebook - class MessageParser - def initialize(response_json) - @response = response_json - end +class Integrations::Facebook::MessageParser + def initialize(response_json) + @response = response_json + end - def sender_id - @response.sender['id'] - end + def sender_id + @response.sender['id'] + end - def recipient_id - @response.recipient['id'] - end + def recipient_id + @response.recipient['id'] + end - def time_stamp - @response.sent_at - end + def time_stamp + @response.sent_at + end - def content - @response.text - end + def content + @response.text + end - def sequence - @response.seq - end + def sequence + @response.seq + end - def attachments - @response.attachments - end + def attachments + @response.attachments + end - def identifier - @response.id - end + def identifier + @response.id + end - def echo? - @response.echo? - end + def echo? + @response.echo? + end - def app_id - @response.app_id - end + def app_id + @response.app_id + end - def sent_from_chatwoot_app? - app_id && app_id == ENV['FB_APP_ID'].to_i - end - end + def sent_from_chatwoot_app? + app_id && app_id == ENV['FB_APP_ID'].to_i end end diff --git a/lib/integrations/widget/incoming_message_builder.rb b/lib/integrations/widget/incoming_message_builder.rb index fb3f32984..abc14aafa 100644 --- a/lib/integrations/widget/incoming_message_builder.rb +++ b/lib/integrations/widget/incoming_message_builder.rb @@ -1,61 +1,57 @@ # frozen_string_literal: true -module Integrations - module Widget - class Integrations::Widget::IncomingMessageBuilder - # params = { - # contact_id: 1, - # inbox_id: 1, - # content: "Hello world" - # } +class Integrations::Widget::IncomingMessageBuilder + # params = { + # contact_id: 1, + # inbox_id: 1, + # content: "Hello world" + # } - attr_accessor :options, :message + attr_accessor :options, :message - def initialize(options) - @options = options - end + def initialize(options) + @options = options + end - def perform - ActiveRecord::Base.transaction do - build_message - end - end - - private - - def inbox - @inbox ||= Inbox.find(options[:inbox_id]) - end - - def contact - @contact ||= Contact.find(options[:contact_id]) - end - - def conversation - @conversation ||= Conversation.find_by(conversation_params) || Conversation.create!(conversation_params) - end - - def build_message - @message = conversation.messages.new(message_params) - @message.save! - end - - def conversation_params - { - account_id: inbox.account_id, - inbox_id: inbox.id, - contact_id: options[:contact_id] - } - end - - def message_params - { - account_id: conversation.account_id, - inbox_id: conversation.inbox_id, - message_type: 0, - content: options[:content] - } - end + def perform + ActiveRecord::Base.transaction do + build_message end end + + private + + def inbox + @inbox ||= Inbox.find(options[:inbox_id]) + end + + def contact + @contact ||= Contact.find(options[:contact_id]) + end + + def conversation + @conversation ||= Conversation.find_by(conversation_params) || Conversation.create!(conversation_params) + end + + def build_message + @message = conversation.messages.new(message_params) + @message.save! + end + + def conversation_params + { + account_id: inbox.account_id, + inbox_id: inbox.id, + contact_id: options[:contact_id] + } + end + + def message_params + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: 0, + content: options[:content] + } + end end diff --git a/spec/actions/contact_merge_action_spec.rb b/spec/actions/contact_merge_action_spec.rb index 7ffcaf1fa..bf95b7291 100644 --- a/spec/actions/contact_merge_action_spec.rb +++ b/spec/actions/contact_merge_action_spec.rb @@ -9,9 +9,7 @@ describe ::ContactMergeAction do before do 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(:contact_inbox, contact: mergee_contact) } end describe '#perform' do diff --git a/spec/builders/messages/message_builder_spec.rb b/spec/builders/messages/incoming_message_builder_spec.rb similarity index 96% rename from spec/builders/messages/message_builder_spec.rb rename to spec/builders/messages/incoming_message_builder_spec.rb index f86489066..0e91d3ae6 100644 --- a/spec/builders/messages/message_builder_spec.rb +++ b/spec/builders/messages/incoming_message_builder_spec.rb @@ -1,6 +1,6 @@ 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 } let!(:facebook_channel) { create(:channel_facebook_page) } diff --git a/spec/controllers/api/v1/widget/messages_controller_spec.rb b/spec/controllers/api/v1/widget/messages_controller_spec.rb new file mode 100644 index 000000000..213335eeb --- /dev/null +++ b/spec/controllers/api/v1/widget/messages_controller_spec.rb @@ -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 diff --git a/spec/controllers/widget_tests_controller_spec.rb b/spec/controllers/widget_tests_controller_spec.rb index 5d0d6ccb8..04c5aa880 100644 --- a/spec/controllers/widget_tests_controller_spec.rb +++ b/spec/controllers/widget_tests_controller_spec.rb @@ -1,12 +1,14 @@ require 'rails_helper' -describe WidgetTestsController, type: :controller do - let(:channel_widget) { create(:channel_widget) } +describe '/widget_tests', type: :request do + before do + create(:channel_widget) + end - describe '#index' do + describe 'GET /widget_tests' do it 'renders the page correctly' do - get :index - expect(response.status).to eq 200 + get widget_tests_url + expect(response).to be_successful end end end diff --git a/spec/controllers/widgets_controller_spec.rb b/spec/controllers/widgets_controller_spec.rb new file mode 100644 index 000000000..6f6eb9c34 --- /dev/null +++ b/spec/controllers/widgets_controller_spec.rb @@ -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 diff --git a/spec/factories/channel/channel_widget.rb b/spec/factories/channel/channel_widget.rb index 6925ebb52..e079d7db6 100644 --- a/spec/factories/channel/channel_widget.rb +++ b/spec/factories/channel/channel_widget.rb @@ -6,5 +6,8 @@ FactoryBot.define do sequence(:website_url) { |n| "https://example-#{n}.com" } sequence(:widget_color, &:to_s) account + after(:create) do |channel_widget| + create(:inbox, channel: channel_widget, account: channel_widget.account) + end end end diff --git a/spec/factories/conversations.rb b/spec/factories/conversations.rb index f89fc899c..a14845e7f 100644 --- a/spec/factories/conversations.rb +++ b/spec/factories/conversations.rb @@ -16,6 +16,7 @@ FactoryBot.define do channel: create(:channel_widget, 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 diff --git a/spec/factories/inboxes.rb b/spec/factories/inboxes.rb index 5017c3d3d..cdd23f3c3 100644 --- a/spec/factories/inboxes.rb +++ b/spec/factories/inboxes.rb @@ -3,7 +3,11 @@ FactoryBot.define do factory :inbox do account - name { 'Inbox' } channel { FactoryBot.build(:channel_widget, account: account) } + name { 'Inbox' } + + after(:create) do |inbox| + inbox.channel.save! + end end end diff --git a/spec/factories/messages.rb b/spec/factories/messages.rb index 4c60c1d04..6f9e37a5d 100644 --- a/spec/factories/messages.rb +++ b/spec/factories/messages.rb @@ -5,9 +5,13 @@ FactoryBot.define do content { 'Message' } status { 'sent' } message_type { 'incoming' } - account - inbox - conversation - user + content_type { 'text' } + account { create(:account) } + + 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 diff --git a/spec/finders/message_finder_spec.rb b/spec/finders/message_finder_spec.rb index ca05a510d..a8da9a56f 100644 --- a/spec/finders/message_finder_spec.rb +++ b/spec/finders/message_finder_spec.rb @@ -21,7 +21,7 @@ describe ::MessageFinder do it 'filter conversations by status' do result = message_finder.perform - expect(result.count).to be 4 + expect(result.count).to be 7 end end @@ -30,7 +30,7 @@ describe ::MessageFinder do it 'filter conversations by status' do result = message_finder.perform - expect(result.count).to be 2 + expect(result.count).to be 5 end end @@ -40,7 +40,7 @@ describe ::MessageFinder do it 'filter conversations by status' do result = message_finder.perform - expect(result.count).to be 4 + expect(result.count).to be 7 end end end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 376ee6f25..e2d44668d 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -57,19 +57,17 @@ RSpec.describe Conversation, type: :model do expect(Rails.configuration.dispatcher).to have_received(:dispatch) .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 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? 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 describe '.after_create' do @@ -169,7 +167,7 @@ RSpec.describe Conversation, type: :model do end it 'returns unread messages' do - expect(unread_messages).to contain_exactly(message) + expect(unread_messages).to include(message) end end diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb new file mode 100644 index 000000000..6df065b24 --- /dev/null +++ b/spec/models/message_spec.rb @@ -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 diff --git a/spec/services/facebook/send_reply_service_spec.rb b/spec/services/facebook/send_reply_service_spec.rb index f926fa833..4e8bc9097 100644 --- a/spec/services/facebook/send_reply_service_spec.rb +++ b/spec/services/facebook/send_reply_service_spec.rb @@ -14,7 +14,8 @@ describe Facebook::SendReplyService do let!(:facebook_channel) { create(:facebook_page, account: account) } let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, 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 context 'without reply' do @@ -41,7 +42,6 @@ describe Facebook::SendReplyService do context 'with reply' 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: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation) expect(bot).to have_received(:deliver) diff --git a/spec/services/message_templates/hook_execution_service_spec.rb b/spec/services/message_templates/hook_execution_service_spec.rb new file mode 100644 index 000000000..ff137c33b --- /dev/null +++ b/spec/services/message_templates/hook_execution_service_spec.rb @@ -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 diff --git a/spec/services/message_templates/template/email_collect_spec.rb b/spec/services/message_templates/template/email_collect_spec.rb new file mode 100644 index 000000000..1d688e37a --- /dev/null +++ b/spec/services/message_templates/template/email_collect_spec.rb @@ -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