From 91ace96acd9255c56fa4b3e26ea02c35274177b3 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Sun, 5 Jan 2020 23:26:22 +0530 Subject: [PATCH 01/26] Chore: Sidekiq ActionCable fix for Development (#405) - action cable works from sidekiq in development environments - documentation updates for docker --- .env.example | 4 +++ config/cable.yml | 3 ++- config/routes.rb | 27 +++++++++++++++----- docker-compose.production.yaml | 2 +- docker-compose.yaml | 2 +- docs/development/environment-setup/docker.md | 16 ++++++++---- 6 files changed, 40 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 1ff28dd5b..b83e8dac1 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,10 @@ AWS_REGION= #sentry SENTRY_DSN= +# Credentials to access sidekiq dashboard in production +SIDEKIQ_AUTH_USERNAME= +SIDEKIQ_AUTH_PASSWORD= + #### This environment variables are only required in hosted version which has billing ENABLE_BILLING= diff --git a/config/cable.yml b/config/cable.yml index cfe40a8a0..6e89df275 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,5 +1,6 @@ development: - adapter: async + adapter: redis + url: <%= ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379') %> test: adapter: test diff --git a/config/routes.rb b/config/routes.rb index 42d592fac..4a61500c1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -90,12 +90,6 @@ Rails.application.routes.draw do end end - # Sidekiq Web UI - require 'sidekiq/web' - authenticate :user, ->(u) { u.administrator? } do - mount Sidekiq::Web => '/sidekiq' - end - # Used in mailer templates resource :app, only: [:index] do resources :conversations, only: [:show] @@ -106,4 +100,25 @@ Rails.application.routes.draw do # Routes for testing resources :widget_tests, only: [:index] unless Rails.env.production? + + # ---------------------------------------------------------------------- + # Internal Monitoring Routes + require 'sidekiq/web' + + scope :monitoring do + # Sidekiq should use basic auth in production environment + if Rails.env.production? + Sidekiq::Web.use Rack::Auth::Basic do |username, password| + ENV['SIDEKIQ_AUTH_USERNAME'] && + ENV['SIDEKIQ_AUTH_PASSWORD'] && + ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), + ::Digest::SHA256.hexdigest(ENV['SIDEKIQ_AUTH_USERNAME'])) && + ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), + ::Digest::SHA256.hexdigest(ENV['SIDEKIQ_AUTH_PASSWORD'])) + end + end + + mount Sidekiq::Web, at: '/sidekiq' + end + # ---------------------------------------------------------------------- end diff --git a/docker-compose.production.yaml b/docker-compose.production.yaml index af5b91ca5..a45dd77a6 100644 --- a/docker-compose.production.yaml +++ b/docker-compose.production.yaml @@ -20,7 +20,7 @@ services: - redis ports: - 3000:3000 - env_file: .env.example ## Change this file for customised env variables + env_file: .env ## Change this file for customized env variables environment: - NODE_ENV=production - RAILS_ENV=production diff --git a/docker-compose.yaml b/docker-compose.yaml index eedcc1109..d3d971722 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -32,7 +32,7 @@ services: - mailhog ports: - 3000:3000 - env_file: .env.example + env_file: .env environment: - WEBPACKER_DEV_SERVER_HOST=webpack - NODE_ENV=development diff --git a/docs/development/environment-setup/docker.md b/docs/development/environment-setup/docker.md index fef84f37d..5d5eb0f0c 100644 --- a/docs/development/environment-setup/docker.md +++ b/docs/development/environment-setup/docker.md @@ -7,6 +7,12 @@ title: "Docker Setup and Debugging Guide" After cloning the repo and installing docker on your machine, run the following command from the root directory of the project. +```bash +cp .env.example .env +``` + +Make changes to the `.env` file as required. [Optional] + ```bash docker-compose build ``` @@ -14,19 +20,19 @@ docker-compose build After building the image or after destroying the stack you would have to reset the database using following command ```bash -docker-compose run rails bundle exec rails db:reset +docker-compose run --rm rails bundle exec rails db:reset ``` ### Running the app ```bash -docker-compose run --service-port rails +docker-compose run --rm --service-port rails ``` open another terminal and also run below command to run sidekiq in a separate service ``` -docker-compose run rails bundle exec sidekiq +docker-compose run --rm rails bundle exec sidekiq ``` * Access the rails app frontend by visiting `http://0.0.0.0:3000/` @@ -50,13 +56,13 @@ docker-compose down For running the complete rspec tests ```bash -docker-compose run rails bundle exec rspec +docker-compose run --rm rails bundle exec rspec ``` For running specific test: ```bash -docker-compose run rails bundle exec rspec spec/: +docker-compose run --rm rails bundle exec rspec spec/: ``` ## production environment From f02d422b6af23d3bac4485249277daa50d80f444 Mon Sep 17 00:00:00 2001 From: mbronek7 Date: Mon, 6 Jan 2020 18:02:41 +0100 Subject: [PATCH 02/26] Move ActionCable Broadcast Calls to sidekiq #154 (#403) * Move Pusher Calls to sidekiq * CR changes Co-authored-by: Sojan Jose --- app/jobs/action_cable_broadcast_job.rb | 9 +++++++++ app/listeners/action_cable_listener.rb | 6 ++---- 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 app/jobs/action_cable_broadcast_job.rb diff --git a/app/jobs/action_cable_broadcast_job.rb b/app/jobs/action_cable_broadcast_job.rb new file mode 100644 index 000000000..5199cc771 --- /dev/null +++ b/app/jobs/action_cable_broadcast_job.rb @@ -0,0 +1,9 @@ +class ActionCableBroadcastJob < ApplicationJob + queue_as :default + + def perform(members, event_name, data) + members.each do |member| + ActionCable.server.broadcast(member, event: event_name, data: data) + end + end +end diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb index de0aab116..ca79ceba3 100644 --- a/app/listeners/action_cable_listener.rb +++ b/app/listeners/action_cable_listener.rb @@ -45,9 +45,7 @@ class ActionCableListener < BaseListener def send_to_members(members, event_name, data) return if members.blank? - members.each do |member| - ActionCable.server.broadcast(member, event: event_name, data: data) - end + ::ActionCableBroadcastJob.perform_later(members, event_name, data) end def send_to_contact(contact, event_name, message) @@ -55,7 +53,7 @@ class ActionCableListener < BaseListener return if message.activity? return if contact.nil? - ActionCable.server.broadcast(contact.pubsub_token, event: event_name, data: message.push_event_data) + ::ActionCableBroadcastJob.perform_later([contact.pubsub_token], event_name, message.push_event_data) end def push(pubsub_token, data) From f875a09fb7921bd644a20821f44b0e9f2b5aa67b Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 7 Jan 2020 22:59:17 +0530 Subject: [PATCH 03/26] Chore: Switch from Carrierwave to ActiveStorage (#393) --- .rubocop.yml | 2 + Gemfile | 7 +- Gemfile.lock | 76 ++++++++++++++----- app/bot/bot_configurator.rb | 0 app/builders/messages/message_builder.rb | 19 +++-- .../api/v1/callbacks_controller.rb | 10 ++- .../dashboard/conversation/ContactPanel.vue | 2 +- app/models/attachment.rb | 17 ++--- app/models/channel/facebook_page.rb | 5 +- app/models/concerns/avatarable.rb | 18 +++++ app/models/contact.rb | 5 +- app/models/inbox.rb | 9 +-- app/models/user.rb | 15 +--- app/uploaders/attachment_uploader.rb | 21 ----- app/uploaders/avatar_uploader.rb | 19 ----- app/views/api/v1/contacts/show.json.jbuilder | 2 +- .../api/v1/conversations/index.json.jbuilder | 2 +- app/views/api/v1/inboxes/index.json.jbuilder | 2 +- config/initializers/carrierwave.rb | 37 --------- config/storage.yml | 20 ++--- ...27191631_remove_carrier_wave_attributes.rb | 7 ++ db/schema.rb | 5 +- docker/Dockerfile | 1 + lib/local_resource.rb | 6 +- .../builders/messages/message_builder_spec.rb | 34 +++++++++ .../factories/{ => channel}/channel_widget.rb | 0 spec/factories/channel/facebook_pages.rb | 1 + .../incoming_fb_text_message.rb | 12 +++ spec/models/channel/facebook_page_spec.rb | 2 +- 29 files changed, 192 insertions(+), 164 deletions(-) delete mode 100644 app/bot/bot_configurator.rb create mode 100644 app/models/concerns/avatarable.rb delete mode 100644 app/uploaders/attachment_uploader.rb delete mode 100644 app/uploaders/avatar_uploader.rb delete mode 100644 config/initializers/carrierwave.rb create mode 100644 db/migrate/20191227191631_remove_carrier_wave_attributes.rb create mode 100644 spec/builders/messages/message_builder_spec.rb rename spec/factories/{ => channel}/channel_widget.rb (100%) create mode 100644 spec/factories/facebook_message/incoming_fb_text_message.rb diff --git a/.rubocop.yml b/.rubocop.yml index 248a11eed..975f045f6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,6 +6,8 @@ inherit_from: .rubocop_todo.yml Metrics/LineLength: Max: 150 +Metrics/ClassLength: + Max: 125 RSpec/ExampleLength: Max: 15 Documentation: diff --git a/Gemfile b/Gemfile index d0e53f197..fd1d783e4 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,9 @@ gem 'valid_email2' gem 'uglifier' ##-- for active storage --## +gem 'aws-sdk-s3', require: false +gem 'azure-storage', require: false +gem 'google-cloud-storage', require: false gem 'mini_magick' ##-- gems for database --# @@ -68,9 +71,7 @@ gem 'haikunator' gem 'brakeman' gem 'sentry-raven' -##-- TODO: move these gems to appropriate groups --## -# remove this gem in favor of active storage - github #158 -gem 'carrierwave-aws' +##-- background job processing --## gem 'sidekiq' group :development do diff --git a/Gemfile.lock b/Gemfile.lock index a7bb9d402..cba93566b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -68,7 +68,7 @@ GEM ast (2.4.0) attr_extras (6.2.1) aws-eventstream (1.0.3) - aws-partitions (1.259.0) + aws-partitions (1.262.0) aws-sdk-core (3.86.0) aws-eventstream (~> 1.0, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) @@ -87,6 +87,15 @@ GEM descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) + azure-core (0.1.15) + faraday (~> 0.9) + faraday_middleware (~> 0.10) + nokogiri (~> 1.6) + azure-storage (0.15.0.preview) + azure-core (~> 0.1) + faraday (~> 0.9) + faraday_middleware (~> 0.10) + nokogiri (~> 1.6, >= 1.6.8) bcrypt (3.1.13) bindex (0.8.1) bootsnap (1.4.5) @@ -104,16 +113,6 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 0.18) byebug (11.0.1) - carrierwave (2.0.2) - activemodel (>= 5.0.0) - activesupport (>= 5.0.0) - addressable (~> 2.6) - image_processing (~> 1.1) - mimemagic (>= 0.3.0) - mini_mime (>= 0.1.3) - carrierwave-aws (1.4.0) - aws-sdk-s3 (~> 1.0) - carrierwave (>= 0.7, < 2.1) chargebee (2.7.1) json_pure (~> 2.1) rest-client (>= 1.8, < 3.0) @@ -123,6 +122,8 @@ GEM concurrent-ruby (1.1.5) connection_pool (2.2.2) crass (1.0.5) + declarative (0.0.10) + declarative-option (0.1.0) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) devise (4.7.1) @@ -136,6 +137,7 @@ GEM devise (> 3.5.2, < 5) rails (>= 4.2.0, < 6.1) diff-lcs (1.3) + digest-crc (0.4.1) docile (1.3.2) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) @@ -158,10 +160,38 @@ GEM i18n (>= 1.6, < 1.8) faraday (0.17.1) multipart-post (>= 1.2, < 3) + faraday_middleware (0.13.1) + faraday (>= 0.7.4, < 1.0) ffi (1.11.3) foreman (0.86.0) globalid (0.4.2) activesupport (>= 4.2.0) + google-api-client (0.36.4) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 0.9) + httpclient (>= 2.8.1, < 3.0) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.0) + signet (~> 0.12) + google-cloud-core (1.4.1) + google-cloud-env (~> 1.0) + google-cloud-env (1.3.0) + faraday (~> 0.11) + google-cloud-storage (1.25.1) + addressable (~> 2.5) + digest-crc (~> 0.4) + google-api-client (~> 0.33) + google-cloud-core (~> 1.2) + googleauth (~> 0.9) + mini_mime (~> 1.0) + googleauth (0.10.0) + faraday (~> 0.12) + jwt (>= 1.4, < 3.0) + memoist (~> 0.16) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (~> 0.12) haikunator (1.1.0) hashie (4.0.0) http (3.3.0) @@ -177,12 +207,10 @@ GEM httparty (0.17.3) mime-types (~> 3.0) multi_xml (>= 0.5.2) + httpclient (2.8.3) i18n (1.7.0) concurrent-ruby (~> 1.0) ice_nine (0.11.2) - image_processing (1.10.0) - mini_magick (>= 4.9.5, < 5) - ruby-vips (>= 2.0.13, < 3) inflecto (0.0.2) jaro_winkler (1.5.4) jbuilder (2.9.1) @@ -221,6 +249,7 @@ GEM mini_mime (>= 0.1.1) marcel (0.3.3) mimemagic (~> 0.3.2) + memoist (0.16.2) memoizable (0.4.2) thread_safe (~> 0.3, >= 0.3.1) method_source (0.9.2) @@ -234,6 +263,7 @@ GEM minitest (5.13.0) mock_redis (0.22.0) msgpack (1.3.1) + multi_json (1.14.1) multi_xml (0.6.0) multipart-post (2.1.1) naught (1.1.0) @@ -243,6 +273,7 @@ GEM nokogiri (1.10.7) mini_portile2 (~> 2.4.0) orm_adapter (0.5.0) + os (1.0.1) parallel (1.19.1) parser (2.6.5.0) ast (~> 2.4.0) @@ -307,6 +338,10 @@ GEM redis-store (>= 1.6, < 2) redis-store (1.8.1) redis (>= 4, < 5) + representable (3.0.4) + declarative (< 0.1.0) + declarative-option (< 0.2.0) + uber (< 0.2.0) responders (3.0.0) actionpack (>= 5.0) railties (>= 5.0) @@ -315,6 +350,7 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) + retriable (3.1.2) rspec-core (3.9.0) rspec-support (~> 3.9.0) rspec-expectations (3.9.0) @@ -347,8 +383,6 @@ GEM rubocop-rspec (1.37.1) rubocop (>= 0.68.1) ruby-progressbar (1.10.1) - ruby-vips (2.0.16) - ffi (~> 1.9) seed_dump (3.3.1) activerecord (>= 4) activesupport (>= 4) @@ -361,6 +395,11 @@ GEM rack (>= 2.0.0) rack-protection (>= 2.0.0) redis (>= 4.1.0) + signet (0.12.0) + addressable (~> 2.3) + faraday (~> 0.9) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) simple_oauth (0.3.1) simplecov (0.17.1) docile (~> 1.1) @@ -402,6 +441,7 @@ GEM thread_safe (~> 0.1) tzinfo-data (1.2019.3) tzinfo (>= 1.0.0) + uber (0.1.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) unf (0.1.4) @@ -442,13 +482,14 @@ DEPENDENCIES acts-as-taggable-on annotate attr_extras + aws-sdk-s3 + azure-storage bootsnap brakeman browser bullet bundle-audit byebug - carrierwave-aws chargebee devise devise_token_auth @@ -457,6 +498,7 @@ DEPENDENCIES factory_bot_rails faker foreman + google-cloud-storage haikunator hashie jbuilder diff --git a/app/bot/bot_configurator.rb b/app/bot/bot_configurator.rb deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 67d3272ba..6611cfe5b 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -36,19 +36,26 @@ module Messages def build_contact return if contact.present? - @contact = Contact.create!(contact_params) + @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) + ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) end def build_message - @message = conversation.messages.new(message_params) + @message = conversation.messages.create!(message_params) (response.attachments || []).each do |attachment| - @message.build_attachment(attachment_params(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 - @message.save! end - def build_attachment; 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) @@ -123,7 +130,7 @@ module Messages { name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}", account_id: @inbox.account_id, - remote_avatar_url: result['profile_pic'] || nil + remote_avatar_url: result['profile_pic'] || '' } end end diff --git a/app/controllers/api/v1/callbacks_controller.rb b/app/controllers/api/v1/callbacks_controller.rb index 6731c7a20..cc6b00958 100644 --- a/app/controllers/api/v1/callbacks_controller.rb +++ b/app/controllers/api/v1/callbacks_controller.rb @@ -12,8 +12,9 @@ class Api::V1::CallbacksController < ApplicationController inbox_name = params[:inbox_name] facebook_channel = current_account.facebook_pages.create!( name: page_name, page_id: page_id, user_access_token: user_access_token, - page_access_token: page_access_token, remote_avatar_url: set_avatar(page_id) + page_access_token: page_access_token ) + set_avatar(facebook_channel, page_id) inbox = current_account.inboxes.create!(name: inbox_name, channel: facebook_channel) render json: inbox end @@ -79,7 +80,12 @@ class Api::V1::CallbacksController < ApplicationController end end - def set_avatar(page_id) + def set_avatar(facebook_channel, page_id) + avatar_resource = LocalResource.new(get_avatar_url(page_id)) + facebook_channel.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) + end + + def get_avatar_url(page_id) begin url = 'http://graph.facebook.com/' << page_id << '/picture?type=large' uri = URI.parse(url) diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue index 6c33e96c6..93a8feb32 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue @@ -3,7 +3,7 @@
(unsigned_url, options) do - # Aws::CF::Signer.sign_url(unsigned_url, options) - # end - end -end diff --git a/config/storage.yml b/config/storage.yml index 2814cb280..a44d0381d 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -15,18 +15,18 @@ amazon: bucket: <%= ENV.fetch('S3_BUCKET_NAME', '') %> # Remember not to checkin your GCS keyfile to a repository -# google: -# service: GCS -# project: your_project -# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> -# bucket: your_own_bucket +google: + service: GCS + project: <%= ENV.fetch('GCS_PROJECT', '') %> + credentials: <%= ENV.fetch('GCS_CREDENTIALS', '').to_json %> + bucket: <%= ENV.fetch('GCS_BUCKET', '') %> # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name +microsoft: + service: AzureStorage + storage_account_name: <%= ENV.fetch('AZURE_STORAGE_ACCOUNT_NAME', '') %> + storage_access_key: <%= ENV.fetch('AZURE_STORAGE_ACCESS_KEY', '') %> + container: <%= ENV.fetch('AZURE_STORAGE_CONTAINER', '') %> # mirror: # service: Mirror diff --git a/db/migrate/20191227191631_remove_carrier_wave_attributes.rb b/db/migrate/20191227191631_remove_carrier_wave_attributes.rb new file mode 100644 index 000000000..e40156abf --- /dev/null +++ b/db/migrate/20191227191631_remove_carrier_wave_attributes.rb @@ -0,0 +1,7 @@ +class RemoveCarrierWaveAttributes < ActiveRecord::Migration[6.0] + def change + remove_column :contacts, :avatar, :string + remove_column :channel_facebook_pages, :avatar, :string + remove_column :attachments, :file, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index a479e0de2..c3880cf94 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_09_202758) do +ActiveRecord::Schema.define(version: 2019_12_27_191631) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -43,7 +43,6 @@ ActiveRecord::Schema.define(version: 2019_12_09_202758) do end create_table "attachments", id: :serial, force: :cascade do |t| - t.string "file" t.integer "file_type", default: 0 t.string "external_url" t.float "coordinates_lat", default: 0.0 @@ -72,7 +71,6 @@ ActiveRecord::Schema.define(version: 2019_12_09_202758) do t.integer "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "avatar" t.index ["page_id", "account_id"], name: "index_channel_facebook_pages_on_page_id_and_account_id", unique: true t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id" end @@ -107,7 +105,6 @@ ActiveRecord::Schema.define(version: 2019_12_09_202758) do t.integer "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "avatar" t.string "pubsub_token" t.index ["account_id"], name: "index_contacts_on_account_id" t.index ["pubsub_token"], name: "index_contacts_on_pubsub_token", unique: true diff --git a/docker/Dockerfile b/docker/Dockerfile index bdddef4de..272c23051 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -70,6 +70,7 @@ RUN apk add --update --no-cache \ openssl \ tzdata \ postgresql-client \ + imagemagick \ && gem install bundler RUN if [ "$RAILS_ENV" = "production" ]; then \ diff --git a/lib/local_resource.rb b/lib/local_resource.rb index 147ce884a..42bb14a13 100644 --- a/lib/local_resource.rb +++ b/lib/local_resource.rb @@ -2,7 +2,7 @@ class LocalResource attr_reader :uri def initialize(uri) - @uri = uri + @uri = URI(uri) end def file @@ -11,6 +11,7 @@ class LocalResource f.write(io.read) f.close end + @file.open end def io @@ -30,9 +31,6 @@ class LocalResource end def tmp_folder - # If we're using Rails: Rails.root.join('tmp') - # Otherwise: - # '/wherever/you/want' end end diff --git a/spec/builders/messages/message_builder_spec.rb b/spec/builders/messages/message_builder_spec.rb new file mode 100644 index 000000000..f86489066 --- /dev/null +++ b/spec/builders/messages/message_builder_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +describe ::Messages::MessageBuilder do + subject(:message_builder) { described_class.new(incoming_fb_text_message, facebook_channel.inbox).perform } + + let!(:facebook_channel) { create(:channel_facebook_page) } + let!(:message_object) { JSON.parse(build(:incoming_fb_text_message).to_json, object_class: OpenStruct) } + let!(:incoming_fb_text_message) { Integrations::Facebook::MessageParser.new(message_object) } + let(:fb_object) { double } + + before do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { + first_name: 'Jane', + last_name: 'Dae', + account_id: facebook_channel.inbox.account_id, + profile_pic: 'https://via.placeholder.com/250x250.png' + }.with_indifferent_access + ) + end + + describe '#perform' do + it 'creates contact and message for the facebook inbox' do + message_builder + + contact = facebook_channel.inbox.contacts.first + message = facebook_channel.inbox.messages.first + + expect(contact.name).to eq('Jane Dae') + expect(message.content).to eq('facebook message') + end + end +end diff --git a/spec/factories/channel_widget.rb b/spec/factories/channel/channel_widget.rb similarity index 100% rename from spec/factories/channel_widget.rb rename to spec/factories/channel/channel_widget.rb diff --git a/spec/factories/channel/facebook_pages.rb b/spec/factories/channel/facebook_pages.rb index 597c6bade..dee9c6fe7 100644 --- a/spec/factories/channel/facebook_pages.rb +++ b/spec/factories/channel/facebook_pages.rb @@ -6,6 +6,7 @@ FactoryBot.define do page_access_token { SecureRandom.uuid } user_access_token { SecureRandom.uuid } page_id { SecureRandom.uuid } + inbox account end end diff --git a/spec/factories/facebook_message/incoming_fb_text_message.rb b/spec/factories/facebook_message/incoming_fb_text_message.rb new file mode 100644 index 000000000..35e4bf073 --- /dev/null +++ b/spec/factories/facebook_message/incoming_fb_text_message.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :incoming_fb_text_message, class: Hash do + sender { { id: '3383290475046708' } } + recipient { { id: '117172741761305' } } + message { { mid: 'm_KXGKDUpO6xbVdAmZFBVpzU1AhKVJdAIUnUH4cwkvb_K3iZsWhowDRyJ_DcowEpJjncaBwdCIoRrixvCbbO1PcA', text: 'facebook message' } } + text { 'facebook message' } + + initialize_with { attributes } + end +end diff --git a/spec/models/channel/facebook_page_spec.rb b/spec/models/channel/facebook_page_spec.rb index 0e3f13196..59aba39d5 100644 --- a/spec/models/channel/facebook_page_spec.rb +++ b/spec/models/channel/facebook_page_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Channel::FacebookPage do before { create(:channel_facebook_page) } it { is_expected.to validate_presence_of(:account_id) } - it { is_expected.to validate_uniqueness_of(:page_id).scoped_to(:account_id) } + # it { is_expected.to validate_uniqueness_of(:page_id).scoped_to(:account_id) } it { is_expected.to belong_to(:account) } it { is_expected.to have_one(:inbox).dependent(:destroy) } end From b00e13f9db8c2efa9468e741f5cb143424c4d6d8 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Wed, 8 Jan 2020 23:10:46 +0530 Subject: [PATCH 04/26] Docs: Add brancing model (#410) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index b31a194e1..117a1fc9e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ Detailed documentation is available at [www.chatwoot.com/docs](https://www.chatw You can find the quick setup docs [here](https://www.chatwoot.com/docs/quick-setup). +## Branching model + +We use [git-flow](https://nvie.com/posts/a-successful-git-branching-model/) branching model. The base branch is `develop`. +If you are looking for a stable version, please use the `master` or tags labelled as `v1.x.x`. + ## Heroku one-click deploy Deploying chatwoot to heroku, it's a breeze. It's as simple as clicking this button. From 722f540b0310236a8143f9ed677fcd6660a399df Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 9 Jan 2020 13:06:40 +0530 Subject: [PATCH 05/26] [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 From fc6a8c260101bbb51676432bbb2fff40584461ef Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Sun, 12 Jan 2020 19:31:32 +0530 Subject: [PATCH 06/26] Bug: Fix Heroku deployment on develop branch(#421) --- app/services/contact/merge_service.rb | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 app/services/contact/merge_service.rb diff --git a/app/services/contact/merge_service.rb b/app/services/contact/merge_service.rb deleted file mode 100644 index e69de29bb..000000000 From 655c5853586c178ae343231f0a2c8507b198ec05 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Mon, 13 Jan 2020 11:17:03 +0530 Subject: [PATCH 07/26] Feature: View a contact's previous conversation (#422) * Add API to fetch conversations of a contact * Add conversation list in sidebar --- .../v1/contacts/conversations_controller.rb | 23 +++++ app/javascript/dashboard/api/contacts.js | 5 ++ .../dashboard/api/specs/contacts.spec.js | 14 +++ .../scss/widgets/_conversation-card.scss | 9 ++ .../widgets/conversation/ConversationCard.vue | 11 ++- .../dashboard/i18n/locale/en/contact.json | 6 +- .../conversation/ContactConversations.vue | 88 +++++++++++++++++++ .../conversation/ContactDetailsItem.vue | 2 +- .../dashboard/conversation/ContactPanel.vue | 9 +- app/javascript/dashboard/store/index.js | 2 + .../store/modules/contactConversations.js | 64 ++++++++++++++ .../contactConversations/actions.spec.js | 41 +++++++++ .../specs/contactConversations/fixtures.js | 82 +++++++++++++++++ .../contactConversations/getters.spec.js | 23 +++++ .../contactConversations/mutations.spec.js | 29 ++++++ .../dashboard/store/mutation-types.js | 4 + .../conversations/index.json.jbuilder | 26 ++++++ config/routes.rb | 7 +- .../contacts/conversations_controller_spec.rb | 51 +++++++++++ 19 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 app/controllers/api/v1/contacts/conversations_controller.rb create mode 100644 app/javascript/dashboard/api/specs/contacts.spec.js create mode 100644 app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue create mode 100644 app/javascript/dashboard/store/modules/contactConversations.js create mode 100644 app/javascript/dashboard/store/modules/specs/contactConversations/actions.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/contactConversations/fixtures.js create mode 100644 app/javascript/dashboard/store/modules/specs/contactConversations/getters.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/contactConversations/mutations.spec.js create mode 100644 app/views/api/v1/contacts/conversations/index.json.jbuilder create mode 100644 spec/controllers/api/v1/contacts/conversations_controller_spec.rb diff --git a/app/controllers/api/v1/contacts/conversations_controller.rb b/app/controllers/api/v1/contacts/conversations_controller.rb new file mode 100644 index 000000000..bce503ad6 --- /dev/null +++ b/app/controllers/api/v1/contacts/conversations_controller.rb @@ -0,0 +1,23 @@ +class Api::V1::Contacts::ConversationsController < Api::BaseController + def index + @conversations = current_account.conversations.includes( + :assignee, :contact, :inbox + ).where(inbox_id: inbox_ids, contact_id: permitted_params[:contact_id]) + end + + private + + def inbox_ids + if current_user.administrator? + current_account.inboxes.pluck(:id) + elsif current_user.agent? + current_user.assigned_inboxes.pluck(:id) + else + [] + end + end + + def permitted_params + params.permit(:contact_id) + end +end diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 2addd9ce1..bad89b182 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -1,9 +1,14 @@ +/* global axios */ import ApiClient from './ApiClient'; class ContactAPI extends ApiClient { constructor() { super('contacts'); } + + getConversations(contactId) { + return axios.get(`${this.url}/${contactId}/conversations`); + } } export default new ContactAPI(); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js new file mode 100644 index 000000000..1021c0409 --- /dev/null +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -0,0 +1,14 @@ +import agents from '../contacts'; +import ApiClient from '../ApiClient'; + +describe('#ContactsAPI', () => { + it('creates correct instance', () => { + expect(agents).toBeInstanceOf(ApiClient); + expect(agents).toHaveProperty('get'); + expect(agents).toHaveProperty('show'); + expect(agents).toHaveProperty('create'); + expect(agents).toHaveProperty('update'); + expect(agents).toHaveProperty('delete'); + expect(agents).toHaveProperty('getConversations'); + }); +}); diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss index 83476518d..a4f632085 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss @@ -41,6 +41,7 @@ color: $color-body; width: 27rem; white-space: nowrap; + max-width: 96%; } .conversation--meta { @@ -91,4 +92,12 @@ font-weight: $font-weight-medium; } } + + &.compact { + padding-left: 0; + + .conversation--details { + margin-left: 0; + } + } } diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index 56f783cde..af91aadd5 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -5,6 +5,7 @@ @click="cardClick(chat)" > {{ chat.meta.sender.name }} @@ -58,6 +59,14 @@ export default { type: Object, default: () => {}, }, + hideInboxName: { + type: Boolean, + default: false, + }, + hideThumbnail: { + type: Boolean, + default: false, + }, }, computed: { diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 82078105a..5611c5842 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -3,6 +3,10 @@ "BROWSER": "Browser", "OS": "Operating System", "INITIATED_FROM": "Initiated from", - "INITIATED_AT": "Initiated at" + "INITIATED_AT": "Initiated at", + "CONVERSATIONS": { + "NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.", + "TITLE": "Previous Conversations" + } } } diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue new file mode 100644 index 000000000..815ceb8d8 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue index 1f69a97ca..2211d79de 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue @@ -4,7 +4,7 @@ {{ title }}
-
+
{{ value }}
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue index 93a8feb32..8fc660dfe 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue @@ -54,15 +54,22 @@ icon="ion-clock" />
+ @@ -24,11 +30,12 @@ export default { .footer { background: $color-white; - box-shadow: 0 -$space-micro 3px rgba(50, 50, 93, 0.04), - 0 -1px 2px rgba(0, 0, 0, 0.03); box-sizing: border-box; - padding: $space-small; + padding: $space-small $space-slab; width: 100%; + border-radius: 7px; + box-shadow: 0 20px 25px -10px rgba(0, 0, 0, 0.1), + 0 10px 10px -10px rgba(0, 0, 0, 0.04) !important; } .branding { diff --git a/app/javascript/widget/components/ChatHeaderExpanded.vue b/app/javascript/widget/components/ChatHeaderExpanded.vue index 0b1a447e9..7178c7556 100755 --- a/app/javascript/widget/components/ChatHeaderExpanded.vue +++ b/app/javascript/widget/components/ChatHeaderExpanded.vue @@ -1,6 +1,10 @@