From a0ffefad717b632269883863c27242bb97d3b66d Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Thu, 16 Sep 2021 13:26:52 +0530 Subject: [PATCH 01/76] chore: Use the name of the sender from the mail object if the sender email is Sony Mathew Contact should be built with Sony Mathew Fixes #2911 --- app/mailboxes/support_mailbox.rb | 2 +- app/presenters/mail_presenter.rb | 4 + .../files/support_without_sender_name.eml | 631 ++++++++++++++++++ spec/mailboxes/support_mailbox_spec.rb | 17 + 4 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 spec/fixtures/files/support_without_sender_name.eml diff --git a/app/mailboxes/support_mailbox.rb b/app/mailboxes/support_mailbox.rb index 335bdefab..eefb5f13b 100644 --- a/app/mailboxes/support_mailbox.rb +++ b/app/mailboxes/support_mailbox.rb @@ -82,6 +82,6 @@ class SupportMailbox < ApplicationMailbox end def identify_contact_name - processed_mail.from.first.split('@').first + processed_mail.sender_name || processed_mail.from.first.split('@').first end end diff --git a/app/presenters/mail_presenter.rb b/app/presenters/mail_presenter.rb index 24b097a63..67acc31ec 100644 --- a/app/presenters/mail_presenter.rb +++ b/app/presenters/mail_presenter.rb @@ -82,6 +82,10 @@ class MailPresenter < SimpleDelegator @mail.from.map(&:downcase) end + def sender_name + Mail::Address.new(@mail[:from].value).name + end + def original_sender @mail['X-Original-Sender'].try(:value) || from.first end diff --git a/spec/fixtures/files/support_without_sender_name.eml b/spec/fixtures/files/support_without_sender_name.eml new file mode 100644 index 000000000..e472a6691 --- /dev/null +++ b/spec/fixtures/files/support_without_sender_name.eml @@ -0,0 +1,631 @@ +From: +Mime-Version: 1.0 (Apple Message framework v1244.3) +Content-Type: multipart/alternative; boundary="Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74" +Subject: Discussion: Let's debate these attachments +Date: Tue, 20 Apr 2020 04:20:20 -0400 +In-Reply-To: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +To: "Replies" +References: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +Message-Id: <0CB459E0-0336-41DA-BC88-E6E28C697DDB@chatwoot.com> +X-Mailer: Apple Mail (2.1244.3) + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; + charset=utf-8 + +Let's talk about these images: + + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Type: multipart/related; + type="text/html"; + boundary="Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1" + + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; + filename=avatar1.jpeg +Content-Type: image/jpg; + name="avatar1.jpeg" +Content-Id: <7AAEB353-2341-4D46-A054-5CA5CB2363B7> + +/9j/4AAQSkZJRgABAQAAAQABAAD/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdC +IFhZWiAHzgACAAkABgAxAABhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAA +AADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFj +cHJ0AAABUAAAADNkZXNjAAABhAAAAGx3dHB0AAAB8AAAABRia3B0AAACBAAAABRyWFlaAAACGAAA +ABRnWFlaAAACLAAAABRiWFlaAAACQAAAABRkbW5kAAACVAAAAHBkbWRkAAACxAAAAIh2dWVkAAAD +TAAAAIZ2aWV3AAAD1AAAACRsdW1pAAAD+AAAABRtZWFzAAAEDAAAACR0ZWNoAAAEMAAAAAxyVFJD +AAAEPAAACAxnVFJDAAAEPAAACAxiVFJDAAAEPAAACAx0ZXh0AAAAAENvcHlyaWdodCAoYykgMTk5 +OCBIZXdsZXR0LVBhY2thcmQgQ29tcGFueQAAZGVzYwAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEA +AAAAAAAAAAAAABJzUkdCIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAWFlaIAAAAAAAAPNRAAEAAAABFsxYWVogAAAAAAAAAAAAAAAA +AAAAAFhZWiAAAAAAAABvogAAOPUAAAOQWFlaIAAAAAAAAGKZAAC3hQAAGNpYWVogAAAAAAAAJKAA +AA+EAAC2z2Rlc2MAAAAAAAAAFklFQyBodHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAFklFQyBo +dHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAABkZXNjAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAsUmVmZXJlbmNlIFZpZXdpbmcg +Q29uZGl0aW9uIGluIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENv +bmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZpZXcAAAAA +ABOk/gAUXy4AEM8UAAPtzAAEEwsAA1yeAAAAAVhZWiAAAAAAAEwJVgBQAAAAVx/nbWVhcwAAAAAA +AAABAAAAAAAAAAAAAAAAAAAAAAAAAo8AAAACc2lnIAAAAABDUlQgY3VydgAAAAAAAAQAAAAABQAK +AA8AFAAZAB4AIwAoAC0AMgA3ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUA +mgCfAKQAqQCuALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEy +ATgBPgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMC +DAIUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMh +Ay0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA+AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH4E +jASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYGJwY3 +BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB/gICwgfCDII +RghaCG4IggiWCKoIvgjSCOcI+wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ+woRCicKPQpUCmoKgQqY +Cq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDPMNDQ0mDUAN +Wg10DY4NqQ3DDd4N+A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg+zD88P7BAJECYQQxBh +EH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIxNDE2MTgxOkE8UT +5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbWFvoXHRdBF2UXiReu +F9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGuwbFBs7G2MbihuyG9oc +AhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4faR+UH78f6iAVIEEgbCCY +IMQg8CEcIUghdSGhIc4h+yInIlUigiKvIt0jCiM4I2YjlCPCI/AkHyRNJHwkqyTaJQklOCVoJZcl +xyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg/KHEooijUKQYpOClrKZ0p0CoCKjUqaCqbKs8rAis2 +K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLyQvWi+RL8cv/jA1MGwwpDDbMRIx +SjGCMbox8jIqMmMymzLUMw0zRjN/M7gz8TQrNGU0njTYNRM1TTWHNcI1/TY3NnI2rjbpNyQ3YDec +N9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qjvoPCc8ZTykPOM9Ij1hPaE94D4gPmA+ +oD7gPyE/YT+iP+JAI0BkQKZA50EpQWpBrEHuQjBCckK1QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXe +RiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSjdKfUrESwxLU0uaS+JMKkxyTLpNAk1KTZNN +3E4lTm5Ot08AT0lPk0/dUCdQcVC7UQZRUFGbUeZSMVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYP +VlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVlqmWvVbRVuVW+VcNVyGXNZdJ114XcleGl5sXr1f +D19hX7NgBWBXYKpg/GFPYaJh9WJJYpxi8GNDY5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg/ +aJZo7GlDaZpp8WpIap9q92tPa6dr/2xXbK9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfBy +S3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyB +fOF9QX2hfgF+Yn7CfyN/hH/lgEeAqIEKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuH +n4gEiGmIzokziZmJ/opkisqLMIuWi/yMY4zKjTGNmI3/jmaOzo82j56QBpBukNaRP5GokhGSepLj +k02TtpQglIqU9JVflcmWNJaflwqXdZfgmEyYuJkkmZCZ/JpomtWbQpuvnByciZz3nWSd0p5Anq6f +HZ+Ln/qgaaDYoUehtqImopajBqN2o+akVqTHpTilqaYapoum/adup+CoUqjEqTepqaocqo+rAqt1 +q+msXKzQrUStuK4trqGvFq+LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm4 +0blKucK6O7q1uy67p7whvJu9Fb2Pvgq+hL7/v3q/9cBwwOzBZ8Hjwl/C28NYw9TEUcTOxUvFyMZG +xsPHQce/yD3IvMk6ybnKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI/0sHTRNPG1EnU +y9VO1dHWVdbY11zX4Nhk2OjZbNnx2nba+9uA3AXcit0Q3ZbeHN6i3ynfr+A24L3hROHM4lPi2+Nj +4+vkc+T85YTmDeaW5x/nqegy6LzpRunQ6lvq5etw6/vshu0R7ZzuKO6070DvzPBY8OXxcvH/8ozz +GfOn9DT0wvVQ9d72bfb794r4Gfio+Tj5x/pX+uf7d/wH/Jj9Kf26/kv+3P9t////2wBDAAICAgIC +AQICAgICAgIDAwYEAwMDAwcFBQQGCAcICAgHCAgJCg0LCQkMCggICw8LDA0ODg4OCQsQEQ8OEQ0O +Dg7/2wBDAQICAgMDAwYEBAYOCQgJDg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4O +Dg4ODg4ODg4ODg4ODg7/wAARCADwAPADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAEC +AwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0Kx +wRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1 +dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ +2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QA +tREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk +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 +mk8VWW5WRGJGAOaAP//Z + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; + filename=avatar2.jpg +Content-Type: image/jpg; + x-unix-mode=0700; + name="avatar2.jpg" +Content-Id: <4594E827-6E69-4329-8691-6BC35E3E73A0> + +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 + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1-- + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74-- diff --git a/spec/mailboxes/support_mailbox_spec.rb b/spec/mailboxes/support_mailbox_spec.rb index 2998b2aa2..55239b289 100644 --- a/spec/mailboxes/support_mailbox_spec.rb +++ b/spec/mailboxes/support_mailbox_spec.rb @@ -32,7 +32,9 @@ RSpec.describe SupportMailbox, type: :mailbox do end it 'create a new contact as the sender of the email' do + email_sender = Mail::Address.new(support_mail.mail[:from].value).name expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first) + expect(conversation.contact.name).to eq(email_sender) end it 'add the mail content as new message on the conversation' do @@ -52,6 +54,18 @@ RSpec.describe SupportMailbox, type: :mailbox do end end + describe 'Sender without name' do + let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') } + let(:described_subject) { described_class.receive support_mail_without_sender_name } + + it 'create a new contact with the email' do + described_subject + email_sender = support_mail_without_sender_name.mail.from.first.split('@').first + expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first) + expect(conversation.contact.name).to eq(email_sender) + end + end + describe 'handle inbox contacts' do let(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) } let(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) } @@ -76,7 +90,10 @@ RSpec.describe SupportMailbox, type: :mailbox do it 'create new contact with original sender' do described_subject + email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name + expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value) + expect(conversation.contact.name).to eq(email_sender) end end end From 0c3e8b6dbdb5d44af7dacadc639a4ff95a56a302 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 16 Sep 2021 16:51:06 +0530 Subject: [PATCH 02/76] chore: Return medium in inbox APIs (#3025) Return medium in inbox APIs --- app/builders/messages/facebook/message_builder.rb | 2 +- app/models/inbox.rb | 4 ++++ app/views/api/v1/models/_inbox.json.jbuilder | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 4ffb4b101..29579fd54 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -167,7 +167,7 @@ class Messages::Facebook::MessageBuilder result = {} # OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user # We don't need to capture this error as we don't care about contact params in case of echo messages - Sentry.capture_exception(e) unless outgoing_echo? + Sentry.capture_exception(e) unless @outgoing_echo rescue StandardError => e result = {} Sentry.capture_exception(e) diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 437e7fd99..dbd64f820 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -83,6 +83,10 @@ class Inbox < ApplicationRecord channel_type == 'Channel::Email' end + def twilio? + channel_type == 'Channel::TwilioSms' + end + def inbox_type channel.name end diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 20e1ca36c..5c25cedd0 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -38,6 +38,7 @@ json.reauthorization_required resource.channel.try(:reauthorization_required?) i ## Twilio Attributes json.phone_number resource.channel.try(:phone_number) +json.medium resource.channel.try(:medium) if resource.twilio? ## Email Channel Attributes json.forward_to_email resource.channel.try(:forward_to_email) From 6ad5a7452cc731d4451d04f2482010f6bad69a96 Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Fri, 17 Sep 2021 22:14:39 +0530 Subject: [PATCH 03/76] fix: Emails not delivered when case does not match Fixes #2504 --- app/mailboxes/application_mailbox.rb | 2 +- app/mailboxes/support_mailbox.rb | 2 +- spec/fixtures/files/support_without_sender_name.eml | 2 +- spec/mailboxes/support_mailbox_spec.rb | 10 ++++++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/mailboxes/application_mailbox.rb b/app/mailboxes/application_mailbox.rb index 5f61f5f7c..3397b657c 100644 --- a/app/mailboxes/application_mailbox.rb +++ b/app/mailboxes/application_mailbox.rb @@ -22,7 +22,7 @@ class ApplicationMailbox < ActionMailbox::Base proc do |inbound_mail_obj| is_a_support_email = false inbound_mail_obj.mail.to&.each do |email| - channel = Channel::Email.find_by('email = ? OR forward_to_email = ?', email, email) + channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase) if channel.present? is_a_support_email = true break diff --git a/app/mailboxes/support_mailbox.rb b/app/mailboxes/support_mailbox.rb index eefb5f13b..bd955353d 100644 --- a/app/mailboxes/support_mailbox.rb +++ b/app/mailboxes/support_mailbox.rb @@ -21,7 +21,7 @@ class SupportMailbox < ApplicationMailbox def find_channel mail.to.each do |email| - @channel = Channel::Email.find_by('email = ? OR forward_to_email = ?', email, email) + @channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase) break if @channel.present? end raise 'Email channel/inbox not found' if @channel.nil? diff --git a/spec/fixtures/files/support_without_sender_name.eml b/spec/fixtures/files/support_without_sender_name.eml index e472a6691..c8d3f1829 100644 --- a/spec/fixtures/files/support_without_sender_name.eml +++ b/spec/fixtures/files/support_without_sender_name.eml @@ -4,7 +4,7 @@ Content-Type: multipart/alternative; boundary="Apple-Mail=_33A037C7-4BB3-4772-AE Subject: Discussion: Let's debate these attachments Date: Tue, 20 Apr 2020 04:20:20 -0400 In-Reply-To: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> -To: "Replies" +To: "Replies" References: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> Message-Id: <0CB459E0-0336-41DA-BC88-E6E28C697DDB@chatwoot.com> X-Mailer: Apple Mail (2.1244.3) diff --git a/spec/mailboxes/support_mailbox_spec.rb b/spec/mailboxes/support_mailbox_spec.rb index 55239b289..1510d2e19 100644 --- a/spec/mailboxes/support_mailbox_spec.rb +++ b/spec/mailboxes/support_mailbox_spec.rb @@ -66,6 +66,16 @@ RSpec.describe SupportMailbox, type: :mailbox do end end + describe 'Sender with upcase mail address' do + let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') } + let(:described_subject) { described_class.receive support_mail_without_sender_name } + + it 'create a new inbox with the email case insensitive' do + described_subject + expect(conversation.inbox.id).to eq(channel_email.inbox.id) + end + end + describe 'handle inbox contacts' do let(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) } let(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) } From 794a56d4cc149744687927cd46f5b17a40299f28 Mon Sep 17 00:00:00 2001 From: "Aswin Dev P.S" Date: Fri, 17 Sep 2021 22:17:11 +0530 Subject: [PATCH 04/76] Feat: Out of office autoresponder (#2992) This change allows the user to enable autoresponder during the out-of-office time. Fixes: #2035 --- .../dashboard/settings/inbox/Settings.vue | 8 ++-- app/models/working_hour.rb | 8 ++-- .../hook_execution_service.rb | 6 ++- spec/models/working_hour_spec.rb | 24 ++++++++++ .../hook_execution_service_spec.rb | 48 ++++++++++++------- 5 files changed, 68 insertions(+), 26 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 4a241102f..cf8ddd2fd 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -392,6 +392,10 @@ export default { key: 'collaborators', name: this.$t('INBOX_MGMT.TABS.COLLABORATORS'), }, + { + key: 'businesshours', + name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), + }, ]; if (this.isAWebWidgetInbox) { @@ -401,10 +405,6 @@ export default { key: 'preChatForm', name: this.$t('INBOX_MGMT.TABS.PRE_CHAT_FORM'), }, - { - key: 'businesshours', - name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), - }, { key: 'configuration', name: this.$t('INBOX_MGMT.TABS.CONFIGURATION'), diff --git a/app/models/working_hour.rb b/app/models/working_hour.rb index b37be4eb1..890a452ba 100644 --- a/app/models/working_hour.rb +++ b/app/models/working_hour.rb @@ -43,10 +43,10 @@ class WorkingHour < ApplicationRecord def open_at?(time) return false if closed_all_day? - time.hour >= open_hour && - time.min >= open_minutes && - time.hour <= close_hour && - time.min <= close_minutes + open_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: open_hour, min: open_minutes }) + close_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: close_hour, min: close_minutes }) + + time.between?(open_time, close_time) end def open_now? diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb index 4e63808bd..d306a399d 100644 --- a/app/services/message_templates/hook_execution_service.rb +++ b/app/services/message_templates/hook_execution_service.rb @@ -14,14 +14,16 @@ class MessageTemplates::HookExecutionService delegate :contact, to: :conversation def trigger_templates - # TODO: let's see whether this is needed and remove this and related logic if not - # ::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message? + ::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message? ::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting? ::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect? ::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey? end def should_send_out_of_office_message? + # should not send if its a tweet message + return false if conversation.tweet? + inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present? end diff --git a/spec/models/working_hour_spec.rb b/spec/models/working_hour_spec.rb index d7c49b0bf..73cb4efdf 100644 --- a/spec/models/working_hour_spec.rb +++ b/spec/models/working_hour_spec.rb @@ -26,4 +26,28 @@ RSpec.describe WorkingHour do expect(described_class.today.closed_now?).to be true end end + + context 'when on friday 12:30pm' do + before do + Time.zone = 'UTC' + create(:working_hour) + travel_to '10.09.2021 12:30'.to_datetime + end + + it 'is considered to be in business hours' do + expect(described_class.today.open_now?).to be true + end + end + + context 'when on friday 17:30pm' do + before do + Time.zone = 'UTC' + create(:working_hour) + travel_to '10.09.2021 17:30'.to_datetime + end + + it 'is considered out of office' do + expect(described_class.today.closed_now?).to be true + end + end end diff --git a/spec/services/message_templates/hook_execution_service_spec.rb b/spec/services/message_templates/hook_execution_service_spec.rb index 526d1888d..929888657 100644 --- a/spec/services/message_templates/hook_execution_service_spec.rb +++ b/spec/services/message_templates/hook_execution_service_spec.rb @@ -154,25 +154,41 @@ describe ::MessageTemplates::HookExecutionService do end end - # TODO: remove this if this hook is removed - # context 'when it is after working hours' do - # it 'calls ::MessageTemplates::Template::OutOfOffice' do - # contact = create :contact - # conversation = create :conversation, contact: contact + context 'when it is after working hours' do + it 'calls ::MessageTemplates::Template::OutOfOffice' do + contact = create :contact + conversation = create :conversation, contact: contact - # conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') - # conversation.inbox.working_hours.today.update!(closed_all_day: true) + conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') + conversation.inbox.working_hours.today.update!(closed_all_day: true) - # out_of_office_service = double + out_of_office_service = double - # allow(::MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) - # allow(out_of_office_service).to receive(:perform).and_return(true) + allow(::MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) + allow(out_of_office_service).to receive(:perform).and_return(true) - # # described class gets called in message after commit - # message = create(:message, conversation: conversation) + # described class gets called in message after commit + message = create(:message, conversation: conversation) - # expect(::MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: message.conversation) - # expect(out_of_office_service).to have_received(:perform) - # end - # end + expect(::MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: message.conversation) + expect(out_of_office_service).to have_received(:perform) + end + + it 'will not call ::MessageTemplates::Template::OutOfOffice if its a tweet conversation' do + twitter_channel = create(:channel_twitter_profile) + twitter_inbox = create(:inbox, channel: twitter_channel) + twitter_inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') + + conversation = create(:conversation, inbox: twitter_inbox, additional_attributes: { type: 'tweet' }) + + out_of_office_service = double + + allow(::MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) + allow(out_of_office_service).to receive(:perform).and_return(false) + + message = create(:message, conversation: conversation) + expect(::MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation) + expect(out_of_office_service).not_to receive(:perform) + end + end end From b1b026870583424e26f66adb8eeba3be9548d090 Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Sat, 18 Sep 2021 00:49:01 +0530 Subject: [PATCH 05/76] feat: Support sending and receiving attachments in Slack Integration (#3022) - Process incoming slack attachments - Send attachments from chatwoot to slack --- app/models/attachment.rb | 1 + .../slack/incoming_message_builder.rb | 38 ++++++++++++++++++- .../slack/send_on_slack_service.rb | 35 ++++++++++++++--- .../slack/incoming_message_builder_spec.rb | 12 ++++++ .../slack/send_on_slack_service_spec.rb | 31 +++++++++++++++ spec/support/slack_stubs.rb | 28 ++++++++++++++ 6 files changed, 139 insertions(+), 6 deletions(-) diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 64637163d..0db94b9ba 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -25,6 +25,7 @@ class Attachment < ApplicationRecord enum file_type: [:image, :audio, :video, :file, :location, :fallback] def push_event_data + return unless file_type return base_data.merge(location_metadata) if file_type.to_sym == :location return base_data.merge(fallback_data) if file_type.to_sym == :fallback diff --git a/lib/integrations/slack/incoming_message_builder.rb b/lib/integrations/slack/incoming_message_builder.rb index 93f23d3a4..e655eef45 100644 --- a/lib/integrations/slack/incoming_message_builder.rb +++ b/lib/integrations/slack/incoming_message_builder.rb @@ -79,7 +79,7 @@ class Integrations::Slack::IncomingMessageBuilder def create_message return unless conversation - conversation.messages.create( + @message = conversation.messages.create( message_type: :outgoing, account_id: conversation.account_id, inbox_id: conversation.inbox_id, @@ -89,10 +89,46 @@ class Integrations::Slack::IncomingMessageBuilder sender: sender ) + process_attachments(params[:event][:files]) if params[:event][:files].present? + { status: 'success' } end def slack_client @slack_client ||= Slack::Web::Client.new(token: @integration_hook.access_token) end + + # TODO: move process attachment for facebook instagram and slack in one place + # https://api.slack.com/messaging/files + def process_attachments(attachments) + attachments.each do |attachment| + tempfile = Down::NetHttp.download(attachment[:url_private], headers: { 'Authorization' => "Bearer #{integration_hook.access_token}" }) + + attachment_params = { + file_type: file_type(attachment), + account_id: @message.account_id, + external_url: attachment[:url_private], + file: { + io: tempfile, + filename: tempfile.original_filename, + content_type: tempfile.content_type + } + } + + attachment_obj = @message.attachments.new(attachment_params) + attachment_obj.file.content_type = attachment[:mimetype] + attachment_obj.save! + end + end + + def file_type(attachment) + return if attachment[:mimetype] == 'text/plain' + + case attachment[:filetype] + when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg' + :image + when 'pdf' + :file + end + end end diff --git a/lib/integrations/slack/send_on_slack_service.rb b/lib/integrations/slack/send_on_slack_service.rb index ba3e68669..5d8b52e24 100644 --- a/lib/integrations/slack/send_on_slack_service.rb +++ b/lib/integrations/slack/send_on_slack_service.rb @@ -45,8 +45,15 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService end def send_message - return if message_content.blank? + post_message if message_content.present? + upload_file if message.attachments.any? + rescue Slack::Web::Api::Errors::AccountInactive => e + Rails.logger.info e + hook.authorization_error! + hook.disable if hook.enabled? + end + def post_message @slack_message = slack_client.chat_postMessage( channel: hook.reference_id, text: message_content, @@ -54,10 +61,28 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService thread_ts: conversation.identifier, icon_url: avatar_url(message.sender) ) - rescue Slack::Web::Api::Errors::AccountInactive => e - Rails.logger.info e - hook.authorization_error! - hook.disable if hook.enabled? + end + + def upload_file + result = slack_client.files_upload({ + channels: hook.reference_id, + initial_comment: 'Attached File!', + thread_ts: conversation.identifier + }.merge(file_information)) + Rails.logger.info(result) + end + + def file_type + File.extname(message.attachments.first.file_url).strip.downcase[1..] + end + + def file_information + { + filename: message.attachments.first.file.filename, + filetype: file_type, + content: message.attachments.first.file.download, + title: message.attachments.first.file.filename + } end def sender_name(sender) diff --git a/spec/lib/integrations/slack/incoming_message_builder_spec.rb b/spec/lib/integrations/slack/incoming_message_builder_spec.rb index ac89434c7..98ae9cdd1 100644 --- a/spec/lib/integrations/slack/incoming_message_builder_spec.rb +++ b/spec/lib/integrations/slack/incoming_message_builder_spec.rb @@ -3,6 +3,7 @@ require 'rails_helper' describe Integrations::Slack::IncomingMessageBuilder do let(:account) { create(:account) } let(:message_params) { slack_message_stub } + let(:message_with_attachments) { slack_attachment_stub } let(:message_without_thread_ts) { slack_message_stub_without_thread_ts } let(:verification_params) { slack_url_verification_stub } @@ -51,6 +52,17 @@ describe Integrations::Slack::IncomingMessageBuilder do builder.perform expect(conversation.messages.count).to eql(messages_count) end + + it 'saves attachment if params files present' do + expect(hook).not_to eq nil + messages_count = conversation.messages.count + builder = described_class.new(message_with_attachments) + allow(builder).to receive(:sender).and_return(nil) + builder.perform + expect(conversation.messages.count).to eql(messages_count + 1) + expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again') + expect(conversation.messages.last.attachments).to be_any + end end end end diff --git a/spec/lib/integrations/slack/send_on_slack_service_spec.rb b/spec/lib/integrations/slack/send_on_slack_service_spec.rb index 234a2180b..ef662ae15 100644 --- a/spec/lib/integrations/slack/send_on_slack_service_spec.rb +++ b/spec/lib/integrations/slack/send_on_slack_service_spec.rb @@ -9,6 +9,7 @@ describe Integrations::Slack::SendOnSlackService do create(:message, account: conversation.account, inbox: conversation.inbox, conversation: conversation) end let(:slack_message) { double } + let(:file_attachment) { double } let(:slack_message_content) { double } let(:slack_client) { double } let(:builder) { described_class.new(message: message, hook: hook) } @@ -58,6 +59,36 @@ describe Integrations::Slack::SendOnSlackService do expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345' end + it 'sent attachment on slack' do + expect(slack_client).to receive(:chat_postMessage).with( + channel: hook.reference_id, + text: message.content, + username: "Contact: #{message.sender.name}", + thread_ts: conversation.identifier, + icon_url: anything + ).and_return(slack_message) + + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png') + + expect(slack_client).to receive(:files_upload).with( + channels: hook.reference_id, + initial_comment: 'Attached File!', + content: anything, + filename: attachment.file.filename, + filetype: 'png', + thread_ts: conversation.identifier, + title: anything + ).and_return(file_attachment) + + message.save! + + builder.perform + + expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345' + expect(message.attachments).to be_any + end + it 'disables hook on Slack AccountInactive error' do expect(slack_client).to receive(:chat_postMessage).with( channel: hook.reference_id, diff --git a/spec/support/slack_stubs.rb b/spec/support/slack_stubs.rb index d0e89221b..650a7cf2b 100644 --- a/spec/support/slack_stubs.rb +++ b/spec/support/slack_stubs.rb @@ -21,6 +21,20 @@ module SlackStubs } end + def slack_attachment_stub + { + token: '[FILTERED]', + team_id: 'TLST3048H', + api_app_id: 'A012S5UETV4', + event: message_event, + type: 'event_callback', + event_id: 'Ev013QUX3WV6', + event_time: 1_588_623_033, + authed_users: '[FILTERED]', + webhook: {} + } + end + def slack_message_stub_without_thread_ts { token: '[FILTERED]', @@ -52,6 +66,7 @@ module SlackStubs ts: '1588623033.006000', team: 'TLST3048H', blocks: message_blocks, + files: file_stub, thread_ts: '1588623023.005900', channel: 'G01354F6A6Q', event_ts: '1588623033.006000', @@ -59,6 +74,19 @@ module SlackStubs } end + def file_stub + [ + { + mimetype: 'image/png', + url_private: 'https://via.placeholder.com/250x250.png', + name: 'name_of_the_file', + title: 'title_of_the_file', + filetype: 'png', + url_private_download: 'https://via.placeholder.com/250x250.png' + } + ] + end + def message_blocks [ { From fa2db5a72943657e2a9cd20f53be4df78a7139d5 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Sun, 19 Sep 2021 20:38:20 +0530 Subject: [PATCH 06/76] fix: Update title for out of office message (#3043) --- app/javascript/dashboard/i18n/locale/ca/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/cs/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/da/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/en/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/fi/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/he/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/hi/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/it/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/ja/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/ml/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/ne/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/nl/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/no/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/ro/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/sk/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/sv/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/ta/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/th/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/tr/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/uk/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/vi/inboxMgmt.json | 2 +- app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/javascript/dashboard/i18n/locale/ca/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/ca/inboxMgmt.json index f5e8da922..5c331a401 100644 --- a/app/javascript/dashboard/i18n/locale/ca/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/ca/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/cs/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/cs/inboxMgmt.json index 28e42b91c..b092c7eb9 100644 --- a/app/javascript/dashboard/i18n/locale/cs/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/cs/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Vyberte časové pásmo", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/da/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/da/inboxMgmt.json index 3db06c047..45da10bd7 100644 --- a/app/javascript/dashboard/i18n/locale/da/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/da/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "Vi er ikke tilgængelige i øjeblikket. Skriv en besked og vi svarer, når vi er tilbage.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 4cd9de91f..4d05e5807 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -354,7 +354,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/fi/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/fi/inboxMgmt.json index 923988f64..5a1c15383 100644 --- a/app/javascript/dashboard/i18n/locale/fi/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/fi/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/he/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/he/inboxMgmt.json index 89efc3de8..f5010b0d8 100644 --- a/app/javascript/dashboard/i18n/locale/he/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/he/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/hi/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/hi/inboxMgmt.json index 0327ada74..d22e53b84 100644 --- a/app/javascript/dashboard/i18n/locale/hi/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/hi/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/it/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/it/inboxMgmt.json index 6ca9fa681..e7cc9dbd6 100644 --- a/app/javascript/dashboard/i18n/locale/it/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/it/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/ja/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/ja/inboxMgmt.json index a4cf934ac..19544deed 100644 --- a/app/javascript/dashboard/i18n/locale/ja/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/ja/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/ml/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/ml/inboxMgmt.json index 0a026316b..459bbad26 100644 --- a/app/javascript/dashboard/i18n/locale/ml/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/ml/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/ne/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/ne/inboxMgmt.json index 0327ada74..d22e53b84 100644 --- a/app/javascript/dashboard/i18n/locale/ne/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/ne/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/nl/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/nl/inboxMgmt.json index d347896e5..e18993ee7 100644 --- a/app/javascript/dashboard/i18n/locale/nl/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/nl/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/no/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/no/inboxMgmt.json index b0d6b2989..6671fa668 100644 --- a/app/javascript/dashboard/i18n/locale/no/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/no/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/ro/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/ro/inboxMgmt.json index e17f9fb77..89863acc5 100644 --- a/app/javascript/dashboard/i18n/locale/ro/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/ro/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/sk/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/sk/inboxMgmt.json index 0327ada74..d22e53b84 100644 --- a/app/javascript/dashboard/i18n/locale/sk/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/sk/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/sv/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/sv/inboxMgmt.json index 386160da8..245ffe707 100644 --- a/app/javascript/dashboard/i18n/locale/sv/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/sv/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/ta/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/ta/inboxMgmt.json index b89dcc554..f5a87ffe8 100644 --- a/app/javascript/dashboard/i18n/locale/ta/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/ta/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/th/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/th/inboxMgmt.json index 041ec75dd..836af5d28 100644 --- a/app/javascript/dashboard/i18n/locale/th/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/th/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/tr/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/tr/inboxMgmt.json index 6001c0eaa..865bf0db7 100644 --- a/app/javascript/dashboard/i18n/locale/tr/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/tr/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/uk/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/uk/inboxMgmt.json index 4db996bc1..2596ea8a3 100644 --- a/app/javascript/dashboard/i18n/locale/uk/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/uk/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/vi/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/vi/inboxMgmt.json index 1313f8385..84fb6cebd 100644 --- a/app/javascript/dashboard/i18n/locale/vi/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/vi/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { diff --git a/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json index 93b285669..5c53f19d9 100644 --- a/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/zh_CN/inboxMgmt.json @@ -350,7 +350,7 @@ "TIMEZONE_LABEL": "Select timezone", "UPDATE": "Update business hours settings", "TOGGLE_AVAILABILITY": "Enable business availability for this inbox", - "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors", + "UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors", "UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.", "TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.", "DAY": { From dcbbb09ebdeccf987f1474a60d3c0558aed3ba21 Mon Sep 17 00:00:00 2001 From: Brent Date: Sun, 19 Sep 2021 17:42:54 +0200 Subject: [PATCH 07/76] fix: Update widget option button style to handle long text (#3030) Co-authored-by: Pranav Raj S --- app/javascript/shared/components/ChatOption.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/javascript/shared/components/ChatOption.vue b/app/javascript/shared/components/ChatOption.vue index 74a614579..672939356 100644 --- a/app/javascript/shared/components/ChatOption.vue +++ b/app/javascript/shared/components/ChatOption.vue @@ -41,10 +41,11 @@ export default { @import '~widget/assets/scss/variables.scss'; .option { - border: 1px solid $color-woot; border-radius: $space-jumbo; + border: 1px solid $color-woot; float: left; margin: $space-smaller; + max-width: 100%; .option-button { background: transparent; @@ -52,7 +53,11 @@ export default { border: 0; color: $color-woot; cursor: pointer; + height: auto; + line-height: 1.5; + min-height: $space-two * 2; text-align: left; + white-space: normal; span { display: inline-block; From aaadd61e09293325a9586a2d8e12daa59e633583 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 21 Sep 2021 10:16:14 +0530 Subject: [PATCH 08/76] fix: Outbound messages triggering out of office (#3051) --- .../hook_execution_service.rb | 2 ++ .../hook_execution_service_spec.rb | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb index d306a399d..bed23b2f8 100644 --- a/app/services/message_templates/hook_execution_service.rb +++ b/app/services/message_templates/hook_execution_service.rb @@ -23,6 +23,8 @@ class MessageTemplates::HookExecutionService def should_send_out_of_office_message? # should not send if its a tweet message return false if conversation.tweet? + # should not send for outbound messages + return false unless message.incoming? inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present? end diff --git a/spec/services/message_templates/hook_execution_service_spec.rb b/spec/services/message_templates/hook_execution_service_spec.rb index 929888657..87264a6ae 100644 --- a/spec/services/message_templates/hook_execution_service_spec.rb +++ b/spec/services/message_templates/hook_execution_service_spec.rb @@ -174,6 +174,25 @@ describe ::MessageTemplates::HookExecutionService do expect(out_of_office_service).to have_received(:perform) end + it 'will not calls ::MessageTemplates::Template::OutOfOffice when outgoing message' do + contact = create :contact + conversation = create :conversation, contact: contact + + conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') + conversation.inbox.working_hours.today.update!(closed_all_day: true) + + out_of_office_service = double + + allow(::MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) + allow(out_of_office_service).to receive(:perform).and_return(true) + + # described class gets called in message after commit + message = create(:message, conversation: conversation, message_type: 'outgoing') + + expect(::MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation) + expect(out_of_office_service).not_to have_received(:perform) + end + it 'will not call ::MessageTemplates::Template::OutOfOffice if its a tweet conversation' do twitter_channel = create(:channel_twitter_profile) twitter_inbox = create(:inbox, channel: twitter_channel) From b59e73b10bf9629349d12e7889a8af9f551e4e7a Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Tue, 21 Sep 2021 10:16:32 +0530 Subject: [PATCH 09/76] fix: Update associations when a label is updated (#3046) --- .../contacts/components/ContactLabels.vue | 10 +++--- app/jobs/labels/update_job.rb | 11 ++++++ app/models/label.rb | 10 ++++++ app/services/labels/update_service.rb | 35 +++++++++++++++++++ spec/jobs/labels/update_job_spec.rb | 15 ++++++++ spec/models/label_spec.rb | 16 +++++++++ spec/services/labels/update_service_spec.rb | 32 +++++++++++++++++ 7 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 app/jobs/labels/update_job.rb create mode 100644 app/services/labels/update_service.rb create mode 100644 spec/jobs/labels/update_job_spec.rb create mode 100644 spec/services/labels/update_service_spec.rb diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactLabels.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactLabels.vue index 8ac65b462..04c79561f 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactLabels.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactLabels.vue @@ -24,12 +24,12 @@ export default { computed: { savedLabels() { - const result = this.$store.getters['contactLabels/getContactLabels']( - this.contactId + const availableContactLabels = this.$store.getters[ + 'contactLabels/getContactLabels' + ](this.contactId); + return this.allLabels.filter(({ title }) => + availableContactLabels.includes(title) ); - return result.map(value => { - return this.allLabels.find(label => label.title === value); - }); }, ...mapGetters({ diff --git a/app/jobs/labels/update_job.rb b/app/jobs/labels/update_job.rb new file mode 100644 index 000000000..8834c97cc --- /dev/null +++ b/app/jobs/labels/update_job.rb @@ -0,0 +1,11 @@ +class Labels::UpdateJob < ApplicationJob + queue_as :default + + def perform(new_label_title, old_label_title, account_id) + Labels::UpdateService.new( + new_label_title: new_label_title, + old_label_title: old_label_title, + account_id: account_id + ).perform + end +end diff --git a/app/models/label.rb b/app/models/label.rb index b53260f58..9b551141d 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -25,6 +25,8 @@ class Label < ApplicationRecord format: { with: UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE }, uniqueness: { scope: :account_id } + after_update_commit :update_associated_models + before_validation do self.title = title.downcase if attribute_present?('title') end @@ -40,4 +42,12 @@ class Label < ApplicationRecord def events account.events.where(conversation_id: conversations.pluck(:id)) end + + private + + def update_associated_models + return unless title_previously_changed? + + Labels::UpdateJob.perform_later(title, title_previously_was, account_id) + end end diff --git a/app/services/labels/update_service.rb b/app/services/labels/update_service.rb new file mode 100644 index 000000000..300331b09 --- /dev/null +++ b/app/services/labels/update_service.rb @@ -0,0 +1,35 @@ +class Labels::UpdateService + pattr_initialize [:new_label_title!, :old_label_title!, :account_id!] + + def perform + tagged_conversations.find_in_batches do |conversation_batch| + conversation_batch.each do |conversation| + conversation.label_list.remove(old_label_title) + conversation.label_list.add(new_label_title) + conversation.save! + end + end + + tagged_contacts.find_in_batches do |contact_batch| + contact_batch.each do |contact| + contact.label_list.remove(old_label_title) + contact.label_list.add(new_label_title) + contact.save! + end + end + end + + private + + def tagged_conversations + account.conversations.tagged_with(old_label_title) + end + + def tagged_contacts + account.contacts.tagged_with(old_label_title) + end + + def account + @account ||= Account.find(account_id) + end +end diff --git a/spec/jobs/labels/update_job_spec.rb b/spec/jobs/labels/update_job_spec.rb new file mode 100644 index 000000000..16692b2c3 --- /dev/null +++ b/spec/jobs/labels/update_job_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe Labels::UpdateJob, type: :job do + subject(:job) { described_class.perform_later(new_label_title, old_label_title, account_id) } + + let(:new_label_title) { 'new-title' } + let(:old_label_title) { 'old-title' } + let(:account_id) { 1 } + + it 'queues the job' do + expect { job }.to have_enqueued_job(described_class) + .with(new_label_title, old_label_title, account_id) + .on_queue('default') + end +end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 215e9d5a6..bd8b6ad19 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -39,4 +39,20 @@ RSpec.describe Label, type: :model do expect(duplicate_label.valid?).to eq false end end + + describe '.after_update_commit' do + let(:label) { create(:label) } + + it 'calls update job' do + expect(Labels::UpdateJob).to receive(:perform_later).with('new-title', label.title, label.account_id) + + label.update(title: 'new-title') + end + + it 'does not call update job if title is not updated' do + expect(Labels::UpdateJob).not_to receive(:perform_later) + + label.update(description: 'new-description') + end + end end diff --git a/spec/services/labels/update_service_spec.rb b/spec/services/labels/update_service_spec.rb new file mode 100644 index 000000000..a71ec3271 --- /dev/null +++ b/spec/services/labels/update_service_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +describe Labels::UpdateService do + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + let(:label) { create(:label, account: account) } + let(:contact) { conversation.contact } + + before do + conversation.label_list.add(label.title) + conversation.save! + + contact.label_list.add(label.title) + contact.save! + end + + describe '#perform' do + it 'updates associated conversations/contacts labels' do + expect(conversation.label_list).to eq([label.title]) + expect(contact.label_list).to eq([label.title]) + + described_class.new( + new_label_title: 'updated-label-title', + old_label_title: label.title, + account_id: account.id + ).perform + + expect(conversation.reload.label_list).to eq(['updated-label-title']) + expect(contact.reload.label_list).to eq(['updated-label-title']) + end + end +end From c504067e2bfe64a381d2ff069c8deb2fd534772e Mon Sep 17 00:00:00 2001 From: Murtaza Bagwala Date: Tue, 21 Sep 2021 10:20:12 +0530 Subject: [PATCH 10/76] fix: Add blank check for file param in Import API (#3057) --- .../api/v1/accounts/contacts_controller.rb | 3 +++ config/locales/en.yml | 3 +++ .../api/v1/accounts/contacts_controller_spec.rb | 14 ++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 444f0c918..82b92f090 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -30,10 +30,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController end def import + render json: { error: I18n.t('errors.contacts.import.failed') }, status: :unprocessable_entity and return if params[:import_file].blank? + ActiveRecord::Base.transaction do import = Current.account.data_imports.create!(data_type: 'contacts') import.import_file.attach(params[:import_file]) end + head :ok end diff --git a/config/locales/en.yml b/config/locales/en.yml index 061d3055e..8e3d5d19f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -41,6 +41,9 @@ en: invalid_email: You have entered an invalid email email_already_exists: "You have already signed up for an account with %{email}" failed: Signup failed + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index 4b6bbf6db..af94fcd19 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -105,6 +105,20 @@ RSpec.describe 'Contacts API', type: :request do expect(account.data_imports.first.import_file.attached?).to eq(true) end end + + context 'when file is empty' do + let(:admin) { create(:user, account: account, role: :administrator) } + + it 'returns Unprocessable Entity' do + post "/api/v1/accounts/#{account.id}/contacts/import", + headers: admin.create_new_auth_token + + json_response = JSON.parse(response.body) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response['error']).to eq('File is blank') + end + end end describe 'GET /api/v1/accounts/{account.id}/contacts/active' do From dddab0bbced3758bcde8378bc62c1c6deeab3507 Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Tue, 21 Sep 2021 23:02:45 +0530 Subject: [PATCH 11/76] chore: Handle the Twilio exception in sentry Fix trying to extract the Sid from non created message. Fixes #3029 --- app/services/twilio/send_on_twilio_service.rb | 4 ++-- spec/services/twilio/send_on_twilio_service_spec.rb | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/services/twilio/send_on_twilio_service.rb b/app/services/twilio/send_on_twilio_service.rb index 1133bbf70..c722bd6bf 100644 --- a/app/services/twilio/send_on_twilio_service.rb +++ b/app/services/twilio/send_on_twilio_service.rb @@ -9,9 +9,9 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService begin twilio_message = client.messages.create(**message_params) rescue Twilio::REST::TwilioError => e - Rails.logger.info "Twilio Error: #{e.message}" + Sentry.capture_exception(e) end - message.update!(source_id: twilio_message.sid) + message.update!(source_id: twilio_message.sid) if twilio_message end def message_params diff --git a/spec/services/twilio/send_on_twilio_service_spec.rb b/spec/services/twilio/send_on_twilio_service_spec.rb index 9fc6e2d57..d4fc16048 100644 --- a/spec/services/twilio/send_on_twilio_service_spec.rb +++ b/spec/services/twilio/send_on_twilio_service_spec.rb @@ -28,6 +28,7 @@ describe Twilio::SendOnTwilioService do message = create(:message, message_type: 'outgoing', private: true, inbox: twilio_inbox, account: account) ::Twilio::SendOnTwilioService.new(message: message).perform expect(twilio_client).not_to have_received(:messages) + expect(message.reload.source_id).to be_nil end it 'if inbox channel is not twilio' do @@ -40,6 +41,7 @@ describe Twilio::SendOnTwilioService do message = create(:message, message_type: 'incoming', inbox: twilio_inbox, account: account) ::Twilio::SendOnTwilioService.new(message: message).perform expect(twilio_client).not_to have_received(:messages) + expect(message.reload.source_id).to be_nil end it 'if message has an source id' do From 0c24df96a8444b7e85b0a8395deb9c34371767aa Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 22 Sep 2021 10:46:48 +0530 Subject: [PATCH 12/76] chore: Conversation custom attribute APIs (#3024) --- Gemfile | 6 +++- .../v1/accounts/conversations_controller.rb | 7 ++++ app/models/conversation.rb | 1 + .../custom_attributes.json.jbuilder | 1 + .../partials/_conversation.json.jbuilder | 1 + config/routes.rb | 1 + ..._add_custom_attributes_to_conversations.rb | 5 +++ db/schema.rb | 3 +- .../accounts/conversations_controller_spec.rb | 33 +++++++++++++++++++ 9 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder create mode 100644 db/migrate/20210916060144_add_custom_attributes_to_conversations.rb diff --git a/Gemfile b/Gemfile index 1dddb482c..91a7f4888 100644 --- a/Gemfile +++ b/Gemfile @@ -56,7 +56,6 @@ gem 'activerecord-import' gem 'dotenv-rails' gem 'foreman' gem 'puma' -gem 'rack-timeout' gem 'webpacker', '~> 5.x' # metrics on heroku gem 'barnes' @@ -122,6 +121,11 @@ gem 'hairtrigger' gem 'procore-sift' +group :production, :staging do + # we dont want request timing out in development while using byebug + gem 'rack-timeout' +end + group :development do gem 'annotate' gem 'bullet' diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index d360723a6..59dc051a7 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -72,6 +72,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro @conversation.save! end + def custom_attributes + @conversation.custom_attributes = params.permit(custom_attributes: {})[:custom_attributes] + @conversation.save! + end + private def set_conversation_status @@ -112,6 +117,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def conversation_params additional_attributes = params[:additional_attributes]&.permit! || {} + custom_attributes = params[:custom_attributes]&.permit! || {} status = params[:status].present? ? { status: params[:status] } : {} # TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases @@ -122,6 +128,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro contact_id: @contact_inbox.contact_id, contact_inbox_id: @contact_inbox.id, additional_attributes: additional_attributes, + custom_attributes: custom_attributes, snoozed_until: params[:snoozed_until] }.merge(status) end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index f5d4f9680..71f6d0a36 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -6,6 +6,7 @@ # additional_attributes :jsonb # agent_last_seen_at :datetime # contact_last_seen_at :datetime +# custom_attributes :jsonb # identifier :string # last_activity_at :datetime not null # snoozed_until :datetime diff --git a/app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder b/app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder new file mode 100644 index 000000000..1ca512802 --- /dev/null +++ b/app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder @@ -0,0 +1 @@ +json.custom_attributes @conversation.custom_attributes diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 12879b3d5..1e35e8217 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -33,5 +33,6 @@ json.contact_last_seen_at conversation.contact_last_seen_at.to_i json.agent_last_seen_at conversation.agent_last_seen_at.to_i json.unread_count conversation.unread_incoming_messages.count json.additional_attributes conversation.additional_attributes +json.custom_attributes conversation.custom_attributes json.account_id conversation.account_id json.labels conversation.label_list diff --git a/config/routes.rb b/config/routes.rb index d7b8cb276..a3239d962 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -72,6 +72,7 @@ Rails.application.routes.draw do post :toggle_status post :toggle_typing_status post :update_last_seen + post :custom_attributes end end diff --git a/db/migrate/20210916060144_add_custom_attributes_to_conversations.rb b/db/migrate/20210916060144_add_custom_attributes_to_conversations.rb new file mode 100644 index 000000000..ef5f8cac1 --- /dev/null +++ b/db/migrate/20210916060144_add_custom_attributes_to_conversations.rb @@ -0,0 +1,5 @@ +class AddCustomAttributesToConversations < ActiveRecord::Migration[6.1] + def change + add_column :conversations, :custom_attributes, :jsonb, default: {} + end +end diff --git a/db/schema.rb b/db/schema.rb index 88af58036..dcb1fe612 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: 2021_08_29_124254) do +ActiveRecord::Schema.define(version: 2021_09_16_060144) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -295,6 +295,7 @@ ActiveRecord::Schema.define(version: 2021_08_29_124254) do t.bigint "team_id" t.bigint "campaign_id" t.datetime "snoozed_until" + t.jsonb "custom_attributes", default: {} 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 ["assignee_id", "account_id"], name: "index_conversations_on_assignee_id_and_account_id" diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 7b66656c2..ec53ec853 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -496,4 +496,37 @@ RSpec.describe 'Conversations API', type: :request do end end end + + describe 'POST /api/v1/accounts/{account.id}/conversations/:id/custom_attributes' do + let(:conversation) { create(:conversation, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/custom_attributes" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:custom_attributes) { { user_id: 1001, created_date: '23/12/2012', subscription_id: 12 } } + let(:valid_params) { { custom_attributes: custom_attributes } } + + before do + create(:inbox_member, user: agent, inbox: conversation.inbox) + end + + it 'updates last seen' do + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/custom_attributes", + headers: agent.create_new_auth_token, + params: valid_params, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.custom_attributes).not_to eq nil + expect(conversation.reload.custom_attributes.count).to eq 3 + end + end + end end From 4f51a46c2ba54ebc59988f81134e9619603add38 Mon Sep 17 00:00:00 2001 From: "Aswin Dev P.S" Date: Thu, 23 Sep 2021 12:52:49 +0530 Subject: [PATCH 13/76] feat: Ability to delete a contact (#2984) This change allows the administrator user to delete a contact and its related data like conversations, contact inboxes, and reports. Fixes #1929 --- .../api/v1/accounts/contacts_controller.rb | 18 +- .../dashboard/helper/actionCable.js | 9 + .../dashboard/i18n/locale/en/contact.json | 16 ++ .../contacts/components/ContactInfoPanel.vue | 2 +- .../conversation/contact/ContactInfo.vue | 159 +++++++++++++++--- .../store/modules/contactConversations.js | 3 + .../store/modules/contacts/actions.js | 23 +++ .../dashboard/store/modules/contacts/index.js | 1 + .../store/modules/contacts/mutations.js | 6 + .../store/modules/conversations/index.js | 7 + .../modules/specs/contacts/actions.spec.js | 36 ++++ .../dashboard/store/mutation-types.js | 3 + app/listeners/action_cable_listener.rb | 7 + app/models/contact.rb | 8 +- app/policies/contact_policy.rb | 4 + config/locales/en.yml | 4 +- config/routes.rb | 2 +- lib/events/types.rb | 1 + .../v1/accounts/contacts_controller_spec.rb | 49 ++++++ spec/listeners/action_cable_listener_spec.rb | 15 ++ swagger/paths/contact/crud.yml | 19 +++ swagger/swagger.json | 27 +++ 22 files changed, 387 insertions(+), 32 deletions(-) diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 82b92f090..561b224c3 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search] - before_action :fetch_contact, only: [:show, :update, :contactable_inboxes] + before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes] before_action :set_include_contact_inboxes, only: [:index, :search] def index @@ -73,6 +73,18 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController }, status: :unprocessable_entity end + def destroy + if ::OnlineStatusTracker.get_presence( + @contact.account.id, 'Contact', @contact.id + ) + return render_error({ message: I18n.t('contacts.online.delete', contact_name: @contact.name.capitalize) }, + :unprocessable_entity) + end + + @contact.destroy! + head :ok + end + private # TODO: Move this to a finder class @@ -137,4 +149,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def fetch_contact @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) end + + def render_error(error, error_status) + render json: error, status: error_status + end end diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index afddb1e88..3a86ad3ea 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -19,6 +19,7 @@ class ActionCableConnector extends BaseActionCableConnector { 'conversation.typing_off': this.onTypingOff, 'conversation.contact_changed': this.onConversationContactChange, 'presence.update': this.onPresenceUpdate, + 'contact.deleted': this.onContactDelete, }; } @@ -115,6 +116,14 @@ class ActionCableConnector extends BaseActionCableConnector { fetchConversationStats = () => { bus.$emit('fetch_conversation_stats'); }; + + onContactDelete = data => { + this.app.$store.dispatch( + 'contacts/deleteContactThroughConversations', + data.id + ); + this.fetchConversationStats(); + }; } export default { diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index e00e01647..d08b363ff 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -54,6 +54,22 @@ "TITLE": "Create new contact", "DESC": "Add basic information details about the contact." }, + "DELETE_CONTACT": { + "BUTTON_LABEL": "Delete Contact", + "TITLE": "Delete contact", + "DESC": "Delete contact details", + "CONFIRM": { + "TITLE": "Confirm Deletion", + "MESSAGE": "Are you sure to delete ", + "PLACE_HOLDER": "Please type {contactName} to confirm", + "YES": "Yes, Delete ", + "NO": "No, Keep " + }, + "API": { + "SUCCESS_MESSAGE": "Contact deleted successfully", + "ERROR_MESSAGE": "Could not delete contact. Please try again later." + } + }, "CONTACT_FORM": { "FORM": { "SUBMIT": "Submit", diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue index be79067e2..27f2f14c2 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue @@ -3,7 +3,7 @@ - + - - {{ $t('EDIT_CONTACT.BUTTON_LABEL') }} - -
- - {{ $t('CONTACT_PANEL.NEW_MESSAGE') }} - - - {{ $t('EDIT_CONTACT.BUTTON_LABEL') }} - +
+
+ + {{ $t('EDIT_CONTACT.BUTTON_LABEL') }} + +
+
+ + {{ $t('DELETE_CONTACT.BUTTON_LABEL') }} + +
+
+
+
+ + + +
+ @@ -179,17 +269,32 @@ export default { .contact-actions { margin-top: var(--space-small); } -.button.edit-contact { + +.edit-contact { margin-left: var(--space-medium); } -.button.new-message { - margin-right: var(--space-small); +.delete-contact { + margin-left: var(--space-medium); } .contact-actions { display: flex; align-items: center; width: 100%; + + .new-message { + font-size: var(--font-size-medium); + } + + .edit-contact { + margin-left: var(--space-small); + font-size: var(--font-size-medium); + } + + .delete-contact { + margin-left: var(--space-small); + font-size: var(--font-size-medium); + } } diff --git a/app/javascript/dashboard/store/modules/contactConversations.js b/app/javascript/dashboard/store/modules/contactConversations.js index d483637fc..eefae3165 100644 --- a/app/javascript/dashboard/store/modules/contactConversations.js +++ b/app/javascript/dashboard/store/modules/contactConversations.js @@ -82,6 +82,9 @@ export const mutations = { const conversations = $state.records[id] || []; Vue.set($state.records, id, [...conversations, data]); }, + [types.default.DELETE_CONTACT_CONVERSATION]: ($state, id) => { + Vue.delete($state.records, id); + }, }; export default { diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js index 09761c443..fae33406a 100644 --- a/app/javascript/dashboard/store/modules/contacts/actions.js +++ b/app/javascript/dashboard/store/modules/contacts/actions.js @@ -83,6 +83,21 @@ export const actions = { } }, + delete: async ({ commit }, id) => { + commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true }); + try { + await ContactAPI.delete(id); + commit(types.SET_CONTACT_UI_FLAG, { isDeleting: false }); + } catch (error) { + commit(types.SET_CONTACT_UI_FLAG, { isDeleting: false }); + if (error.response?.data?.message) { + throw new Error(error.response.data.message); + } else { + throw new Error(error); + } + } + }, + fetchContactableInbox: async ({ commit }, id) => { commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true }); try { @@ -110,4 +125,12 @@ export const actions = { setContact({ commit }, data) { commit(types.SET_CONTACT_ITEM, data); }, + + deleteContactThroughConversations: ({ commit }, id) => { + commit(types.DELETE_CONTACT, id); + commit(types.CLEAR_CONTACT_CONVERSATIONS, id, { root: true }); + commit(`contactConversations/${types.DELETE_CONTACT_CONVERSATION}`, id, { + root: true, + }); + }, }; diff --git a/app/javascript/dashboard/store/modules/contacts/index.js b/app/javascript/dashboard/store/modules/contacts/index.js index d5264169e..f1982e4be 100644 --- a/app/javascript/dashboard/store/modules/contacts/index.js +++ b/app/javascript/dashboard/store/modules/contacts/index.js @@ -13,6 +13,7 @@ const state = { isFetchingItem: false, isFetchingInboxes: false, isUpdating: false, + isDeleting: false, }, sortOrder: [], }; diff --git a/app/javascript/dashboard/store/modules/contacts/mutations.js b/app/javascript/dashboard/store/modules/contacts/mutations.js index 46f4d94fa..9e8e64e6d 100644 --- a/app/javascript/dashboard/store/modules/contacts/mutations.js +++ b/app/javascript/dashboard/store/modules/contacts/mutations.js @@ -46,6 +46,12 @@ export const mutations = { Vue.set($state.records, data.id, data); }, + [types.DELETE_CONTACT]: ($state, id) => { + const index = $state.sortOrder.findIndex(item => item === id); + Vue.delete($state.sortOrder, index); + Vue.delete($state.records, id); + }, + [types.UPDATE_CONTACTS_PRESENCE]: ($state, data) => { Object.values($state.records).forEach(element => { const availabilityStatus = data[element.id]; diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index f0005c627..f2fd0fd43 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -177,6 +177,13 @@ export const mutations = { Vue.set(chat, 'can_reply', canReply); } }, + + [types.default.CLEAR_CONTACT_CONVERSATIONS](_state, contactId) { + const chats = _state.allConversations.filter( + c => c.meta.sender.id !== contactId + ); + Vue.set(_state, 'allConversations', chats); + }, }; export default { diff --git a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js index 4bc8b6723..03d49d8d0 100644 --- a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js @@ -139,6 +139,27 @@ describe('#actions', () => { }); }); + describe('#delete', () => { + it('sends correct mutations if API is success', async () => { + axios.delete.mockResolvedValue(); + await actions.delete({ commit }, contactList[0].id); + expect(commit.mock.calls).toEqual([ + [types.SET_CONTACT_UI_FLAG, { isDeleting: true }], + [types.SET_CONTACT_UI_FLAG, { isDeleting: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.delete.mockRejectedValue({ message: 'Incorrect header' }); + await expect( + actions.delete({ commit }, contactList[0].id) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.SET_CONTACT_UI_FLAG, { isDeleting: true }], + [types.SET_CONTACT_UI_FLAG, { isDeleting: false }], + ]); + }); + }); + describe('#setContact', () => { it('returns correct mutations', () => { const data = { id: 1, name: 'john doe', availability_status: 'online' }; @@ -146,4 +167,19 @@ describe('#actions', () => { expect(commit.mock.calls).toEqual([[types.SET_CONTACT_ITEM, data]]); }); }); + + describe('#deleteContactThroughConversations', () => { + it('returns correct mutations', () => { + actions.deleteContactThroughConversations({ commit }, contactList[0].id); + expect(commit.mock.calls).toEqual([ + [types.DELETE_CONTACT, contactList[0].id], + [types.CLEAR_CONTACT_CONVERSATIONS, contactList[0].id, { root: true }], + [ + `contactConversations/${types.DELETE_CONTACT_CONVERSATION}`, + contactList[0].id, + { root: true }, + ], + ]); + }); + }); }); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index c364f7e4d..eff36b0d2 100755 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -18,6 +18,7 @@ export default { CHANGE_CHAT_STATUS_FILTER: 'CHANGE_CHAT_STATUS_FILTER', UPDATE_ASSIGNEE: 'UPDATE_ASSIGNEE', UPDATE_CONVERSATION_CONTACT: 'UPDATE_CONVERSATION_CONTACT', + CLEAR_CONTACT_CONVERSATIONS: 'CLEAR_CONTACT_CONVERSATIONS', SET_CURRENT_CHAT_WINDOW: 'SET_CURRENT_CHAT_WINDOW', CLEAR_CURRENT_CHAT_WINDOW: 'CLEAR_CURRENT_CHAT_WINDOW', @@ -101,6 +102,7 @@ export default { SET_CONTACTS: 'SET_CONTACTS', CLEAR_CONTACTS: 'CLEAR_CONTACTS', EDIT_CONTACT: 'EDIT_CONTACT', + DELETE_CONTACT: 'DELETE_CONTACT', UPDATE_CONTACTS_PRESENCE: 'UPDATE_CONTACTS_PRESENCE', // Notifications @@ -119,6 +121,7 @@ export default { SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG', SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS', ADD_CONTACT_CONVERSATION: 'ADD_CONTACT_CONVERSATION', + DELETE_CONTACT_CONVERSATION: 'DELETE_CONTACT_CONVERSATION', // Contact Label SET_CONTACT_LABELS_UI_FLAG: 'SET_CONTACT_LABELS_UI_FLAG', diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb index dfedc5ba7..a2388774f 100644 --- a/app/listeners/action_cable_listener.rb +++ b/app/listeners/action_cable_listener.rb @@ -111,6 +111,13 @@ class ActionCableListener < BaseListener broadcast(account, tokens, CONTACT_MERGED, contact.push_event_data) end + def contact_deleted(event) + contact, account = extract_contact_and_account(event) + tokens = user_tokens(account, account.agents) + + broadcast(account, tokens, CONTACT_DELETED, contact.push_event_data) + end + private def typing_event_listener_tokens(account, conversation, user) diff --git a/app/models/contact.rb b/app/models/contact.rb index b81b520f2..267dfab72 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -48,6 +48,7 @@ class Contact < ApplicationRecord before_validation :prepare_email_attribute after_create_commit :dispatch_create_event, :ip_lookup after_update_commit :dispatch_update_event + after_destroy_commit :dispatch_destroy_event def get_source_id(inbox_id) contact_inboxes.find_by!(inbox_id: inbox_id).source_id @@ -73,7 +74,8 @@ class Contact < ApplicationRecord id: id, name: name, avatar: avatar_url, - type: 'contact' + type: 'contact', + account: account.webhook_data } end @@ -98,4 +100,8 @@ class Contact < ApplicationRecord def dispatch_update_event Rails.configuration.dispatcher.dispatch(CONTACT_UPDATED, Time.zone.now, contact: self) end + + def dispatch_destroy_event + Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self) + end end diff --git a/app/policies/contact_policy.rb b/app/policies/contact_policy.rb index fb4cd4009..9013014d7 100644 --- a/app/policies/contact_policy.rb +++ b/app/policies/contact_policy.rb @@ -30,4 +30,8 @@ class ContactPolicy < ApplicationPolicy def create? true end + + def destroy? + @account_user.administrator? + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8e3d5d19f..494622381 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -92,7 +92,9 @@ en: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" - + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/routes.rb b/config/routes.rb index a3239d962..36fc15e3d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -76,7 +76,7 @@ Rails.application.routes.draw do end end - resources :contacts, only: [:index, :show, :update, :create] do + resources :contacts, only: [:index, :show, :update, :create, :destroy] do collection do get :active get :search diff --git a/lib/events/types.rb b/lib/events/types.rb index c47691cd2..9c0f04fce 100644 --- a/lib/events/types.rb +++ b/lib/events/types.rb @@ -35,6 +35,7 @@ module Events::Types CONTACT_CREATED = 'contact.created' CONTACT_UPDATED = 'contact.updated' CONTACT_MERGED = 'contact.merged' + CONTACT_DELETED = 'contact.deleted' # agent events AGENT_ADDED = 'agent.added' diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index af94fcd19..13d2649f3 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -376,4 +376,53 @@ RSpec.describe 'Contacts API', type: :request do end end end + + describe 'DELETE /api/v1/accounts/{account.id}/contacts/:id', :contact_delete do + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + + it 'deletes the contact for administrator user' do + allow(::OnlineStatusTracker).to receive(:get_presence).and_return(false) + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", + headers: admin.create_new_auth_token + + expect(contact.conversations).to be_empty + expect(contact.inboxes).to be_empty + expect(contact.contact_inboxes).to be_empty + expect(contact.csat_survey_responses).to be_empty + expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(response).to have_http_status(:success) + end + + it 'does not delete the contact if online' do + allow(::OnlineStatusTracker).to receive(:get_presence).and_return(true) + + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns unauthorized for agent user' do + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb index a70ff6675..8e7aed2a1 100644 --- a/spec/listeners/action_cable_listener_spec.rb +++ b/spec/listeners/action_cable_listener_spec.rb @@ -65,4 +65,19 @@ describe ActionCableListener do listener.conversation_typing_off(event) end end + + describe '#contact_deleted' do + let(:event_name) { :'contact.deleted' } + let!(:contact) { create(:contact, account: account) } + let!(:event) { Events::Base.new(event_name, Time.zone.now, contact: contact) } + + it 'sends message to account admins, inbox agents' do + expect(ActionCableBroadcastJob).to receive(:perform_later).with( + [agent.pubsub_token, admin.pubsub_token], + 'contact.deleted', + contact.push_event_data.merge(account_id: account.id) + ) + listener.contact_deleted(event) + end + end end diff --git a/swagger/paths/contact/crud.yml b/swagger/paths/contact/crud.yml index fafa89cb4..7f0089b4b 100644 --- a/swagger/paths/contact/crud.yml +++ b/swagger/paths/contact/crud.yml @@ -48,3 +48,22 @@ put: description: Contact not found 403: description: Access denied + +delete: + tags: + - Contact + operationId: contactDelete + summary: Delete Contact + parameters: + - name: id + in: path + type: number + description: ID of the contact + required: true + responses: + 200: + description: Success + 401: + description: Unauthorized + 404: + description: Contact not found \ No newline at end of file diff --git a/swagger/swagger.json b/swagger/swagger.json index d9254d8e5..c1ffa0835 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1249,6 +1249,33 @@ "description": "Access denied" } } + }, + "delete": { + "tags": [ + "Contact" + ], + "operationId": "contactDelete", + "summary": "Delete Contact", + "parameters": [ + { + "name": "id", + "in": "path", + "type": "number", + "description": "ID of the contact", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Contact not found" + } + } } }, "/api/v1/accounts/{account_id}/contacts/{id}/conversations": { From 54bdb2957f3896bcd3703b2be6a4b316dbc0249d Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Thu, 23 Sep 2021 20:59:10 +0530 Subject: [PATCH 14/76] feat: Add assignee last seen to conversations (#3069) - Adds assignee last seen attribute to conversations --- .../api/v1/accounts/conversations_controller.rb | 5 +++++ app/models/conversation.rb | 1 + .../conversations/messages/index.json.jbuilder | 1 + .../partials/_conversation.json.jbuilder | 1 + db/migrate/20210922082754_add_assignee_last_seen.rb | 5 +++++ db/schema.rb | 3 ++- .../v1/accounts/conversations_controller_spec.rb | 13 +++++++++++++ 7 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20210922082754_add_assignee_last_seen.rb diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 59dc051a7..5e7c7d433 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -69,6 +69,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def update_last_seen @conversation.agent_last_seen_at = DateTime.now.utc + @conversation.assignee_last_seen_at = DateTime.now.utc if assignee? @conversation.save! end @@ -136,4 +137,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def conversation_finder @conversation_finder ||= ConversationFinder.new(current_user, params) end + + def assignee? + @conversation.assignee_id? && current_user == @conversation.assignee + end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 71f6d0a36..bc4db57b6 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -5,6 +5,7 @@ # id :integer not null, primary key # additional_attributes :jsonb # agent_last_seen_at :datetime +# assignee_last_seen_at :datetime # contact_last_seen_at :datetime # custom_attributes :jsonb # identifier :string diff --git a/app/views/api/v1/accounts/conversations/messages/index.json.jbuilder b/app/views/api/v1/accounts/conversations/messages/index.json.jbuilder index db58b3faa..e1a3e1125 100644 --- a/app/views/api/v1/accounts/conversations/messages/index.json.jbuilder +++ b/app/views/api/v1/accounts/conversations/messages/index.json.jbuilder @@ -4,6 +4,7 @@ json.meta do json.contact @conversation.contact.push_event_data json.assignee @conversation.assignee.push_event_data if @conversation.assignee.present? json.agent_last_seen_at @conversation.agent_last_seen_at + json.assignee_last_seen_at @conversation.assignee_last_seen_at end json.payload do diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 1e35e8217..411cbf8f1 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -31,6 +31,7 @@ json.can_reply conversation.can_reply? json.timestamp conversation.last_activity_at.to_i json.contact_last_seen_at conversation.contact_last_seen_at.to_i json.agent_last_seen_at conversation.agent_last_seen_at.to_i +json.assignee_last_seen_at conversation.assignee_last_seen_at.to_i json.unread_count conversation.unread_incoming_messages.count json.additional_attributes conversation.additional_attributes json.custom_attributes conversation.custom_attributes diff --git a/db/migrate/20210922082754_add_assignee_last_seen.rb b/db/migrate/20210922082754_add_assignee_last_seen.rb new file mode 100644 index 000000000..41ff17818 --- /dev/null +++ b/db/migrate/20210922082754_add_assignee_last_seen.rb @@ -0,0 +1,5 @@ +class AddAssigneeLastSeen < ActiveRecord::Migration[6.1] + def change + add_column :conversations, :assignee_last_seen_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index dcb1fe612..2b3d5d274 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: 2021_09_16_060144) do +ActiveRecord::Schema.define(version: 2021_09_22_082754) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -296,6 +296,7 @@ ActiveRecord::Schema.define(version: 2021_09_16_060144) do t.bigint "campaign_id" t.datetime "snoozed_until" t.jsonb "custom_attributes", default: {} + t.datetime "assignee_last_seen_at" 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 ["assignee_id", "account_id"], name: "index_conversations_on_assignee_id_and_account_id" diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index ec53ec853..fdb8e51d5 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -393,6 +393,19 @@ RSpec.describe 'Conversations API', type: :request do expect(response).to have_http_status(:success) expect(conversation.reload.agent_last_seen_at).not_to eq nil end + + it 'updates assignee last seen' do + conversation.update!(assignee_id: agent.id) + + expect(conversation.reload.assignee_last_seen_at).to eq nil + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.assignee_last_seen_at).not_to eq nil + end end end From 1f4685ae4bb22343606681337788b31b0638cca7 Mon Sep 17 00:00:00 2001 From: Hugo Sarti Date: Thu, 23 Sep 2021 15:50:08 -0300 Subject: [PATCH 15/76] fix: offset issue for Business hours on timezones with DST --- app/views/api/v1/widget/configs/create.json.jbuilder | 2 +- app/views/widgets/show.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/api/v1/widget/configs/create.json.jbuilder b/app/views/api/v1/widget/configs/create.json.jbuilder index 8a7696a87..8fbd5a502 100644 --- a/app/views/api/v1/widget/configs/create.json.jbuilder +++ b/app/views/api/v1/widget/configs/create.json.jbuilder @@ -16,7 +16,7 @@ json.chatwoot_website_channel do json.csat_survey_enabled @web_widget.inbox.csat_survey_enabled json.working_hours @web_widget.inbox.working_hours json.out_of_office_message @web_widget.inbox.out_of_office_message - json.utc_off_set ActiveSupport::TimeZone[@web_widget.inbox.timezone].formatted_offset + json.utc_off_set ActiveSupport::TimeZone[@web_widget.inbox.timezone].now.formatted_offset end json.chatwoot_widget_defaults do json.use_inbox_avatar_for_bot ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false)) diff --git a/app/views/widgets/show.html.erb b/app/views/widgets/show.html.erb index 04f8ac0e6..6c4d38f88 100644 --- a/app/views/widgets/show.html.erb +++ b/app/views/widgets/show.html.erb @@ -23,7 +23,7 @@ csatSurveyEnabled: <%= @web_widget.inbox.csat_survey_enabled %>, workingHours: <%= @web_widget.inbox.working_hours.to_json.html_safe %>, outOfOfficeMessage: <%= @web_widget.inbox.out_of_office_message.to_json.html_safe %>, - utcOffset: '<%= ActiveSupport::TimeZone[@web_widget.inbox.timezone].formatted_offset %>' + utcOffset: '<%= ActiveSupport::TimeZone[@web_widget.inbox.timezone].now.formatted_offset %>' } window.chatwootWidgetDefaults = { useInboxAvatarForBot: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false)) %>, From 1cb2226dbc239ff37e1288a4f94c6b9a8cf49c51 Mon Sep 17 00:00:00 2001 From: Akhil G Krishnan Date: Fri, 24 Sep 2021 17:22:22 +0530 Subject: [PATCH 16/76] chore: Remove unused memoization in telegram incoming service (#3075) --- app/services/telegram/incoming_message_service.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb index ac1459887..33fd6f1f9 100644 --- a/app/services/telegram/incoming_message_service.rb +++ b/app/services/telegram/incoming_message_service.rb @@ -30,10 +30,6 @@ class Telegram::IncomingMessageService params.dig(:message, :chat, :type) == 'private' end - def account - @account ||= inbox.account - end - def set_contact contact_inbox = ::ContactBuilder.new( source_id: params[:message][:from][:id], From 8b7f6c691aea399e6244c9a73377c2a5e325fbbb Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 24 Sep 2021 20:37:30 +0530 Subject: [PATCH 17/76] bug: fixes error in creating a new one off campaign (#3067) * bug: fixes error in creating a new one off campaign * Review fixes Co-authored-by: Muhsin Keloth --- .../routes/dashboard/settings/campaigns/AddCampaign.vue | 2 +- .../routes/dashboard/settings/campaigns/EditCampaign.vue | 2 +- app/javascript/dashboard/store/modules/inboxes.js | 5 +++++ .../dashboard/store/modules/specs/inboxes/fixtures.js | 2 +- .../dashboard/store/modules/specs/inboxes/getters.spec.js | 5 +++++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue b/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue index cf56f66e9..267188592 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue @@ -237,7 +237,7 @@ export default { if (this.isOngoingType) { return this.$store.getters['inboxes/getWebsiteInboxes']; } - return this.$store.getters['inboxes/getTwilioInboxes']; + return this.$store.getters['inboxes/getTwilioSMSInboxes']; }, sendersAndBotList() { return [ diff --git a/app/javascript/dashboard/routes/dashboard/settings/campaigns/EditCampaign.vue b/app/javascript/dashboard/routes/dashboard/settings/campaigns/EditCampaign.vue index 592a09fe7..deb3b56b6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/campaigns/EditCampaign.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/campaigns/EditCampaign.vue @@ -161,7 +161,7 @@ export default { if (this.isOngoingType) { return this.$store.getters['inboxes/getWebsiteInboxes']; } - return this.$store.getters['inboxes/getTwilioInboxes']; + return this.$store.getters['inboxes/getTwilioSMSInboxes']; }, pageTitle() { return `${this.$t('CAMPAIGN.EDIT.TITLE')} - ${ diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 6094608e6..b02d35f3f 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -73,6 +73,11 @@ export const getters = { item => item.channel_type === INBOX_TYPES.TWILIO ); }, + getTwilioSMSInboxes($state) { + return $state.records.filter( + item => item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms' + ); + }, }; export const actions = { diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js b/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js index 3a245aa2f..9db92b00a 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js @@ -49,10 +49,10 @@ export default [ name: 'Test Widget 5', channel_type: 'Channel::TwilioSms', avatar_url: null, + medium: 'sms', page_id: null, widget_color: '#68BC00', website_token: 'randomid125', enable_auto_assignment: true, }, - ]; diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js index f70e372cb..f49d68e89 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js @@ -19,6 +19,11 @@ describe('#getters', () => { expect(getters.getTwilioInboxes(state).length).toEqual(1); }); + it('getTwilioSMSInboxes', () => { + const state = { records: inboxList }; + expect(getters.getTwilioSMSInboxes(state).length).toEqual(1); + }); + it('getInbox', () => { const state = { records: inboxList, From 15aaa8883c9a7af453d66f57ef69990b4d564b51 Mon Sep 17 00:00:00 2001 From: "Aswin Dev P.S" Date: Mon, 27 Sep 2021 21:12:08 +0530 Subject: [PATCH 18/76] feat: API to Filter reports by teams (#3066) Add API to Filter reports by teams Fixes: #2916 --- app/builders/v2/report_builder.rb | 12 +++++-- .../api/v2/accounts/reports_controller.rb | 6 ++++ app/models/team.rb | 8 +++++ .../api/v2/accounts/reports/teams.csv.erb | 19 +++++++++++ config/locales/en.yml | 5 +++ config/routes.rb | 1 + .../api/v2/accounts/report_controller_spec.rb | 33 +++++++++++++++++++ swagger/parameters/report_type.yml | 2 +- swagger/paths/index.yml | 4 +-- swagger/swagger.json | 7 ++-- 10 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 app/views/api/v2/accounts/reports/teams.csv.erb diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 30804b8e8..33b2db820 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -41,19 +41,25 @@ class V2::ReportBuilder user when :label label + when :team + team end end def inbox - @inbox ||= account.inboxes.where(id: params[:id]).first + @inbox ||= account.inboxes.find(params[:id]) end def user - @user ||= account.users.where(id: params[:id]).first + @user ||= account.users.find(params[:id]) end def label - @label ||= account.labels.where(id: params[:id]).first + @label ||= account.labels.find(params[:id]) + end + + def team + @team ||= account.teams.find(params[:id]) end def conversations_count diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index af28fe544..4fabd587e 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -29,6 +29,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render layout: false, template: 'api/v2/accounts/reports/labels.csv.erb', format: 'csv' end + def teams + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = 'attachment; filename=teams_report.csv' + render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv' + end + private def check_authorization diff --git a/app/models/team.rb b/app/models/team.rb index 9f6c2f3ed..1b998eb4c 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -40,4 +40,12 @@ class Team < ApplicationRecord def remove_member(user_id) team_members.find_by(user_id: user_id)&.destroy end + + def messages + account.messages.where(conversation_id: conversations.pluck(:id)) + end + + def events + account.events.where(conversation_id: conversations.pluck(:id)) + end end diff --git a/app/views/api/v2/accounts/reports/teams.csv.erb b/app/views/api/v2/accounts/reports/teams.csv.erb new file mode 100644 index 000000000..55c679273 --- /dev/null +++ b/app/views/api/v2/accounts/reports/teams.csv.erb @@ -0,0 +1,19 @@ +<% headers = [ + I18n.t('reports.team_csv.team_name'), + I18n.t('reports.team_csv.conversations_count'), + I18n.t('reports.team_csv.avg_first_response_time'), + I18n.t('reports.team_csv.avg_resolution_time') + ] +%> +<%= CSV.generate_line headers %> +<% Current.account.teams.each do |team| %> + <% team_report = V2::ReportBuilder.new(Current.account, { + type: :team, + id: team.id, + since: params[:since], + until: params[:until] + }).summary %> + <% row = [ team.name, team_report[:conversations_count], (team_report[:avg_first_response_time]/60).to_i, (team_report[:avg_resolution_time]/60).to_i ] %> + <%= CSV.generate_line row %> +<% end %> +<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 494622381..12cb8ce0f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -52,6 +52,11 @@ en: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: diff --git a/config/routes.rb b/config/routes.rb index 36fc15e3d..a1c4ceff5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -188,6 +188,7 @@ Rails.application.routes.draw do get :agents get :inboxes get :labels + get :teams end end end diff --git a/spec/controllers/api/v2/accounts/report_controller_spec.rb b/spec/controllers/api/v2/accounts/report_controller_spec.rb index 201c53f16..123119fb5 100644 --- a/spec/controllers/api/v2/accounts/report_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/report_controller_spec.rb @@ -192,4 +192,37 @@ RSpec.describe 'Reports API', type: :request do end end end + + describe 'GET /api/v2/accounts/:account_id/reports/teams' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/teams.csv" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + params = { + since: 30.days.ago.to_i.to_s, + until: Time.zone.today.to_time.to_i.to_s + } + + it 'returns unauthorized for teams' do + get "/api/v2/accounts/#{account.id}/reports/teams.csv", + params: params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns summary' do + get "/api/v2/accounts/#{account.id}/reports/teams.csv", + params: params, + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:success) + end + end + end end diff --git a/swagger/parameters/report_type.yml b/swagger/parameters/report_type.yml index 6c78f964d..407478d9d 100644 --- a/swagger/parameters/report_type.yml +++ b/swagger/parameters/report_type.yml @@ -2,6 +2,6 @@ in: query name: report_type schema: type: string - enum: [account,agent,inbox,label] + enum: [account,agent,inbox,label,team] required: true description: Type of report diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 2fb77e254..4324b2504 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -314,7 +314,7 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat ### Reports # List -/api/v1/accounts/{id}/reports: +/api/v2/accounts/{id}/reports: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/report_metric' @@ -338,7 +338,7 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat $ref: './reports/index.yml' # Summary -/api/v1/accounts/{id}/reports/summary: +/api/v2/accounts/{id}/reports/summary: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/report_type' diff --git a/swagger/swagger.json b/swagger/swagger.json index c1ffa0835..f4aad11f9 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -3088,7 +3088,7 @@ } } }, - "/api/v1/accounts/{id}/reports": { + "/api/v2/accounts/{id}/reports": { "parameters": [ { "$ref": "#/parameters/account_id" @@ -3151,7 +3151,7 @@ } } }, - "/api/v1/accounts/{id}/reports/summary": { + "/api/v2/accounts/{id}/reports/summary": { "parameters": [ { "$ref": "#/parameters/account_id" @@ -4498,7 +4498,8 @@ "account", "agent", "inbox", - "label" + "label", + "team" ] }, "required": true, From 1761bec6152b3c7dedf0c97da4e6468914d7e881 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 28 Sep 2021 12:33:08 +0530 Subject: [PATCH 19/76] feat: Adds ability to edit out webhook URL of API Channel (#3013) --- .../dashboard/i18n/locale/en/inboxMgmt.json | 9 ++++- .../dashboard/settings/inbox/Settings.vue | 39 ++++++++++++++++++- app/javascript/shared/helpers/Validators.js | 2 + .../helpers/specs/ValidatorsHelper.spec.js | 7 ++++ 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 4d05e5807..b585eda4d 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -56,6 +56,11 @@ "CHANNEL_AVATAR": { "LABEL": "Channel Avatar" }, + "CHANNEL_WEBHOOK_URL": { + "LABEL": "Webhook URL", + "PLACEHOLDER": "Enter your Webhook URL", + "ERROR": "Please enter a valid URL" + }, "CHANNEL_DOMAIN": { "LABEL": "Website Domain", "PLACEHOLDER": "Enter your website domain (eg: acme.com)" @@ -127,11 +132,11 @@ "ERROR_MESSAGE": "We were not able to authenticate Twilio credentials, please try again" } }, - "SMS": { + "SMS": { "TITLE": "SMS Channel via Twilio", "DESC": "Start supporting your customers via SMS with Twilio integration." }, - "WHATSAPP": { + "WHATSAPP": { "TITLE": "Whatsapp Channel via Twilio", "DESC": "Start supporting your customers via Whatsapp with Twilio integration." }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index cf8ddd2fd..fd7204fe7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -22,7 +22,7 @@ @@ -32,6 +32,24 @@ :label="inboxNameLabel" :placeholder="inboxNamePlaceHolder" /> + + import { mapGetters } from 'vuex'; import { createMessengerScript } from 'dashboard/helper/scriptGenerator'; +import { required } from 'vuelidate/lib/validators'; +import { shouldBeUrl } from 'shared/helpers/Validators'; import configMixin from 'shared/mixins/configMixin'; import alertMixin from 'shared/mixins/alertMixin'; import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner'; @@ -357,6 +387,7 @@ export default { csatSurveyEnabled: false, selectedInboxName: '', channelWebsiteUrl: '', + webhookUrl: '', channelWelcomeTitle: '', channelWelcomeTagline: '', selectedFeatureFlags: [], @@ -503,6 +534,7 @@ export default { this.fetchAttachedAgents(); this.avatarUrl = this.inbox.avatar_url; this.selectedInboxName = this.inbox.name; + this.webhookUrl = this.inbox.webhook_url; this.greetingEnabled = this.inbox.greeting_enabled || false; this.greetingMessage = this.inbox.greeting_message || ''; this.autoAssignment = this.inbox.enable_auto_assignment; @@ -555,6 +587,7 @@ export default { channel: { widget_color: this.inbox.widget_color, website_url: this.channelWebsiteUrl, + webhook_url: this.webhookUrl, welcome_title: this.channelWelcomeTitle || '', welcome_tagline: this.channelWelcomeTagline || '', selectedFeatureFlags: this.selectedFeatureFlags, @@ -593,6 +626,10 @@ export default { }, }, validations: { + webhookUrl: { + required, + shouldBeUrl, + }, selectedAgents: { isEmpty() { return !!this.selectedAgents.length; diff --git a/app/javascript/shared/helpers/Validators.js b/app/javascript/shared/helpers/Validators.js index 57e5e0e58..d6b802de8 100644 --- a/app/javascript/shared/helpers/Validators.js +++ b/app/javascript/shared/helpers/Validators.js @@ -1,2 +1,4 @@ export const isPhoneE164 = value => !!value.match(/^\+[1-9]\d{1,14}$/); export const isPhoneE164OrEmpty = value => isPhoneE164(value) || value === ''; +export const shouldBeUrl = (value = '') => + value ? value.startsWith('http') : true; diff --git a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js new file mode 100644 index 000000000..ba393a7a3 --- /dev/null +++ b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js @@ -0,0 +1,7 @@ +import { shouldBeUrl } from '../Validators'; + +describe('#shouldBeUrl', () => { + it('should return correct url', () => { + expect(shouldBeUrl('http')).toEqual(true); + }); +}); From edd0e2329f22acc164e5e42aabd0fc0522676a67 Mon Sep 17 00:00:00 2001 From: "Aswin Dev P.S" Date: Tue, 28 Sep 2021 19:25:44 +0530 Subject: [PATCH 20/76] feat: Add the ability to close and reopen the chat window via SDK (#3080) --- app/javascript/packs/sdk.js | 4 ++-- app/javascript/sdk/IFrameHelper.js | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/javascript/packs/sdk.js b/app/javascript/packs/sdk.js index ab02f1f3a..d666b864b 100755 --- a/app/javascript/packs/sdk.js +++ b/app/javascript/packs/sdk.js @@ -40,8 +40,8 @@ const runSDK = ({ baseUrl, websiteToken }) => { launcherTitle: chatwootSettings.launcherTitle || '', showPopoutButton: chatwootSettings.showPopoutButton || false, - toggle() { - IFrameHelper.events.toggleBubble(); + toggle(state) { + IFrameHelper.events.toggleBubble(state); }, setUser(identifier, user) { diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js index 5fcf215c0..098171fe5 100644 --- a/app/javascript/sdk/IFrameHelper.js +++ b/app/javascript/sdk/IFrameHelper.js @@ -137,8 +137,15 @@ export const IFrameHelper = { setBubbleText(window.$chatwoot.launcherTitle || message.label); }, - toggleBubble: () => { - onBubbleClick(); + toggleBubble: state => { + let bubbleState = {}; + if (state === 'open') { + bubbleState.toggleValue = true; + } else if (state === 'close') { + bubbleState.toggleValue = false; + } + + onBubbleClick(bubbleState); }, onBubbleToggle: isOpen => { From ed22e7cad4252a0d8e9e082898f96649e111c2ce Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 28 Sep 2021 19:34:11 +0530 Subject: [PATCH 21/76] chore: mitigate CVE-2021-41098 (#3101) ignoring CVE-2021-41098 as Chatwoot doesn't support JRuby at the moment fixes: #3097 --- .bundler-audit.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .bundler-audit.yml diff --git a/.bundler-audit.yml b/.bundler-audit.yml new file mode 100644 index 000000000..afe8702ac --- /dev/null +++ b/.bundler-audit.yml @@ -0,0 +1,3 @@ +--- +ignore: + - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) From 6129edce0889d57103628ed4a63ce9579063f57f Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 28 Sep 2021 20:49:50 +0530 Subject: [PATCH 22/76] fix: dialogflow integration in docker environments (#3072) * natively compile ruby gems * add missing compiler tools * add bundler platform ruby to force native compilation * fix bundler env in final docker stage * add comments to DockerFile Co-authored-by: Sojan Jose --- Gemfile.lock | 9 +++++++++ docker/Dockerfile | 8 ++++++++ docker/entrypoints/rails.sh | 1 + 3 files changed, 18 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 45a7b9133..f89baf242 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -247,6 +247,7 @@ GEM google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) + google-protobuf (3.17.3) google-protobuf (3.17.3-universal-darwin) google-protobuf (3.17.3-x86_64-linux) googleapis-common-protos (1.3.11) @@ -264,6 +265,9 @@ GEM signet (~> 0.14) groupdate (5.2.2) activesupport (>= 5) + grpc (1.38.0) + google-protobuf (~> 3.15) + googleapis-common-protos-types (~> 1.0) grpc (1.38.0-universal-darwin) google-protobuf (~> 3.15) googleapis-common-protos-types (~> 1.0) @@ -346,6 +350,7 @@ GEM mime-types-data (3.2021.0704) mini_magick (4.11.0) mini_mime (1.1.1) + mini_portile2 (2.5.3) minitest (5.14.4) mock_redis (0.28.0) ruby2_keywords @@ -360,6 +365,9 @@ GEM netrc (0.11.0) newrelic_rpm (7.2.0) nio4r (2.5.8) + nokogiri (1.11.7) + mini_portile2 (~> 2.5.0) + racc (~> 1.4) nokogiri (1.11.7-arm64-darwin) racc (~> 1.4) nokogiri (1.11.7-x86_64-darwin) @@ -617,6 +625,7 @@ GEM PLATFORMS arm64-darwin-20 + ruby x86_64-darwin-18 x86_64-darwin-20 x86_64-darwin-21 diff --git a/docker/Dockerfile b/docker/Dockerfile index 7b595a111..e51a86379 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -33,6 +33,11 @@ WORKDIR /app COPY Gemfile Gemfile.lock ./ +# natively compile grpc and protobuf to support alpine musl (dialogflow-docker workflow) +# https://github.com/googleapis/google-cloud-ruby/issues/13306 +RUN apk add --no-cache musl ruby-full ruby-dev gcc make musl-dev openssl openssl-dev g++ linux-headers +RUN bundle config set --local force_ruby_platform true + # Do not install development or test gems in production RUN if [ "$RAILS_ENV" = "production" ]; then \ bundle config set without 'development test'; bundle install -j 4 -r 3; \ @@ -71,6 +76,9 @@ ENV EXECJS_RUNTIME ${EXECJS_RUNTIME} ARG RAILS_SERVE_STATIC_FILES=true ENV RAILS_SERVE_STATIC_FILES ${RAILS_SERVE_STATIC_FILES} +ARG BUNDLE_FORCE_RUBY_PLATFORM=1 +ENV BUNDLE_FORCE_RUBY_PLATFORM ${BUNDLE_FORCE_RUBY_PLATFORM} + ARG RAILS_ENV=production ENV RAILS_ENV ${RAILS_ENV} ENV BUNDLE_PATH="/gems" diff --git a/docker/entrypoints/rails.sh b/docker/entrypoints/rails.sh index 8bdf0551c..77657f6ee 100755 --- a/docker/entrypoints/rails.sh +++ b/docker/entrypoints/rails.sh @@ -20,6 +20,7 @@ done echo "Database ready to accept connections." +#install missing gems for local dev as we are using base image compiled for production bundle install BUNDLE="bundle check" From bba2750975fbc69a0d2a0a91d5374c4bfa2886cc Mon Sep 17 00:00:00 2001 From: Fayaz Ahmed <15716057+fayazara@users.noreply.github.com> Date: Wed, 29 Sep 2021 12:01:58 +0530 Subject: [PATCH 23/76] feat: Add ability to bulk import contacts (#3026) Co-authored-by: Pranav Raj S --- app/javascript/dashboard/api/contacts.js | 8 ++ .../dashboard/api/specs/contacts.spec.js | 12 +++ .../assets/scss/_utility-helpers.scss | 3 + .../dashboard/assets/scss/widgets/_modal.scss | 3 +- .../dashboard/i18n/locale/en/contact.json | 15 ++- .../contacts/components/ContactsView.vue | 10 ++ .../dashboard/contacts/components/Header.vue | 18 +++- .../contacts/components/ImportContacts.vue | 92 +++++++++++++++++++ .../store/modules/contacts/actions.js | 13 ++- public/downloads/import-contacts-sample.csv | 26 ++++++ 10 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 app/javascript/dashboard/assets/scss/_utility-helpers.scss create mode 100644 app/javascript/dashboard/routes/dashboard/contacts/components/ImportContacts.vue create mode 100644 public/downloads/import-contacts-sample.csv diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 0ed9bb101..a6415cb37 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -52,6 +52,14 @@ class ContactAPI extends ApiClient { )}`; return axios.get(requestURL); } + + importContacts(file) { + const formData = new FormData(); + formData.append('import_file', file); + return axios.post(`${this.url}/import`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } } export default new ContactAPI(); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index 0c0a21125..03a71ca11 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -59,6 +59,18 @@ describe('#ContactsAPI', () => { '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' ); }); + + it('#importContacts', () => { + const file = 'file'; + contactAPI.importContacts(file); + expect(context.axiosMock.post).toHaveBeenCalledWith( + '/api/v1/contacts/import', + expect.any(FormData), + { + headers: { 'Content-Type': 'multipart/form-data' }, + } + ); + }); }); }); diff --git a/app/javascript/dashboard/assets/scss/_utility-helpers.scss b/app/javascript/dashboard/assets/scss/_utility-helpers.scss new file mode 100644 index 000000000..60fecb994 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/_utility-helpers.scss @@ -0,0 +1,3 @@ +.margin-right-small { + margin-right: var(--space-small); +} diff --git a/app/javascript/dashboard/assets/scss/widgets/_modal.scss b/app/javascript/dashboard/assets/scss/widgets/_modal.scss index 8c4e656c4..a017f776a 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_modal.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_modal.scss @@ -71,7 +71,8 @@ @include padding($space-large); } - form { + form, + .modal-content { @include padding($space-large); align-self: center; diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index d08b363ff..44f241e79 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -54,6 +54,19 @@ "TITLE": "Create new contact", "DESC": "Add basic information details about the contact." }, + "IMPORT_CONTACTS": { + "BUTTON_LABEL": "Import", + "TITLE": "Import Contacts", + "DESC": "Import contacts through a CSV file.", + "DOWNLOAD_LABEL": "Download a sample csv.", + "FORM": { + "LABEL": "CSV File", + "SUBMIT": "Import", + "CANCEL": "Cancel" + }, + "SUCCESS_MESSAGE": "Contacts saved successfully", + "ERROR_MESSAGE": "There was an error, please try again" + }, "DELETE_CONTACT": { "BUTTON_LABEL": "Delete Contact", "TITLE": "Delete contact", @@ -255,4 +268,4 @@ "ERROR_MESSAGE": "Could not merge contcts, try again!" } } -} +} \ No newline at end of file diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue index ada19502e..4ac5c0fc7 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue @@ -7,6 +7,7 @@ this-selected-contact-id="" :on-input-search="onInputSearch" :on-toggle-create="onToggleCreate" + :on-toggle-import="onToggleImport" :header-title="label" /> + + + @@ -41,6 +45,7 @@ import ContactsTable from './ContactsTable'; import ContactInfoPanel from './ContactInfoPanel'; import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact'; import TableFooter from 'dashboard/components/widgets/TableFooter'; +import ImportContacts from './ImportContacts.vue'; const DEFAULT_PAGE = 1; @@ -51,6 +56,7 @@ export default { TableFooter, ContactInfoPanel, CreateContact, + ImportContacts, }, props: { label: { type: String, default: '' }, @@ -59,6 +65,7 @@ export default { return { searchQuery: '', showCreateModal: false, + showImportModal: false, selectedContactId: '', sortConfig: { name: 'asc' }, }; @@ -168,6 +175,9 @@ export default { onToggleCreate() { this.showCreateModal = !this.showCreateModal; }, + onToggleImport() { + this.showImportModal = !this.showImportModal; + }, onSortChange(params) { this.sortConfig = params; this.fetchContacts(this.meta.currentPage); diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue index 93e471a76..008bf2c34 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue @@ -29,11 +29,20 @@ {{ $t('CREATE_CONTACT.BUTTON_LABEL') }} + + + {{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }} + @@ -41,7 +50,6 @@ diff --git a/app/javascript/dashboard/constants.js b/app/javascript/dashboard/constants.js index 58fdabdc1..5692e85e4 100644 --- a/app/javascript/dashboard/constants.js +++ b/app/javascript/dashboard/constants.js @@ -10,6 +10,7 @@ export default { RESOLVED: 'resolved', PENDING: 'pending', SNOOZED: 'snoozed', + ALL: 'all', }, }; export const DEFAULT_REDIRECT_URL = '/app/'; diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index 3445aeb95..7262c4f9a 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -10,6 +10,7 @@ "SEARCH": { "INPUT": "Search for People, Chats, Saved Replies .." }, + "FILTER_ALL": "All", "STATUS_TABS": [ { "NAME": "Open", diff --git a/app/javascript/dashboard/store/modules/conversations/helpers.js b/app/javascript/dashboard/store/modules/conversations/helpers.js index 27df5c7ef..be136af54 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers.js @@ -5,6 +5,9 @@ export const findPendingMessageIndex = (chat, message) => { ); }; +const filterByStatus = (chatStatus, filterStatus) => + filterStatus === 'all' ? true : chatStatus === filterStatus; + export const applyPageFilters = (conversation, filters) => { const { inboxId, status, labels = [], teamId } = filters; const { @@ -15,9 +18,8 @@ export const applyPageFilters = (conversation, filters) => { } = conversation; const team = meta.team || {}; const { id: chatTeamId } = team; - const filterByStatus = chatStatus === status; - let shouldFilter = filterByStatus; + let shouldFilter = filterByStatus(chatStatus, status); if (inboxId) { const filterByInbox = Number(inboxId) === chatInboxId; shouldFilter = shouldFilter && filterByInbox; diff --git a/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js index 6f3463666..af25f5527 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js @@ -7,28 +7,28 @@ const conversationList = [ { id: 1, inbox_id: 2, - status: 1, + status: 'open', meta: {}, labels: ['sales', 'dev'], }, { id: 2, inbox_id: 2, - status: 1, + status: 'open', meta: {}, labels: ['dev'], }, { id: 11, inbox_id: 3, - status: 1, + status: 'resolved', meta: { team: { id: 5 } }, labels: [], }, { id: 22, inbox_id: 4, - status: 1, + status: 'pending', meta: { team: { id: 5 } }, labels: ['sales'], }, @@ -56,14 +56,14 @@ describe('#applyPageFilters', () => { describe('#filter-team', () => { it('returns true if conversation has team and team filter is active', () => { const filters = { - status: 1, + status: 'resolved', teamId: 5, }; - expect(applyPageFilters(conversationList[3], filters)).toEqual(true); + expect(applyPageFilters(conversationList[2], filters)).toEqual(true); }); it('returns true if conversation has no team and team filter is active', () => { const filters = { - status: 1, + status: 'open', teamId: 5, }; expect(applyPageFilters(conversationList[0], filters)).toEqual(false); @@ -73,14 +73,14 @@ describe('#applyPageFilters', () => { describe('#filter-inbox', () => { it('returns true if conversation has inbox and inbox filter is active', () => { const filters = { - status: 1, + status: 'pending', inboxId: 4, }; expect(applyPageFilters(conversationList[3], filters)).toEqual(true); }); it('returns true if conversation has no inbox and inbox filter is active', () => { const filters = { - status: 1, + status: 'open', inboxId: 5, }; expect(applyPageFilters(conversationList[0], filters)).toEqual(false); @@ -90,14 +90,14 @@ describe('#applyPageFilters', () => { describe('#filter-labels', () => { it('returns true if conversation has labels and labels filter is active', () => { const filters = { - status: 1, + status: 'open', labels: ['dev'], }; expect(applyPageFilters(conversationList[0], filters)).toEqual(true); }); it('returns true if conversation has no inbox and inbox filter is active', () => { const filters = { - status: 1, + status: 'open', labels: ['dev'], }; expect(applyPageFilters(conversationList[2], filters)).toEqual(false); @@ -107,7 +107,13 @@ describe('#applyPageFilters', () => { describe('#filter-status', () => { it('returns true if conversation has status and status filter is active', () => { const filters = { - status: 1, + status: 'open', + }; + expect(applyPageFilters(conversationList[1], filters)).toEqual(true); + }); + it('returns true if conversation has status and status filter is all', () => { + const filters = { + status: 'all', }; expect(applyPageFilters(conversationList[1], filters)).toEqual(true); }); diff --git a/spec/finders/conversation_finder_spec.rb b/spec/finders/conversation_finder_spec.rb index 82a479837..67b526b64 100644 --- a/spec/finders/conversation_finder_spec.rb +++ b/spec/finders/conversation_finder_spec.rb @@ -48,6 +48,15 @@ describe ::ConversationFinder do end end + context 'with status all' do + let(:params) { { status: 'all' } } + + it 'returns all conversations' do + result = conversation_finder.perform + expect(result[:conversations].length).to be 5 + end + end + context 'with assignee_type assigned' do let(:params) { { assignee_type: 'assigned' } } diff --git a/swagger/paths/conversation/index.yml b/swagger/paths/conversation/index.yml index 7c690fe56..7871908f8 100644 --- a/swagger/paths/conversation/index.yml +++ b/swagger/paths/conversation/index.yml @@ -15,7 +15,7 @@ get: - name: status in: query type: string - enum: ['open', 'resolved', 'pending'] + enum: ['open', 'resolved', 'pending', 'all'] - name: page in: query type: integer diff --git a/swagger/swagger.json b/swagger/swagger.json index f4aad11f9..450c18f4d 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1488,7 +1488,8 @@ "enum": [ "open", "resolved", - "pending" + "pending", + "all" ] }, { From 49ac4a440074bafc3e519be16878e653a34522e3 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 29 Sep 2021 18:29:13 +0530 Subject: [PATCH 28/76] chore: Fix Twilio callback url not appearing until refresh (#3106) The Twilio callback url wasn't being displayed until the page is refreshed fixes: #3034 --- .../accounts/channels/twilio_channels/create.json.jbuilder | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/views/api/v1/accounts/channels/twilio_channels/create.json.jbuilder b/app/views/api/v1/accounts/channels/twilio_channels/create.json.jbuilder index 8b27a80a7..981c1dec0 100644 --- a/app/views/api/v1/accounts/channels/twilio_channels/create.json.jbuilder +++ b/app/views/api/v1/accounts/channels/twilio_channels/create.json.jbuilder @@ -1,6 +1 @@ -json.id @inbox.id -json.channel_id @inbox.channel_id -json.name @inbox.name -json.channel_type @inbox.channel_type -json.enable_auto_assignment @inbox.enable_auto_assignment -json.phone_number @inbox.channel.phone_number +json.partial! 'api/v1/models/inbox.json.jbuilder', resource: @inbox From 57abdc4d5fc45bdbdc7951fe0ca0e6fd8387fff3 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Wed, 29 Sep 2021 19:33:51 +0530 Subject: [PATCH 29/76] feat: Display "Snoozed Until" time on conversation header (#3028) --- .../dashboard/assets/scss/_woot.scss | 1 + .../assets/scss/widgets/_conv-header.scss | 14 --- .../components/widgets/InboxName.vue | 35 ++++++ .../widgets/conversation/ConversationCard.vue | 33 ++---- .../conversation/ConversationHeader.vue | 110 ++++++++++++------ .../widgets/conversation/MoreActions.vue | 67 +++++------ .../conversation/specs/MoreActions.spec.js | 34 +----- .../i18n/locale/en/conversation.json | 5 +- .../dashboard/contacts/components/Header.vue | 1 - .../store/modules/conversations/actions.js | 35 +++--- .../store/modules/conversations/index.js | 6 +- .../specs/conversations/actions.spec.js | 15 ++- .../specs/conversations/mutations.spec.js | 4 +- .../dashboard/store/mutation-types.js | 2 +- .../conversations/event_data_presenter.rb | 3 +- .../conversations/toggle_status.json.jbuilder | 3 +- .../partials/_conversation.json.jbuilder | 19 +-- spec/models/conversation_spec.rb | 1 + .../event_data_presenter_spec.rb | 1 + 19 files changed, 217 insertions(+), 172 deletions(-) create mode 100644 app/javascript/dashboard/components/widgets/InboxName.vue diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 908daa315..7debac956 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -14,6 +14,7 @@ @import 'helper-classes'; @import 'formulate'; @import 'date-picker'; +@import 'utility-helpers'; @import 'foundation-sites/scss/foundation'; @import '~bourbon/core/bourbon'; diff --git a/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss b/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss index f286c7bc0..c0d6b1bb6 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss @@ -42,14 +42,6 @@ $resolve-button-width: 13.2rem; margin-right: var(--space-normal); min-width: 0; - .user--name { - @include margin(0); - display: inline-block; - font-size: $font-size-medium; - line-height: 1.3; - text-transform: capitalize; - width: 100%; - } .user--profile__meta { align-items: flex-start; @@ -59,12 +51,6 @@ $resolve-button-width: 13.2rem; margin-left: $space-slab; min-width: 0; } - - .user--profile__button { - font-size: $font-size-mini; - margin-top: $space-micro; - padding: 0; - } } } diff --git a/app/javascript/dashboard/components/widgets/InboxName.vue b/app/javascript/dashboard/components/widgets/InboxName.vue new file mode 100644 index 000000000..cafbabad3 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/InboxName.vue @@ -0,0 +1,35 @@ + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index 34a47fc9c..3f0be1e79 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -19,11 +19,7 @@ />
diff --git a/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue b/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue index 2c73969e5..f7fe31bd2 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue @@ -1,41 +1,29 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue b/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue index c0aa1eae9..c289cea17 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue @@ -9,9 +9,10 @@ :back-url="backUrl" :show-new-button="showNewButton" /> - + +
@@ -27,6 +28,10 @@ export default { headerTitle: String, headerButtonText: String, icon: String, + keepAlive: { + type: Boolean, + default: true, + }, newButtonRoutes: { type: Array, default: () => [], diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js index 96e9062fa..d37331f02 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js @@ -14,6 +14,7 @@ export default { props: { headerTitle: 'REPORT.HEADER', icon: 'ion-arrow-graph-up-right', + keepAlive: false, }, children: [ { @@ -34,6 +35,7 @@ export default { props: { headerTitle: 'CSAT_REPORTS.HEADER', icon: 'ion-happy-outline', + keepAlive: false, }, children: [ { @@ -49,7 +51,8 @@ export default { component: SettingsContent, props: { headerTitle: 'AGENT_REPORTS.HEADER', - icon: 'ion-people', + icon: 'ion-ios-people', + keepAlive: false, }, children: [ { @@ -66,6 +69,7 @@ export default { props: { headerTitle: 'LABEL_REPORTS.HEADER', icon: 'ion-pricetags', + keepAlive: false, }, children: [ { @@ -82,6 +86,7 @@ export default { props: { headerTitle: 'INBOX_REPORTS.HEADER', icon: 'ion-archive', + keepAlive: false, }, children: [ { From e6bcf24864b217a8ac51f66b5a69bf9e3d73315d Mon Sep 17 00:00:00 2001 From: Siddharth Ramesh Date: Thu, 30 Sep 2021 22:07:20 +0530 Subject: [PATCH 32/76] chore: Add readonly attribute to woot-input component (#3119) --- app/javascript/dashboard/components/widgets/forms/Input.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/javascript/dashboard/components/widgets/forms/Input.vue b/app/javascript/dashboard/components/widgets/forms/Input.vue index 7322cda77..09aba7f2f 100644 --- a/app/javascript/dashboard/components/widgets/forms/Input.vue +++ b/app/javascript/dashboard/components/widgets/forms/Input.vue @@ -5,6 +5,7 @@ :value="value" :type="type" :placeholder="placeholder" + :readonly="readonly" @input="onChange" @blur="onBlur" /> @@ -42,6 +43,10 @@ export default { type: String, default: '', }, + readonly: { + type: Boolean, + deafaut: false, + }, }, methods: { onChange(e) { From be9a055a3fdea1ae897a0a9b03240aa471d84fb4 Mon Sep 17 00:00:00 2001 From: "Aswin Dev P.S" Date: Fri, 1 Oct 2021 01:45:29 +0530 Subject: [PATCH 33/76] feat: Ability to send attachments to telegram (#3108) This feature allows the user to send and delete attachments in the telegram conversation. Fixes #3037 --- .../widgets/conversation/ReplyBox.vue | 3 +- app/javascript/shared/mixins/inboxMixin.js | 3 + app/models/channel/telegram.rb | 52 ++++++++++++-- .../telegram/send_on_telegram_service.rb | 2 +- spec/assets/attachment.pdf | 0 spec/models/channel/telegram_spec.rb | 70 +++++++++++++++++++ 6 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 spec/assets/attachment.pdf create mode 100644 spec/models/channel/telegram_spec.rb diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index ff831281b..0d3c35cde 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -223,7 +223,8 @@ export default { this.isATwilioWhatsappChannel || this.isAPIInbox || this.isAnEmailChannel || - this.isATwilioSMSChannel + this.isATwilioSMSChannel || + this.isATelegramChannel ); }, replyButtonLabel() { diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 5ff8509fe..5ec1aeb14 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -35,6 +35,9 @@ export default { isAnEmailChannel() { return this.channelType === INBOX_TYPES.EMAIL; }, + isATelegramChannel() { + return this.channelType === INBOX_TYPES.TELEGRAM; + }, isATwilioSMSChannel() { const { medium: medium = '' } = this.inbox; return this.isATwilioChannel && medium === 'sms'; diff --git a/app/models/channel/telegram.rb b/app/models/channel/telegram.rb index e0d020f2f..16e6427e3 100644 --- a/app/models/channel/telegram.rb +++ b/app/models/channel/telegram.rb @@ -32,14 +32,10 @@ class Channel::Telegram < ApplicationRecord "https://api.telegram.org/bot#{bot_token}" end - def send_message_on_telegram(message, chat_id) - response = HTTParty.post("#{telegram_api_url}/sendMessage", - body: { - chat_id: chat_id, - text: message - }) + def send_message_on_telegram(message) + return send_message(message) if message.attachments.empty? - response.parsed_response['result']['message_id'] if response.success? + send_attachments(message) end def get_telegram_profile_image(user_id) @@ -80,4 +76,46 @@ class Channel::Telegram < ApplicationRecord }) errors.add(:bot_token, 'error setting up the webook') unless response.success? end + + def send_message(message) + response = message_request(message.conversation[:additional_attributes]['chat_id'], message.content) + response.parsed_response['result']['message_id'] if response.success? + end + + def send_attachments(message) + send_message(message) unless message.content.nil? + + telegram_attachments = [] + message.attachments.each do |attachment| + telegram_attachment = {} + + case attachment[:file_type] + when 'image' + telegram_attachment[:type] = 'photo' + when 'file' + telegram_attachment[:type] = 'document' + end + telegram_attachment[:media] = attachment.file_url + telegram_attachments << telegram_attachment + end + + response = attachments_request(message.conversation[:additional_attributes]['chat_id'], telegram_attachments) + response.parsed_response['result'].first['message_id'] if response.success? + end + + def attachments_request(chat_id, attachments) + HTTParty.post("#{telegram_api_url}/sendMediaGroup", + body: { + chat_id: chat_id, + media: attachments.to_json + }) + end + + def message_request(chat_id, text) + HTTParty.post("#{telegram_api_url}/sendMessage", + body: { + chat_id: chat_id, + text: text + }) + end end diff --git a/app/services/telegram/send_on_telegram_service.rb b/app/services/telegram/send_on_telegram_service.rb index e6e98ff39..c4b27bc4f 100644 --- a/app/services/telegram/send_on_telegram_service.rb +++ b/app/services/telegram/send_on_telegram_service.rb @@ -8,7 +8,7 @@ class Telegram::SendOnTelegramService < Base::SendOnChannelService def perform_reply ## send reply to telegram message api # https://core.telegram.org/bots/api#sendmessage - message_id = channel.send_message_on_telegram(message.content, conversation[:additional_attributes]['chat_id']) + message_id = channel.send_message_on_telegram(message) message.update!(source_id: message_id) if message_id.present? end diff --git a/spec/assets/attachment.pdf b/spec/assets/attachment.pdf new file mode 100644 index 000000000..e69de29bb diff --git a/spec/models/channel/telegram_spec.rb b/spec/models/channel/telegram_spec.rb new file mode 100644 index 000000000..de174d89c --- /dev/null +++ b/spec/models/channel/telegram_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' + +RSpec.describe Channel::Telegram do + let(:telegram_channel) { create(:channel_telegram) } + + context 'when a valid message and empty attachments' do + it 'send message' do + message = create(:message, message_type: :outgoing, content: 'test', + conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) + + telegram_message_response = double + + allow(telegram_message_response).to receive(:success?).and_return(true) + allow(telegram_message_response).to receive(:parsed_response).and_return({ 'result' => { 'message_id' => 'telegram_123' } }) + allow(telegram_channel).to receive(:message_request).and_return(telegram_message_response) + expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123') + end + end + + context 'when a empty message and valid attachments' do + let(:message) do + create(:message, message_type: :outgoing, content: nil, + conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) + end + + it 'send image' do + telegram_attachment_response = double + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png') + + allow(telegram_attachment_response).to receive(:success?).and_return(true) + allow(telegram_attachment_response).to receive(:parsed_response).and_return({ 'result' => [{ 'message_id' => 'telegram_456' }] }) + allow(telegram_channel).to receive(:attachments_request).and_return(telegram_attachment_response) + expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_456') + end + + it 'send document' do + telegram_attachment_response = double + attachment = message.attachments.new(account_id: message.account_id, file_type: :file) + attachment.file.attach(io: File.open(Rails.root.join('spec/assets/attachment.pdf')), filename: 'attachment.pdf', + content_type: 'application/pdf') + + allow(telegram_attachment_response).to receive(:success?).and_return(true) + allow(telegram_attachment_response).to receive(:parsed_response).and_return({ 'result' => [{ 'message_id' => 'telegram_456' }] }) + allow(telegram_channel).to receive(:attachments_request).and_return(telegram_attachment_response) + expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_456') + end + end + + context 'when a valid message and valid attachment' do + it 'send both message and attachment' do + message = create(:message, message_type: :outgoing, content: 'test', + conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) + + telegram_message_response = double + telegram_attachment_response = double + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png') + + allow(telegram_message_response).to receive(:success?).and_return(true) + allow(telegram_message_response).to receive(:parsed_response).and_return({ 'result' => { 'message_id' => 'telegram_456' } }) + allow(telegram_attachment_response).to receive(:success?).and_return(true) + allow(telegram_attachment_response).to receive(:parsed_response).and_return({ 'result' => [{ 'message_id' => 'telegram_789' }] }) + + allow(telegram_channel).to receive(:message_request).and_return(telegram_message_response) + allow(telegram_channel).to receive(:attachments_request).and_return(telegram_attachment_response) + expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_789') + end + end +end From 1d94e65ca88383297cbbf1be5bfc03960fe23f20 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 1 Oct 2021 13:37:52 +0530 Subject: [PATCH 34/76] fix: CSAT reports page failing to load (#3123) CSAT reports page was failing to load when there is an assigned agent is deleted. --- app/views/api/v1/models/_agent.json.jbuilder | 3 ++- .../accounts/csat_survey_responses_controller_spec.rb | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/views/api/v1/models/_agent.json.jbuilder b/app/views/api/v1/models/_agent.json.jbuilder index 59a31603d..071fbbcd8 100644 --- a/app/views/api/v1/models/_agent.json.jbuilder +++ b/app/views/api/v1/models/_agent.json.jbuilder @@ -1,4 +1,5 @@ -json.account_id resource.account.id +# could be nil for a deleted agent hence the safe operator before account id +json.account_id resource.account&.id json.availability_status resource.availability_status json.confirmed resource.confirmed? json.email resource.email diff --git a/spec/controllers/api/v1/accounts/csat_survey_responses_controller_spec.rb b/spec/controllers/api/v1/accounts/csat_survey_responses_controller_spec.rb index 3946f20c5..526879099 100644 --- a/spec/controllers/api/v1/accounts/csat_survey_responses_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/csat_survey_responses_controller_spec.rb @@ -47,6 +47,17 @@ RSpec.describe 'CSAT Survey Responses API', type: :request do expect(response_data.pluck('id')).to include(csat_3_days_ago.id) expect(response_data.pluck('id')).not_to include(csat_10_days_ago.id) end + + it 'returns csat responses even if the agent is deleted from account' do + deleted_agent_csat = create(:csat_survey_response, account: account, assigned_agent: agent) + deleted_agent_csat.assigned_agent.account_users.destroy_all + + get "/api/v1/accounts/#{account.id}/csat_survey_responses", + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + end end end From 30244f79a6d72c678e459d235187903ff9dcdab1 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 4 Oct 2021 10:19:44 +0530 Subject: [PATCH 35/76] fixes: Reply box goes hidden and emoji input header section is broken. (#3121) --- .../dashboard/assets/scss/widgets/_conversation-view.scss | 2 +- app/javascript/shared/components/emoji/EmojiInput.vue | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss index f2c156542..f6965c493 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss @@ -93,7 +93,7 @@ .conversation-panel { @include flex; - @include flex-weight(1); + @include flex-weight(1 1 1px); @include margin($zero); flex-direction: column; height: 100%; diff --git a/app/javascript/shared/components/emoji/EmojiInput.vue b/app/javascript/shared/components/emoji/EmojiInput.vue index 91a68b9a9..eb71ac73f 100644 --- a/app/javascript/shared/components/emoji/EmojiInput.vue +++ b/app/javascript/shared/components/emoji/EmojiInput.vue @@ -133,6 +133,7 @@ $font-size-medium: 18px; ul { display: flex; list-style: none; + overflow: auto; margin: 0; padding: $space-smaller 0 0; From 40d0b2faf32dd7f66a65469f6de24144af4a2717 Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Tue, 5 Oct 2021 14:35:32 +0530 Subject: [PATCH 36/76] feat: Add Instagram Channel (#2955) --- .env.example | 3 + .../messages/facebook/message_builder.rb | 45 +----- .../messages/instagram/message_builder.rb | 150 ++++++++++++++++++ .../messages/messenger/message_builder.rb | 42 +++++ .../api/v1/accounts/callbacks_controller.rb | 10 ++ .../api/v1/instagram_callbacks_controller.rb | 30 ++++ .../assets/images/channels/messenger.png | Bin 0 -> 12771 bytes .../assets/images/instagram_direct.png | Bin 0 -> 80057 bytes .../assets/images/messenger_direct.png | Bin 0 -> 4509 bytes .../components/widgets/ChannelItem.vue | 2 +- .../components/widgets/Thumbnail.vue | 9 +- .../widgets/conversation/ConversationCard.vue | 14 +- .../conversation/ConversationHeader.vue | 16 +- .../dashboard/settings/inbox/ChannelList.vue | 2 +- .../settings/inbox/channels/Facebook.vue | 2 +- app/jobs/send_reply_job.rb | 10 +- app/jobs/webhooks/instagram_events_job.rb | 84 ++++++++++ app/models/channel/facebook_page.rb | 14 ++ app/services/instagram/message_text.rb | 49 ++++++ .../instagram/send_on_instagram_service.rb | 99 ++++++++++++ .../instagram/webhooks_base_service.rb | 21 +++ config/routes.rb | 2 + ...81438_add_instagram_id_to_facebook_page.rb | 9 ++ db/schema.rb | 23 +++ .../instagram/message_builder_spec.rb | 41 +++++ spec/factories/channel/insatgram_channel.rb | 10 ++ .../instagram_message_create_event.rb | 58 +++++++ .../instagram_message/incoming_messages.rb | 31 ++++ .../webhooks/instagram_events_job_spec.rb | 54 +++++++ .../send_on_instagram_service_spec.rb | 45 ++++++ 30 files changed, 825 insertions(+), 50 deletions(-) create mode 100644 app/builders/messages/instagram/message_builder.rb create mode 100644 app/builders/messages/messenger/message_builder.rb create mode 100644 app/controllers/api/v1/instagram_callbacks_controller.rb create mode 100644 app/javascript/dashboard/assets/images/channels/messenger.png create mode 100755 app/javascript/dashboard/assets/images/instagram_direct.png create mode 100644 app/javascript/dashboard/assets/images/messenger_direct.png create mode 100644 app/jobs/webhooks/instagram_events_job.rb create mode 100644 app/services/instagram/message_text.rb create mode 100644 app/services/instagram/send_on_instagram_service.rb create mode 100644 app/services/instagram/webhooks_base_service.rb create mode 100644 db/migrate/20210902181438_add_instagram_id_to_facebook_page.rb create mode 100644 spec/builders/messages/instagram/message_builder_spec.rb create mode 100644 spec/factories/channel/insatgram_channel.rb create mode 100644 spec/factories/instagram/instagram_message_create_event.rb create mode 100644 spec/factories/instagram_message/incoming_messages.rb create mode 100644 spec/jobs/webhooks/instagram_events_job_spec.rb create mode 100644 spec/services/instagram/send_on_instagram_service_spec.rb diff --git a/.env.example b/.env.example index 39a533355..3a5600495 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,9 @@ FB_VERIFY_TOKEN= FB_APP_SECRET= FB_APP_ID= +# https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard +IG_VERIFY_TOKEN + # Twitter # documentation: https://www.chatwoot.com/docs/twitter-app-setup TWITTER_APP_ID= diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 29579fd54..5410aa3c4 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -4,10 +4,11 @@ # 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. -class Messages::Facebook::MessageBuilder +class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder attr_reader :response def initialize(response, inbox, outgoing_echo: false) + super() @response = response @inbox = inbox @outgoing_echo = outgoing_echo @@ -47,30 +48,12 @@ class Messages::Facebook::MessageBuilder def build_message @message = conversation.messages.create!(message_params) + @attachments.each do |attachment| process_attachment(attachment) end end - def process_attachment(attachment) - return if attachment['type'].to_sym == :template - - attachment_obj = @message.attachments.new(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 - - def attach_file(attachment, file_url) - attachment_file = Down.download( - file_url - ) - attachment.file.attach( - io: attachment_file, - filename: attachment_file.original_filename, - content_type: attachment_file.content_type - ) - end - def ensure_contact_avatar return if contact_params[:remote_avatar_url].blank? return if @contact.avatar.attached? @@ -89,28 +72,6 @@ class Messages::Facebook::MessageBuilder )) 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'] diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb new file mode 100644 index 000000000..18c82d813 --- /dev/null +++ b/app/builders/messages/instagram/message_builder.rb @@ -0,0 +1,150 @@ +# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` +# Assumptions +# 1. Incase of an outgoing message which is echo, source_id will NOT be nil, +# 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. + +class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder + attr_reader :messaging + + def initialize(messaging, inbox, outgoing_echo: false) + super() + @messaging = messaging + @inbox = inbox + @outgoing_echo = outgoing_echo + end + + def perform + return if @inbox.channel.reauthorization_required? + + ActiveRecord::Base.transaction do + build_message + end + rescue Koala::Facebook::AuthenticationError + @inbox.channel.authorization_error! + raise + rescue StandardError => e + Sentry.capture_exception(e) + true + end + + private + + def attachments + @messaging[:message][:attachments] || {} + end + + def message_type + @outgoing_echo ? :outgoing : :incoming + end + + def message_source_id + @outgoing_echo ? recipient_id : sender_id + end + + def sender_id + @messaging[:sender][:id] + end + + def recipient_id + @messaging[:recipient][:id] + end + + def message + @messaging[:message] + end + + def contact + @contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact + end + + def conversation + @conversation ||= Conversation.find_by(conversation_params) || build_conversation + end + + def message_content + @messaging[:message][:text] + end + + def content_attributes + { message_id: @messaging[:message][:mid] } + end + + def build_message + return if @outgoing_echo && already_sent_from_chatwoot? + + @message = conversation.messages.create!(message_params) + + attachments.each do |attachment| + process_attachment(attachment) + end + end + + def build_conversation + @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) + Conversation.create!(conversation_params.merge( + contact_inbox_id: @contact_inbox.id + )) + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: contact.id, + additional_attributes: { + type: 'instagram_direct_message' + } + } + end + + def message_params + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: message_type, + source_id: message_source_id, + content: message_content, + content_attributes: content_attributes, + sender: @outgoing_echo ? nil : contact + } + end + + def already_sent_from_chatwoot? + cw_message = conversation.messages.where( + source_id: nil, + message_type: 'outgoing', + content: message_content, + private: false, + status: :sent + ).first + cw_message.update(content_attributes: content_attributes) if cw_message.present? + cw_message.present? + end + + ### Sample response + # { + # "object": "instagram", + # "entry": [ + # { + # "id": "",// ig id of the business + # "time": 1569262486134, + # "messaging": [ + # { + # "sender": { + # "id": "" + # }, + # "recipient": { + # "id": "" + # }, + # "timestamp": 1569262485349, + # "message": { + # "mid": "", + # "text": "" + # } + # } + # ] + # } + # ], + # } +end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb new file mode 100644 index 000000000..08aa58be0 --- /dev/null +++ b/app/builders/messages/messenger/message_builder.rb @@ -0,0 +1,42 @@ +class Messages::Messenger::MessageBuilder + def process_attachment(attachment) + return if attachment['type'].to_sym == :template + + attachment_obj = @message.attachments.new(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 + + def attach_file(attachment, file_url) + attachment_file = Down.download( + file_url + ) + attachment.file.attach( + io: attachment_file, + filename: attachment_file.original_filename, + content_type: attachment_file.content_type + ) + 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 +end diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 07a6b4d71..aa0524568 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -12,6 +12,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController page_access_token: page_access_token ) @facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel) + set_instagram_id(page_access_token, facebook_channel) set_avatar(@facebook_inbox, page_id) rescue StandardError => e Rails.logger.info e @@ -22,6 +23,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController @page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts')) end + def set_instagram_id(page_access_token, facebook_channel) + fb_object = Koala::Facebook::API.new(page_access_token) + response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' }) + return if response['instagram_business_account'].blank? + + instagram_id = response['instagram_business_account']['id'] + facebook_channel.update(instagram_id: instagram_id) + end + # get params[:inbox_id], current_account. params[:omniauth_token] def reauthorize_page if @inbox&.facebook? diff --git a/app/controllers/api/v1/instagram_callbacks_controller.rb b/app/controllers/api/v1/instagram_callbacks_controller.rb new file mode 100644 index 000000000..0c7e7a94c --- /dev/null +++ b/app/controllers/api/v1/instagram_callbacks_controller.rb @@ -0,0 +1,30 @@ +class Api::V1::InstagramCallbacksController < ApplicationController + skip_before_action :authenticate_user!, raise: false + skip_before_action :set_current_user + + def verify + if valid_instagram_token?(params['hub.verify_token']) + Rails.logger.info('Instagram webhook verified') + render json: params['hub.challenge'] + else + render json: { error: 'Error; wrong verify token', status: 403 } + end + end + + def events + Rails.logger.info('Instagram webhook received events') + if params['object'].casecmp('instagram').zero? + ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry]) + render json: :ok + else + Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}") + head :unprocessable_entity + end + end + + private + + def valid_instagram_token?(token) + token == ENV['IG_VERIFY_TOKEN'] + end +end diff --git a/app/javascript/dashboard/assets/images/channels/messenger.png b/app/javascript/dashboard/assets/images/channels/messenger.png new file mode 100644 index 0000000000000000000000000000000000000000..f1bbb68d41b472ac1d12aa7928ec9d7ef3c98bf9 GIT binary patch literal 12771 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 literal 0 HcmV?d00001 diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 183f42c10..07a984f04 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -6,7 +6,7 @@ > + @@ -73,9 +73,23 @@ export default { uiFlags: 'inboxAssignableAgents/getUIFlags', currentChat: 'getSelectedChat', }), + + chatExtraAttributes() { + return this.chat.additional_attributes; + }, + chatMetadata() { return this.chat.meta; }, + + chatBadge() { + if(this.chatExtraAttributes['type']){ + return this.chatExtraAttributes['type'] + } else { + return this.chatMetadata.channel + } + }, + currentContact() { return this.$store.getters['contacts/getContact']( this.chat.meta.sender.id diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index 086b2f27a..27b3cbc73 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -40,7 +40,7 @@ export default { const { apiChannelName, apiChannelThumbnail } = this.globalConfig; return [ { key: 'website', name: 'Website' }, - { key: 'facebook', name: 'Facebook' }, + { key: 'facebook', name: 'Messenger' }, { key: 'twitter', name: 'Twitter' }, { key: 'whatsapp', name: 'WhatsApp via Twilio' }, { key: 'sms', name: 'SMS via Twilio' }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue index 96cb95c1c..338d7bbeb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue @@ -206,7 +206,7 @@ export default { } }, { - scope: 'pages_manage_metadata,pages_messaging', + scope: 'pages_manage_metadata,pages_messaging,instagram_basic,pages_show_list,instagram_manage_messages', } ); }, diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb index b834c41ea..8e73433f6 100644 --- a/app/jobs/send_reply_job.rb +++ b/app/jobs/send_reply_job.rb @@ -3,10 +3,16 @@ class SendReplyJob < ApplicationJob def perform(message_id) message = Message.find(message_id) - channel_name = message.conversation.inbox.channel.class.to_s + conversation = message.conversation + channel_name = conversation.inbox.channel.class.to_s + case channel_name when 'Channel::FacebookPage' - ::Facebook::SendOnFacebookService.new(message: message).perform + if conversation.additional_attributes['type'] == 'instagram_direct_message' + ::Instagram::SendOnInstagramService.new(message: message).perform + else + ::Facebook::SendOnFacebookService.new(message: message).perform + end when 'Channel::TwitterProfile' ::Twitter::SendOnTwitterService.new(message: message).perform when 'Channel::TwilioSms' diff --git a/app/jobs/webhooks/instagram_events_job.rb b/app/jobs/webhooks/instagram_events_job.rb new file mode 100644 index 000000000..ef077c7ee --- /dev/null +++ b/app/jobs/webhooks/instagram_events_job.rb @@ -0,0 +1,84 @@ +class Webhooks::InstagramEventsJob < ApplicationJob + queue_as :default + + include HTTParty + + base_uri 'https://graph.facebook.com/v11.0/me' + + # @return [Array] We will support further events like reaction or seen in future + SUPPORTED_EVENTS = [:message].freeze + + # @see https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook + def perform(entries) + @entries = entries + + if @entries[0].key?(:changes) + Rails.logger.info('Probably Test data.') + # grab the test entry for the review app + create_test_text + return + end + + @entries.each do |entry| + entry[:messaging].each do |messaging| + send(@event_name, messaging) if event_name(messaging) + end + end + end + + private + + def event_name(messaging) + @event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) } + end + + def message(messaging) + ::Instagram::MessageText.new(messaging).perform + end + + def create_test_text + messenger_channel = Channel::FacebookPage.last + @inbox = ::Inbox.find_by!(channel: messenger_channel) + @contact_inbox = @inbox.contact_inboxes.where(source_id: 'sender_username').first + unless @contact_inbox + @contact_inbox ||= @inbox.channel.create_contact_inbox( + 'sender_username', 'sender_username' + ) + end + @contact = @contact_inbox.contact + + @conversation ||= Conversation.find_by(conversation_params) || build_conversation(conversation_params) + + @message = @conversation.messages.create!(message_params) + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: @contact.id, + additional_attributes: { + type: 'instagram_direct_message' + } + } + end + + def message_params + { + account_id: @conversation.account_id, + inbox_id: @conversation.inbox_id, + message_type: 'incoming', + source_id: 'facebook_test_webhooks', + content: 'This is a test message from facebook.', + sender: @contact + } + end + + def build_conversation(conversation_params) + Conversation.create!( + conversation_params.merge( + contact_inbox_id: @contact_inbox.id + ) + ) + end +end diff --git a/app/models/channel/facebook_page.rb b/app/models/channel/facebook_page.rb index d564d0048..6735d5e35 100644 --- a/app/models/channel/facebook_page.rb +++ b/app/models/channel/facebook_page.rb @@ -8,6 +8,7 @@ # created_at :datetime not null # updated_at :datetime not null # account_id :integer not null +# instagram_id :string # page_id :string not null # # Indexes @@ -35,6 +36,19 @@ class Channel::FacebookPage < ApplicationRecord true end + def create_contact_inbox(instagram_id, name) + ActiveRecord::Base.transaction do + contact = inbox.account.contacts.create!(name: name) + ::ContactInbox.create( + contact_id: contact.id, + inbox_id: inbox.id, + source_id: instagram_id + ) + rescue StandardError => e + Rails.logger.info e + end + end + def subscribe # ref https://developers.facebook.com/docs/messenger-platform/reference/webhook-events response = Facebook::Messenger::Subscriptions.subscribe( diff --git a/app/services/instagram/message_text.rb b/app/services/instagram/message_text.rb new file mode 100644 index 000000000..b4bfc1ed5 --- /dev/null +++ b/app/services/instagram/message_text.rb @@ -0,0 +1,49 @@ +class Instagram::MessageText < Instagram::WebhooksBaseService + include HTTParty + + attr_reader :messaging + + base_uri 'https://graph.facebook.com/v11.0/' + + def initialize(messaging) + super() + @messaging = messaging + end + + def perform + instagram_id, contact_id = if agent_message_via_echo? + [@messaging[:sender][:id], @messaging[:recipient][:id]] + else + [@messaging[:recipient][:id], @messaging[:sender][:id]] + end + inbox_channel(instagram_id) + ensure_contact(contact_id) + + create_message + end + + private + + def ensure_contact(ig_scope_id) + begin + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + result = k.get_object(ig_scope_id) || {} + rescue Koala::Facebook::AuthenticationError + @inbox.channel.authorization_error! + raise + rescue StandardError => e + result = {} + Sentry.capture_exception(e) + end + + find_or_create_contact(result) + end + + def agent_message_via_echo? + @messaging[:message][:is_echo].present? + end + + def create_message + Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform + end +end diff --git a/app/services/instagram/send_on_instagram_service.rb b/app/services/instagram/send_on_instagram_service.rb new file mode 100644 index 000000000..17ba76cb1 --- /dev/null +++ b/app/services/instagram/send_on_instagram_service.rb @@ -0,0 +1,99 @@ +class Instagram::SendOnInstagramService < Base::SendOnChannelService + include HTTParty + + pattr_initialize [:message!] + + base_uri 'https://graph.facebook.com/v11.0/me' + + private + + delegate :additional_attributes, to: :contact + + def channel_class + Channel::FacebookPage + end + + def perform_reply + send_to_facebook_page attachament_message_params if message.attachments.present? + send_to_facebook_page message_params + rescue StandardError => e + Sentry.capture_exception(e) + channel.authorization_error! + end + + def message_params + { + recipient: { id: contact.get_source_id(inbox.id) }, + message: { + text: message.content + } + } + end + + def attachament_message_params + attachment = message.attachments.first + { + recipient: { id: contact.get_source_id(inbox.id) }, + message: { + attachment: { + type: attachment_type(attachment), + payload: { + url: attachment.file_url + } + } + } + } + end + + # Deliver a message with the given payload. + # @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message + def send_to_facebook_page(message_content) + access_token = channel.page_access_token + app_secret_proof = calculate_app_secret_proof(ENV['FB_APP_SECRET'], access_token) + + query = { access_token: access_token } + query[:appsecret_proof] = app_secret_proof if app_secret_proof + + # url = "https://graph.facebook.com/v11.0/me/messages?access_token=#{access_token}" + + response = HTTParty.post( + 'https://graph.facebook.com/v11.0/me/messages', + body: message_content, + query: query + ) + # response = HTTParty.post(url, options) + + Rails.logger.info("Instagram response: #{response} : #{message_content}") if response[:body][:error] + + response[:body] + end + + def calculate_app_secret_proof(app_secret, access_token) + Facebook::Messenger::Configuration::AppSecretProofCalculator.call( + app_secret, access_token + ) + end + + def attachment_type(attachment) + return attachment.file_type if %w[image audio video file].include? attachment.file_type + + 'file' + end + + def conversation_type + conversation.additional_attributes['type'] + end + + def sent_first_outgoing_message_after_24_hours? + # we can send max 1 message after 24 hour window + conversation.messages.outgoing.where('id > ?', last_incoming_message.id).count == 1 + end + + def last_incoming_message + conversation.messages.incoming.last + end + + def config + Facebook::Messenger.config + end +end diff --git a/app/services/instagram/webhooks_base_service.rb b/app/services/instagram/webhooks_base_service.rb new file mode 100644 index 000000000..2a534ac82 --- /dev/null +++ b/app/services/instagram/webhooks_base_service.rb @@ -0,0 +1,21 @@ +class Instagram::WebhooksBaseService + private + + def inbox_channel(instagram_id) + messenger_channel = Channel::FacebookPage.where(instagram_id: instagram_id) + @inbox = ::Inbox.find_by!(channel: messenger_channel) + end + + def find_or_create_contact(user) + @contact_inbox = @inbox.contact_inboxes.where(source_id: user['id']).first + @contact = @contact_inbox.contact if @contact_inbox + return if @contact + + @contact_inbox = @inbox.channel.create_contact_inbox( + user['id'], user['name'] + ) + + @contact = @contact_inbox.contact + ContactAvatarJob.perform_later(@contact, user['profile_pic']) if user['profile_pic'] + end +end diff --git a/config/routes.rb b/config/routes.rb index a1c4ceff5..871490d2b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -251,6 +251,8 @@ Rails.application.routes.draw do post 'webhooks/twitter', to: 'api/v1/webhooks#twitter_events' post 'webhooks/line/:line_channel_id', to: 'webhooks/line#process_payload' post 'webhooks/telegram/:bot_token', to: 'webhooks/telegram#process_payload' + get 'instagram_callbacks/event', to: 'api/v1/instagram_callbacks#verify' + post 'instagram_callbacks/event', to: 'api/v1/instagram_callbacks#events' namespace :twitter do resource :callback, only: [:show] diff --git a/db/migrate/20210902181438_add_instagram_id_to_facebook_page.rb b/db/migrate/20210902181438_add_instagram_id_to_facebook_page.rb new file mode 100644 index 000000000..391fac835 --- /dev/null +++ b/db/migrate/20210902181438_add_instagram_id_to_facebook_page.rb @@ -0,0 +1,9 @@ +class AddInstagramIdToFacebookPage < ActiveRecord::Migration[6.1] + def up + add_column :channel_facebook_pages, :instagram_id, :string + end + + def down + remove_column :channel_facebook_pages, :instagram_id, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 2b3d5d274..b7040e19a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -181,6 +181,7 @@ ActiveRecord::Schema.define(version: 2021_09_22_082754) do t.integer "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "instagram_id" 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 @@ -244,6 +245,28 @@ ActiveRecord::Schema.define(version: 2021_09_22_082754) do t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true end + create_table "companies", force: :cascade do |t| + t.string "name", null: false + t.text "address" + t.string "city", null: false + t.string "state" + t.string "country", null: false + t.integer "no_of_employees", null: false + t.string "industry_type" + t.bigint "annual_revenue" + t.text "website" + t.string "office_phone_number" + t.string "facebook" + t.string "twitter" + t.string "linkedin" + t.jsonb "additional_attributes" + t.bigint "contact_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["contact_id"], name: "index_companies_on_contact_id" + t.index ["name"], name: "index_companies_on_name", unique: true + end + create_table "contact_inboxes", force: :cascade do |t| t.bigint "contact_id" t.bigint "inbox_id" diff --git a/spec/builders/messages/instagram/message_builder_spec.rb b/spec/builders/messages/instagram/message_builder_spec.rb new file mode 100644 index 000000000..7f9395af9 --- /dev/null +++ b/spec/builders/messages/instagram/message_builder_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +describe ::Messages::Instagram::MessageBuilder do + subject(:instagram_message_builder) { described_class } + + let!(:account) { create(:account) } + let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } + let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) } + let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access } + let(:fb_object) { double } + let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') } + let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') } + + describe '#perform' do + it 'creates contact and message for the facebook inbox' do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { + name: 'Jane', + id: 'Sender-id-1', + account_id: instagram_inbox.account_id, + profile_pic: 'https://via.placeholder.com/250x250.png' + }.with_indifferent_access + ) + messaging = dm_params[:entry][0]['messaging'][0] + contact_inbox + instagram_message_builder.new(messaging, instagram_inbox).perform + + instagram_inbox.reload + + expect(instagram_inbox.conversations.count).to be 1 + expect(instagram_inbox.messages.count).to be 1 + + contact = instagram_channel.inbox.contacts.first + message = instagram_channel.inbox.messages.first + + expect(contact.name).to eq('Jane Dae') + expect(message.content).to eq('This is the first message from the customer') + end + end +end diff --git a/spec/factories/channel/insatgram_channel.rb b/spec/factories/channel/insatgram_channel.rb new file mode 100644 index 000000000..e2192d683 --- /dev/null +++ b/spec/factories/channel/insatgram_channel.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :channel_instagram_fb_page, class: 'Channel::FacebookPage' do + page_access_token { SecureRandom.uuid } + user_access_token { SecureRandom.uuid } + page_id { SecureRandom.uuid } + account + end +end diff --git a/spec/factories/instagram/instagram_message_create_event.rb b/spec/factories/instagram/instagram_message_create_event.rb new file mode 100644 index 000000000..d0dbffdab --- /dev/null +++ b/spec/factories/instagram/instagram_message_create_event.rb @@ -0,0 +1,58 @@ +FactoryBot.define do + factory :instagram_message_create_event, class: Hash do + entry do + [ + { + 'id': 'instagram-message-id-123', + 'time': '2021-09-08T06:34:04+0000', + 'messaging': [ + { + 'sender': { + 'id': 'Sender-id-1' + }, + 'recipient': { + 'id': 'chatwoot-app-user-id-1' + }, + 'timestamp': '2021-09-08T06:34:04+0000', + 'message': { + 'mid': 'message-id-1', + 'text': 'This is the first message from the customer' + } + } + ] + } + ] + end + initialize_with { attributes } + end + + factory :instagram_test_text_event, class: Hash do + entry do + [ + { + 'id': 'instagram-message-id-123', + 'time': '2021-09-08T06:34:04+0000', + 'changes': [ + { + 'field': 'messages', + 'value': { + 'event_type': 'TEXT', + 'event_timestamp': '1527459824', + 'event_data': { + 'message_id': 'vcvacopiufqwehfawdnb', + 'sender': { + 'username': 'sender_username' + }, + 'recipient': { + 'thread_id': 'faeoqiehrkbfadsfawd' + } + } + } + } + ] + } + ] + end + initialize_with { attributes } + end +end diff --git a/spec/factories/instagram_message/incoming_messages.rb b/spec/factories/instagram_message/incoming_messages.rb new file mode 100644 index 000000000..dce5af627 --- /dev/null +++ b/spec/factories/instagram_message/incoming_messages.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :incoming_ig_text_message, class: Hash do + messaging do + [ + { + 'id': 'instagram-message-id-123', + 'time': '2021-09-08T06:34:04+0000', + 'messaging': [ + { + 'sender': { + 'id': 'Sender-id-1' + }, + 'recipient': { + 'id': 'chatwoot-app-user-id-1' + }, + 'timestamp': '2021-09-08T06:34:04+0000', + 'message': { + 'mid': 'message-id-1', + 'text': 'This is the first message from the customer' + } + } + ] + } + ] + end + + initialize_with { attributes } + end +end diff --git a/spec/jobs/webhooks/instagram_events_job_spec.rb b/spec/jobs/webhooks/instagram_events_job_spec.rb new file mode 100644 index 000000000..0f5d0c4a3 --- /dev/null +++ b/spec/jobs/webhooks/instagram_events_job_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' +require 'webhooks/twitter' + +describe Webhooks::InstagramEventsJob do + subject(:instagram_webhook) { described_class } + + let!(:account) { create(:account) } + let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } + let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) } + let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access } + let!(:test_params) { build(:instagram_test_text_event).with_indifferent_access } + let(:fb_object) { double } + + describe '#perform' do + context 'with direct_message params' do + it 'creates incoming message in the instagram inbox' do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { + name: 'Jane', + id: 'Sender-id-1', + account_id: instagram_inbox.account_id, + profile_pic: 'https://via.placeholder.com/250x250.png' + }.with_indifferent_access + ) + instagram_webhook.perform_now(dm_params[:entry]) + + instagram_inbox.reload + + expect(instagram_inbox.contacts.count).to be 1 + expect(instagram_inbox.conversations.count).to be 1 + expect(instagram_inbox.messages.count).to be 1 + end + + it 'creates test text message in the instagram inbox' do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { + name: 'Jane', + id: 'Sender-id-1', + account_id: instagram_inbox.account_id, + profile_pic: 'https://via.placeholder.com/250x250.png' + }.with_indifferent_access + ) + instagram_webhook.perform_now(test_params[:entry]) + + instagram_inbox.reload + + expect(instagram_inbox.messages.count).to be 1 + expect(instagram_inbox.messages.last.content).to eq('This is a test message from facebook.') + end + end + end +end diff --git a/spec/services/instagram/send_on_instagram_service_spec.rb b/spec/services/instagram/send_on_instagram_service_spec.rb new file mode 100644 index 000000000..0c5e754f1 --- /dev/null +++ b/spec/services/instagram/send_on_instagram_service_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +describe Instagram::SendOnInstagramService do + subject(:send_reply_service) { described_class.new(message: message) } + + before do + create(:message, message_type: :incoming, inbox: instagram_inbox, account: account, conversation: conversation) + end + + let!(:account) { create(:account) } + let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } + let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) } + let!(:contact) { create(:contact, account: account) } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) } + let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) } + let(:response) { double } + + describe '#perform' do + context 'with reply' do + before do + allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token') + allow(HTTParty).to receive(:post).and_return( + { + body: { recipient: { id: contact_inbox.source_id } } + } + ) + end + + it 'if message is sent from chatwoot and is outgoing' do + message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation) + response = ::Instagram::SendOnInstagramService.new(message: message).perform + expect(response).to eq({ recipient: { id: contact_inbox.source_id } }) + end + + it 'if message with attachment is sent from chatwoot and is outgoing' do + message = build(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation) + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png') + message.save! + response = ::Instagram::SendOnInstagramService.new(message: message).perform + expect(response).to eq({ recipient: { id: contact_inbox.source_id } }) + end + end + end +end From bd7aeba484895e35739665b7c191cc93189ca589 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 5 Oct 2021 23:35:06 +0530 Subject: [PATCH 37/76] chore: Provider API prototype (#3112) Enabling Support for Whatsapp via 360Dialog as a prototype for the provider APIs. Co-authored-by: Pranav Raj S --- .env.example | 2 +- .../api/v1/accounts/inboxes_controller.rb | 2 + .../webhooks/whatsapp_controller.rb | 6 + .../widgets/conversation/MessagesView.vue | 4 +- .../widgets/conversation/ReplyBox.vue | 8 +- app/javascript/dashboard/helper/inbox.js | 3 + .../dashboard/i18n/locale/en/inboxMgmt.json | 43 ++++-- .../dashboard/settings/inbox/ChannelList.vue | 2 +- .../routes/dashboard/settings/inbox/Index.vue | 3 + .../dashboard/settings/inbox/Settings.vue | 5 +- .../inbox/channels/360DialogWhatsapp.vue | 128 ++++++++++++++++++ .../settings/inbox/channels/Facebook.vue | 3 +- .../settings/inbox/channels/Twilio.vue | 10 +- .../settings/inbox/channels/Whatsapp.vue | 29 +++- .../settings/inbox/facebook/Reauthorize.vue | 3 +- app/javascript/shared/mixins/inboxMixin.js | 7 + app/jobs/send_reply_job.rb | 18 ++- app/jobs/webhooks/whatsapp_events_job.rb | 13 ++ app/models/account.rb | 1 + app/models/channel/whatsapp.rb | 67 +++++++++ .../contacts/contactable_inboxes_service.rb | 25 +++- .../whatsapp/incoming_message_service.rb | 61 +++++++++ .../whatsapp/send_on_whatsapp_service.rb | 11 ++ config/routes.rb | 1 + .../20210916112533_add_whatsapp_channel.rb | 11 ++ db/schema.rb | 24 +--- .../webhooks/whatsapp_controller_spec.rb | 12 ++ spec/factories/channel/channel_whatsapp.rb | 11 ++ spec/jobs/send_reply_job_spec.rb | 9 ++ .../whatsapp/incoming_message_service_spec.rb | 21 +++ .../whatsapp/send_on_whatsapp_service_spec.rb | 23 ++++ 31 files changed, 506 insertions(+), 60 deletions(-) create mode 100644 app/controllers/webhooks/whatsapp_controller.rb create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/360DialogWhatsapp.vue create mode 100644 app/jobs/webhooks/whatsapp_events_job.rb create mode 100644 app/models/channel/whatsapp.rb create mode 100644 app/services/whatsapp/incoming_message_service.rb create mode 100644 app/services/whatsapp/send_on_whatsapp_service.rb create mode 100644 db/migrate/20210916112533_add_whatsapp_channel.rb create mode 100644 spec/controllers/webhooks/whatsapp_controller_spec.rb create mode 100644 spec/factories/channel/channel_whatsapp.rb create mode 100644 spec/services/whatsapp/incoming_message_service_spec.rb create mode 100644 spec/services/whatsapp/send_on_whatsapp_service_spec.rb diff --git a/.env.example b/.env.example index 3a5600495..dd4052f69 100644 --- a/.env.example +++ b/.env.example @@ -101,7 +101,7 @@ FB_APP_SECRET= FB_APP_ID= # https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard -IG_VERIFY_TOKEN +IG_VERIFY_TOKEN= # Twitter # documentation: https://www.chatwoot.com/docs/twitter-app-setup diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index df055923c..3f8686499 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -96,6 +96,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController Current.account.line_channels.create!(permitted_params(Channel::Line::EDITABLE_ATTRS)[:channel].except(:type)) when 'telegram' Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type)) + when 'whatsapp' + Current.account.whatsapp_channels.create!(permitted_params(Channel::Whatsapp::EDITABLE_ATTRS)[:channel].except(:type)) end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb new file mode 100644 index 000000000..7560da1e4 --- /dev/null +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -0,0 +1,6 @@ +class Webhooks::WhatsappController < ActionController::API + def process_payload + Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) + head :ok + end +end diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index c84b14b01..db042b5b7 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -1,7 +1,7 @@ @@ -78,15 +78,15 @@ " > - {{ - props.option.title - }} + + {{ props.option.title }} + -

+
@@ -94,7 +94,7 @@ v-model="currentSelectedFilter" track-by="id" label="name" - :placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')" + :placeholder="multiselectLabel" selected-label :select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" deselect-label="" @@ -185,12 +185,19 @@ export default { const fromDate = subDays(new Date(), diff); return this.fromCustomDate(fromDate); }, + multiselectLabel() { + const typeLabels = { + agent: this.$t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL'), + label: this.$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL'), + inbox: this.$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL'), + team: this.$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL'), + }; + return typeLabels[this.type] || this.$t('FORMS.MULTISELECT.SELECT_ONE'); + }, }, watch: { filterItemsList(val) { this.currentSelectedFilter = val[0]; - }, - currentSelectedFilter() { this.changeFilterSelection(); }, }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue index 64821f62e..b5d4f889d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue @@ -15,8 +15,8 @@ @date-range-change="onDateRangeChange" @filter-change="onFilterChange" /> -
-
+
+
- + {{ $t('REPORT.NO_ENOUGH_DATA') }} @@ -118,9 +121,15 @@ export default { }; }, metrics() { - const reportKeys = [ - 'CONVERSATIONS', - 'INCOMING_MESSAGES', + let reportKeys = ['CONVERSATIONS']; + // If report type is agent, we don't need to show + // incoming messages count, as there will not be any message + // sent by an agent which is incoming. + if (this.type !== 'agent') { + reportKeys.push('INCOMING_MESSAGES'); + } + reportKeys = [ + ...reportKeys, 'OUTGOING_MESSAGES', 'FIRST_RESPONSE_TIME', 'RESOLUTION_TIME', @@ -175,6 +184,9 @@ export default { case 'inbox': this.$store.dispatch('downloadInboxReports', { from, to, fileName }); break; + case 'team': + this.$store.dispatch('downloadTeamReports', { from, to, fileName }); + break; default: break; } diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js index d37331f02..4cb837af5 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js @@ -2,6 +2,7 @@ import Index from './Index'; import AgentReports from './AgentReports'; import LabelReports from './LabelReports'; import InboxReports from './InboxReports'; +import TeamReports from './TeamReports'; import CsatResponses from './CsatResponses'; import SettingsContent from '../Wrapper'; import { frontendURL } from '../../../../helper/URLHelper'; @@ -97,5 +98,21 @@ export default { }, ], }, + { + path: frontendURL('accounts/:accountId/reports'), + component: SettingsContent, + props: { + headerTitle: 'TEAM_REPORTS.HEADER', + icon: 'ion-ios-people', + }, + children: [ + { + path: 'teams', + name: 'team_reports', + roles: ['administrator'], + component: TeamReports, + }, + ], + }, ], }; diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js index 828e8114f..87050bcef 100644 --- a/app/javascript/dashboard/store/modules/reports.js +++ b/app/javascript/dashboard/store/modules/reports.js @@ -7,6 +7,8 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import * as types from '../mutation-types'; import Report from '../../api/reports'; +import { downloadCsvFile } from '../../helper/downloadCsvFile'; + const state = { fetchingStatus: false, reportData: [], @@ -78,15 +80,7 @@ export const actions = { downloadAgentReports(_, reportObj) { return Report.getAgentReports(reportObj.from, reportObj.to) .then(response => { - let csvContent = 'data:text/csv;charset=utf-8,' + response.data; - var encodedUri = encodeURI(csvContent); - var downloadLink = document.createElement('a'); - downloadLink.href = encodedUri; - downloadLink.download = reportObj.fileName; - - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); + downloadCsvFile(reportObj.fileName, response.data); }) .catch(error => { console.error(error); @@ -95,15 +89,7 @@ export const actions = { downloadLabelReports(_, reportObj) { return Report.getLabelReports(reportObj.from, reportObj.to) .then(response => { - let csvContent = 'data:text/csv;charset=utf-8,' + response.data; - var encodedUri = encodeURI(csvContent); - var downloadLink = document.createElement('a'); - downloadLink.href = encodedUri; - downloadLink.download = reportObj.fileName; - - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); + downloadCsvFile(reportObj.fileName, response.data); }) .catch(error => { console.error(error); @@ -112,15 +98,16 @@ export const actions = { downloadInboxReports(_, reportObj) { return Report.getInboxReports(reportObj.from, reportObj.to) .then(response => { - let csvContent = 'data:text/csv;charset=utf-8,' + response.data; - var encodedUri = encodeURI(csvContent); - var downloadLink = document.createElement('a'); - downloadLink.href = encodedUri; - downloadLink.download = reportObj.fileName; - - document.body.appendChild(downloadLink); - downloadLink.click(); - // document.body.removeChild(downloadLink); + downloadCsvFile(reportObj.fileName, response.data); + }) + .catch(error => { + console.error(error); + }); + }, + downloadTeamReports(_, reportObj) { + return Report.getTeamReports(reportObj.from, reportObj.to) + .then(response => { + downloadCsvFile(reportObj.fileName, response.data); }) .catch(error => { console.error(error); diff --git a/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js b/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js index 10ccbdcd3..0ba89ff81 100644 --- a/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js @@ -78,4 +78,25 @@ describe('#actions', () => { expect(mockInboxDownloadElement.download).toEqual(param.fileName); }); }); + + describe('#downloadTeamReports', () => { + it('open CSV download prompt if API is success', async () => { + axios.get.mockResolvedValue({ + data: `Team name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes) + sales team,0,0,0 + Reporting period 2021-09-23 to 2021-09-29`, + }); + const param = { + from: 1631039400, + to: 1635013800, + fileName: 'inbox-report-24-10-2021.csv', + }; + const mockInboxDownloadElement = createElementSpy(); + await actions.downloadInboxReports(1, param); + expect(mockInboxDownloadElement.href).toEqual( + 'data:text/csv;charset=utf-8,Team%20name,Conversations%20count,Avg%20first%20response%20time%20(Minutes),Avg%20resolution%20time%20(Minutes)%0A%20%20%20%20%20%20%20%20sales%20team,0,0,0%0A%20%20%20%20%20%20%20%20Reporting%20period%202021-09-23%20to%202021-09-29' + ); + expect(mockInboxDownloadElement.download).toEqual(param.fileName); + }); + }); }); diff --git a/spec/builders/v2/report_builder_spec.rb b/spec/builders/v2/report_builder_spec.rb index 8506c88f6..bcf02f1d8 100644 --- a/spec/builders/v2/report_builder_spec.rb +++ b/spec/builders/v2/report_builder_spec.rb @@ -171,14 +171,14 @@ describe ::V2::ReportBuilder do type: :label, id: label_1.id, since: (Time.zone.today - 3.days).to_time.to_i.to_s, - until: Time.zone.today.to_time.to_i.to_s + until: (Time.zone.today + 1.day).to_time.to_i.to_s } builder = V2::ReportBuilder.new(account, params) metrics = builder.timeseries expect(metrics[Time.zone.today]).to be 20 - expect(metrics[Time.zone.today - 2.days]).to be 5 + expect(metrics[Time.zone.today - 2.days]).to be 0 end it 'return outgoing messages count' do @@ -187,14 +187,14 @@ describe ::V2::ReportBuilder do type: :label, id: label_1.id, since: (Time.zone.today - 3.days).to_time.to_i.to_s, - until: Time.zone.today.to_time.to_i.to_s + until: (Time.zone.today + 1.day).to_time.to_i.to_s } builder = V2::ReportBuilder.new(account, params) metrics = builder.timeseries expect(metrics[Time.zone.today]).to be 50 - expect(metrics[Time.zone.today - 2.days]).to be 15 + expect(metrics[Time.zone.today - 2.days]).to be 0 end it 'return resolutions count' do @@ -203,7 +203,7 @@ describe ::V2::ReportBuilder do type: :label, id: label_2.id, since: (Time.zone.today - 3.days).to_time.to_i.to_s, - until: Time.zone.today.to_time.to_i.to_s + until: (Time.zone.today + 1.day).to_time.to_i.to_s } conversations = account.conversations.where('created_at < ?', 1.day.ago) @@ -242,8 +242,8 @@ describe ::V2::ReportBuilder do metrics = builder.summary expect(metrics[:conversations_count]).to be 5 - expect(metrics[:incoming_messages_count]).to be 25 - expect(metrics[:outgoing_messages_count]).to be 65 + expect(metrics[:incoming_messages_count]).to be 5 + expect(metrics[:outgoing_messages_count]).to be 15 expect(metrics[:avg_resolution_time]).to be 0 expect(metrics[:resolutions_count]).to be 0 end From c54aae21ff8f21db3a8be45c44bc1dfda2798f0c Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 7 Oct 2021 13:21:46 +0530 Subject: [PATCH 44/76] chore: Move agent availability to Account level (#3074) - Move agent availability to the account level --- .../api/v1/accounts/agents_controller.rb | 30 +-- app/controllers/api/v1/profiles_controller.rb | 13 +- app/javascript/dashboard/api/auth.js | 6 +- app/javascript/dashboard/api/endPoints.js | 3 + .../components/layout/AvailabilityStatus.vue | 18 +- .../layout/specs/AvailabilityStatus.spec.js | 8 +- .../dashboard/store/modules/auth.js | 19 +- .../store/modules/specs/auth/actions.spec.js | 9 +- .../store/modules/specs/auth/getters.spec.js | 6 +- .../helpers/BaseActionCableConnector.js | 2 +- app/models/account_user.rb | 27 +- .../concerns/availability_statusable.rb | 32 +-- app/models/user.rb | 21 +- .../v1/accounts/agents/create.json.jbuilder | 1 + .../v1/accounts/agents/update.json.jbuilder | 1 + app/views/api/v1/models/_agent.json.jbuilder | 3 +- app/views/api/v1/models/_user.json.jbuilder | 3 +- .../api/v1/profiles/availability.jbuilder | 1 + app/views/api/v1/profiles/show.json.jbuilder | 1 + .../widget/inbox_members/index.json.jbuilder | 2 +- .../api/v1/models/_user.json.jbuilder | 1 - config/routes.rb | 8 +- ...0418_add_online_status_to_account_users.rb | 21 ++ db/schema.rb | 2 + lib/online_status_tracker.rb | 2 +- .../api/v1/accounts/agents_controller_spec.rb | 16 +- .../api/v1/profiles_controller_spec.rb | 34 ++- swagger/definitions/resource/agent.yml | 11 +- swagger/index.yml | 1 + .../{ => application}/agent_bots/create.yml | 0 .../{ => application}/agent_bots/delete.yml | 0 .../{ => application}/agent_bots/index.yml | 0 .../{ => application}/agent_bots/show.yml | 0 .../{ => application}/agent_bots/update.yml | 0 swagger/paths/application/agents/create.yml | 42 ++++ swagger/paths/application/agents/delete.yml | 21 ++ swagger/paths/application/agents/index.yml | 17 ++ swagger/paths/application/agents/update.yml | 42 ++++ .../contact_inboxes/create.yml | 0 .../contactable_inboxes/get.yml | 0 .../contacts}/conversations.yml | 0 .../contacts}/crud.yml | 0 .../contacts}/list_create.yml | 0 .../contacts}/search.yml | 0 .../conversation/assignments.yml | 0 .../{ => application}/conversation/create.yml | 0 .../{ => application}/conversation/index.yml | 0 .../conversation/labels/create.yml | 0 .../conversation/labels/index.yml | 0 .../conversation/messages/create.yml | 0 .../messages/create_attachment.yml | 0 .../conversation/messages/delete.yml | 0 .../conversation/messages/index.yml | 0 .../{ => application}/conversation/show.yml | 0 .../conversation/toggle_status.yml | 0 .../conversation/update_last_seen.yml | 0 .../custom_filters/create.yml | 0 .../custom_filters/delete.yml | 0 .../custom_filters/index.yml | 0 .../{ => application}/custom_filters/show.yml | 0 .../custom_filters/update.yml | 0 .../{ => application}/inboxes/create.yml | 0 .../inboxes/get_agent_bot.yml | 0 .../inboxes/inbox_members/create.yml | 0 .../inboxes/inbox_members/delete.yml | 0 .../inboxes/inbox_members/show.yml | 0 .../inboxes/inbox_members/update.yml | 0 .../paths/{ => application}/inboxes/index.yml | 0 .../inboxes/set_agent_bot.yml | 0 .../paths/{ => application}/inboxes/show.yml | 0 .../{ => application}/inboxes/update.yml | 0 .../integrations/apps/show.yml | 0 .../integrations/hooks/create.yml | 0 .../integrations/hooks/delete.yml | 0 .../integrations/hooks/update.yml | 0 .../paths/{ => application}/reports/index.yml | 0 .../{ => application}/reports/summary.yml | 0 .../paths/{ => application}/teams/create.yml | 0 .../paths/{ => application}/teams/delete.yml | 0 .../paths/{ => application}/teams/index.yml | 0 .../paths/{ => application}/teams/show.yml | 0 .../paths/{ => application}/teams/update.yml | 0 swagger/paths/index.yml | 111 +++++---- swagger/swagger.json | 231 +++++++++++++++++- 84 files changed, 618 insertions(+), 148 deletions(-) create mode 100644 app/views/api/v1/accounts/agents/create.json.jbuilder create mode 100644 app/views/api/v1/accounts/agents/update.json.jbuilder create mode 100644 app/views/api/v1/profiles/availability.jbuilder create mode 100644 app/views/api/v1/profiles/show.json.jbuilder create mode 100644 db/migrate/20210923190418_add_online_status_to_account_users.rb rename swagger/paths/{ => application}/agent_bots/create.yml (100%) rename swagger/paths/{ => application}/agent_bots/delete.yml (100%) rename swagger/paths/{ => application}/agent_bots/index.yml (100%) rename swagger/paths/{ => application}/agent_bots/show.yml (100%) rename swagger/paths/{ => application}/agent_bots/update.yml (100%) create mode 100644 swagger/paths/application/agents/create.yml create mode 100644 swagger/paths/application/agents/delete.yml create mode 100644 swagger/paths/application/agents/index.yml create mode 100644 swagger/paths/application/agents/update.yml rename swagger/paths/{ => application}/contact_inboxes/create.yml (100%) rename swagger/paths/{ => application}/contactable_inboxes/get.yml (100%) rename swagger/paths/{contact => application/contacts}/conversations.yml (100%) rename swagger/paths/{contact => application/contacts}/crud.yml (100%) rename swagger/paths/{contact => application/contacts}/list_create.yml (100%) rename swagger/paths/{contact => application/contacts}/search.yml (100%) rename swagger/paths/{ => application}/conversation/assignments.yml (100%) rename swagger/paths/{ => application}/conversation/create.yml (100%) rename swagger/paths/{ => application}/conversation/index.yml (100%) rename swagger/paths/{ => application}/conversation/labels/create.yml (100%) rename swagger/paths/{ => application}/conversation/labels/index.yml (100%) rename swagger/paths/{ => application}/conversation/messages/create.yml (100%) rename swagger/paths/{ => application}/conversation/messages/create_attachment.yml (100%) rename swagger/paths/{ => application}/conversation/messages/delete.yml (100%) rename swagger/paths/{ => application}/conversation/messages/index.yml (100%) rename swagger/paths/{ => application}/conversation/show.yml (100%) rename swagger/paths/{ => application}/conversation/toggle_status.yml (100%) rename swagger/paths/{ => application}/conversation/update_last_seen.yml (100%) rename swagger/paths/{ => application}/custom_filters/create.yml (100%) rename swagger/paths/{ => application}/custom_filters/delete.yml (100%) rename swagger/paths/{ => application}/custom_filters/index.yml (100%) rename swagger/paths/{ => application}/custom_filters/show.yml (100%) rename swagger/paths/{ => application}/custom_filters/update.yml (100%) rename swagger/paths/{ => application}/inboxes/create.yml (100%) rename swagger/paths/{ => application}/inboxes/get_agent_bot.yml (100%) rename swagger/paths/{ => application}/inboxes/inbox_members/create.yml (100%) rename swagger/paths/{ => application}/inboxes/inbox_members/delete.yml (100%) rename swagger/paths/{ => application}/inboxes/inbox_members/show.yml (100%) rename swagger/paths/{ => application}/inboxes/inbox_members/update.yml (100%) rename swagger/paths/{ => application}/inboxes/index.yml (100%) rename swagger/paths/{ => application}/inboxes/set_agent_bot.yml (100%) rename swagger/paths/{ => application}/inboxes/show.yml (100%) rename swagger/paths/{ => application}/inboxes/update.yml (100%) rename swagger/paths/{ => application}/integrations/apps/show.yml (100%) rename swagger/paths/{ => application}/integrations/hooks/create.yml (100%) rename swagger/paths/{ => application}/integrations/hooks/delete.yml (100%) rename swagger/paths/{ => application}/integrations/hooks/update.yml (100%) rename swagger/paths/{ => application}/reports/index.yml (100%) rename swagger/paths/{ => application}/reports/summary.yml (100%) rename swagger/paths/{ => application}/teams/create.yml (100%) rename swagger/paths/{ => application}/teams/delete.yml (100%) rename swagger/paths/{ => application}/teams/index.yml (100%) rename swagger/paths/{ => application}/teams/show.yml (100%) rename swagger/paths/{ => application}/teams/update.yml (100%) diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 334844de9..a406e0cf3 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -9,21 +9,18 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController @agents = agents end + def create; end + + def update + @agent.update!(agent_params.slice(:name).compact) + @agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact) + end + def destroy @agent.current_account_user.destroy head :ok end - def update - @agent.update!(agent_params.except(:role)) - @agent.current_account_user.update!(role: agent_params[:role]) if agent_params[:role] - render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @agent } - end - - def create - render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @user } - end - private def check_authorization @@ -47,22 +44,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def save_account_user - AccountUser.create!( + AccountUser.create!({ account_id: Current.account.id, user_id: @user.id, - role: new_agent_params[:role], inviter_id: current_user.id - ) + }.merge({ + role: new_agent_params[:role], + availability: new_agent_params[:availability], + auto_offline: new_agent_params[:auto_offline] + }.compact)) end def agent_params - params.require(:agent).permit(:email, :name, :role) + params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline) end def new_agent_params # intial string ensures the password requirements are met temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" - params.require(:agent).permit(:email, :name, :role) + params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline) .merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user) end diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index 5805f49ed..a92c479bc 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -1,9 +1,7 @@ class Api::V1::ProfilesController < Api::BaseController before_action :set_user - def show - render partial: 'api/v1/models/user.json.jbuilder', locals: { resource: @user } - end + def show; end def update if password_params[:password].present? @@ -15,19 +13,26 @@ class Api::V1::ProfilesController < Api::BaseController @user.update!(profile_params) end + def availability + @user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability]) + end + private def set_user @user = current_user end + def availability_params + params.require(:profile).permit(:account_id, :availability) + end + def profile_params params.require(:profile).permit( :email, :name, :display_name, :avatar, - :availability, ui_settings: {} ) end diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index 558eb4933..c41a43624 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -161,9 +161,9 @@ export default { }); }, - updateAvailability({ availability }) { - return axios.put(endPoints('profileUpdate').url, { - profile: { availability }, + updateAvailability(availabilityData) { + return axios.post(endPoints('availabilityUpdate').url, { + profile: { ...availabilityData }, }); }, }; diff --git a/app/javascript/dashboard/api/endPoints.js b/app/javascript/dashboard/api/endPoints.js index 8df609f70..0b801fdb3 100644 --- a/app/javascript/dashboard/api/endPoints.js +++ b/app/javascript/dashboard/api/endPoints.js @@ -13,6 +13,9 @@ const endPoints = { profileUpdate: { url: '/api/v1/profile', }, + availabilityUpdate: { + url: '/api/v1/profile/availability', + }, logout: { url: 'auth/sign_out', }, diff --git a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue index 8587bacc8..ec26b8bce 100644 --- a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue +++ b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue @@ -26,7 +26,9 @@ color-scheme="secondary" class-names="status-change--dropdown-button" :is-disabled="status.disabled" - @click="changeAvailabilityStatus(status.value)" + @click=" + changeAvailabilityStatus(status.value, currentAccountId) + " > {{ status.label }} @@ -75,7 +77,8 @@ export default { computed: { ...mapGetters({ - currentUser: 'getCurrentUser', + getCurrentUserAvailabilityStatus: 'getCurrentUserAvailabilityStatus', + getCurrentAccountId: 'getCurrentAccountId', }), availabilityDisplayLabel() { const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex( @@ -85,8 +88,11 @@ export default { availabilityIndex ]; }, + currentAccountId() { + return this.getCurrentAccountId; + }, currentUserAvailabilityStatus() { - return this.currentUser.availability_status; + return this.getCurrentUserAvailabilityStatus; }, availabilityStatuses() { return this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUSES_LIST').map( @@ -108,16 +114,16 @@ export default { closeStatusMenu() { this.isStatusMenuOpened = false; }, - changeAvailabilityStatus(availability) { + changeAvailabilityStatus(availability, accountId) { if (this.isUpdating) { return; } this.isUpdating = true; - this.$store .dispatch('updateAvailability', { - availability, + availability: availability, + account_id: accountId, }) .finally(() => { this.isUpdating = false; diff --git a/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js b/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js index d96f9761d..2954b837d 100644 --- a/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js +++ b/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js @@ -17,7 +17,8 @@ const i18nConfig = new VueI18n({ }); describe('AvailabilityStatus', () => { - const currentUser = { availability_status: 'online' }; + const currentAvailabilityStatus = 'online' ; + const currentAccountId = '1'; let store = null; let actions = null; let modules = null; @@ -33,7 +34,8 @@ describe('AvailabilityStatus', () => { modules = { auth: { getters: { - getCurrentUser: () => currentUser, + getCurrentUserAvailabilityStatus: () => currentAvailabilityStatus, + getCurrentAccountId: () => currentAccountId, }, }, }; @@ -77,7 +79,7 @@ describe('AvailabilityStatus', () => { expect(actions.updateAvailability).toBeCalledWith( expect.any(Object), - { availability: 'offline' }, + { availability: 'offline', account_id: currentAccountId }, undefined ); }); diff --git a/app/javascript/dashboard/store/modules/auth.js b/app/javascript/dashboard/store/modules/auth.js index e0691049c..0805b35db 100644 --- a/app/javascript/dashboard/store/modules/auth.js +++ b/app/javascript/dashboard/store/modules/auth.js @@ -40,7 +40,11 @@ export const getters = { }, getCurrentUserAvailabilityStatus(_state) { - return _state.currentUser.availability_status; + const { accounts = [] } = _state.currentUser; + const [currentAccount = {}] = accounts.filter( + account => account.id === _state.currentAccountId + ); + return currentAccount.availability_status; }, getCurrentAccountId(_state) { @@ -125,14 +129,17 @@ export const actions = { } }, - updateAvailability: ({ commit, dispatch }, { availability }) => { - authAPI.updateAvailability({ availability }).then(response => { + updateAvailability: async ({ commit, dispatch }, params) => { + try { + const response = await authAPI.updateAvailability(params); const userData = response.data; - const { id, availability_status: availabilityStatus } = userData; + const { id } = userData; setUser(userData, getHeaderExpiry(response)); commit(types.default.SET_CURRENT_USER); - dispatch('agents/updatePresence', { [id]: availabilityStatus }); - }); + dispatch('agents/updatePresence', { [id]: params.availability }); + } catch (error) { + // Ignore error + } }, setCurrentAccountId({ commit }, accountId) { diff --git a/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js b/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js index adbca0d94..fe503e3ea 100644 --- a/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js @@ -54,13 +54,16 @@ describe('#actions', () => { describe('#updateAvailability', () => { it('sends correct actions if API is success', async () => { - axios.put.mockResolvedValue({ - data: { id: 1, name: 'John', availability_status: 'offline' }, + axios.post.mockResolvedValue({ + data: { + id: 1, + account_users: [{ account_id: 1, availability_status: 'offline' }], + }, headers: { expiry: 581842904 }, }); await actions.updateAvailability( { commit, dispatch }, - { availability: 'offline' } + { availability: 'offline', account_id: 1 }, ); expect(setUser).toHaveBeenCalledTimes(1); expect(commit.mock.calls).toEqual([[types.default.SET_CURRENT_USER]]); diff --git a/app/javascript/dashboard/store/modules/specs/auth/getters.spec.js b/app/javascript/dashboard/store/modules/specs/auth/getters.spec.js index c934d3824..dd87cd79d 100644 --- a/app/javascript/dashboard/store/modules/specs/auth/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/auth/getters.spec.js @@ -21,7 +21,11 @@ describe('#getters', () => { it('get', () => { expect( getters.getCurrentUserAvailabilityStatus({ - currentUser: { id: 1, name: 'Pranav', availability_status: 'busy' }, + currentAccountId: 1, + currentUser: { + id: 1, + accounts: [{ id: 1, availability_status: 'busy' }], + }, }) ).toEqual('busy'); }); diff --git a/app/javascript/shared/helpers/BaseActionCableConnector.js b/app/javascript/shared/helpers/BaseActionCableConnector.js index 89b3ed6b0..f92ed9c6b 100644 --- a/app/javascript/shared/helpers/BaseActionCableConnector.js +++ b/app/javascript/shared/helpers/BaseActionCableConnector.js @@ -1,6 +1,6 @@ import { createConsumer } from '@rails/actioncable'; -const PRESENCE_INTERVAL = 60000; +const PRESENCE_INTERVAL = 20000; class BaseActionCableConnector { constructor(app, pubsubToken, websocketHost = '') { diff --git a/app/models/account_user.rb b/app/models/account_user.rb index 6c5e89e3c..8f06253fc 100644 --- a/app/models/account_user.rb +++ b/app/models/account_user.rb @@ -2,14 +2,16 @@ # # Table name: account_users # -# id :bigint not null, primary key -# active_at :datetime -# role :integer default("agent") -# created_at :datetime not null -# updated_at :datetime not null -# account_id :bigint -# inviter_id :bigint -# user_id :bigint +# id :bigint not null, primary key +# active_at :datetime +# auto_offline :boolean default(TRUE), not null +# availability :integer default("online"), not null +# role :integer default("agent") +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint +# inviter_id :bigint +# user_id :bigint # # Indexes # @@ -24,15 +26,20 @@ # class AccountUser < ApplicationRecord + include AvailabilityStatusable + belongs_to :account belongs_to :user belongs_to :inviter, class_name: 'User', optional: true enum role: { agent: 0, administrator: 1 } + enum availability: { online: 0, offline: 1, busy: 2 } + accepts_nested_attributes_for :account after_create_commit :notify_creation, :create_notification_setting after_destroy :notify_deletion, :remove_user_from_account + after_save :update_presence_in_redis, if: :saved_change_to_availability? validates :user_id, uniqueness: { scope: :account_id } @@ -56,4 +63,8 @@ class AccountUser < ApplicationRecord def notify_deletion Rails.configuration.dispatcher.dispatch(AGENT_REMOVED, Time.zone.now, account: account) end + + def update_presence_in_redis + OnlineStatusTracker.set_status(account.id, user.id, availability) + end end diff --git a/app/models/concerns/availability_statusable.rb b/app/models/concerns/availability_statusable.rb index 11d1e438e..9cfe6bfec 100644 --- a/app/models/concerns/availability_statusable.rb +++ b/app/models/concerns/availability_statusable.rb @@ -2,29 +2,29 @@ module AvailabilityStatusable extend ActiveSupport::Concern def online_presence? - return if user_profile_page_context? - - ::OnlineStatusTracker.get_presence(availability_account_id, self.class.name, id) + obj_id = is_a?(Contact) ? id : user_id + ::OnlineStatusTracker.get_presence(account_id, self.class.name, obj_id) end def availability_status - return availability if user_profile_page_context? - return 'offline' unless online_presence? - return 'online' if is_a? Contact - - ::OnlineStatusTracker.get_status(availability_account_id, id) || 'online' + if is_a? Contact + contact_availability_status + else + user_availability_status + end end - def user_profile_page_context? - # at the moment profile pages aren't account scoped - # hence we will return availability attribute instead of true presence - # we will revisit this later - is_a?(User) && Current.account.blank? + private + + def contact_availability_status + online_presence? ? 'online' : 'offline' end - def availability_account_id - return account_id if is_a? Contact + def user_availability_status + # we are not considering presence in this case. Just returns the availability + return availability unless auto_offline - Current.account.id + # availability as a fallback in case the status is not present in redis + online_presence? ? (::OnlineStatusTracker.get_status(account_id, user_id) || availability) : 'offline' end end diff --git a/app/models/user.rb b/app/models/user.rb index 89ed7be84..214658869 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -39,7 +39,6 @@ class User < ApplicationRecord include AccessTokenable - include AvailabilityStatusable include Avatarable # Include default devise modules. include DeviseTokenAuth::Concerns::User @@ -57,6 +56,8 @@ class User < ApplicationRecord :confirmable, :password_has_required_content + # TODO: remove in a future version once online status is moved to account users + # remove the column availability from users enum availability: { online: 0, offline: 1, busy: 2 } # The validation below has been commented out as it does not @@ -89,8 +90,6 @@ class User < ApplicationRecord before_validation :set_password_and_uid, on: :create - after_save :update_presence_in_redis, if: :saved_change_to_availability? - scope :order_by_full_name, -> { order('lower(name) ASC') } def send_devise_notification(notification, *args) @@ -141,6 +140,14 @@ class User < ApplicationRecord current_account_user&.role end + def availability_status + current_account_user&.availability_status + end + + def auto_offline + current_account_user&.auto_offline + end + def inviter current_account_user&.inviter end @@ -169,12 +176,4 @@ class User < ApplicationRecord type: 'user' } end - - private - - def update_presence_in_redis - accounts.each do |account| - OnlineStatusTracker.set_status(account.id, id, availability) - end - end end diff --git a/app/views/api/v1/accounts/agents/create.json.jbuilder b/app/views/api/v1/accounts/agents/create.json.jbuilder new file mode 100644 index 000000000..7f22d270d --- /dev/null +++ b/app/views/api/v1/accounts/agents/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/agent.json.jbuilder', resource: @user diff --git a/app/views/api/v1/accounts/agents/update.json.jbuilder b/app/views/api/v1/accounts/agents/update.json.jbuilder new file mode 100644 index 000000000..38328ca08 --- /dev/null +++ b/app/views/api/v1/accounts/agents/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/agent.json.jbuilder', resource: @agent diff --git a/app/views/api/v1/models/_agent.json.jbuilder b/app/views/api/v1/models/_agent.json.jbuilder index 071fbbcd8..fd7119ca5 100644 --- a/app/views/api/v1/models/_agent.json.jbuilder +++ b/app/views/api/v1/models/_agent.json.jbuilder @@ -1,10 +1,11 @@ +json.id resource.id # could be nil for a deleted agent hence the safe operator before account id json.account_id resource.account&.id json.availability_status resource.availability_status +json.auto_offline resource.auto_offline json.confirmed resource.confirmed? json.email resource.email json.available_name resource.available_name -json.id resource.id json.custom_attributes resource.custom_attributes if resource.custom_attributes.present? json.name resource.name json.role resource.role diff --git a/app/views/api/v1/models/_user.json.jbuilder b/app/views/api/v1/models/_user.json.jbuilder index 5c0cdeec9..745dd0618 100644 --- a/app/views/api/v1/models/_user.json.jbuilder +++ b/app/views/api/v1/models/_user.json.jbuilder @@ -1,6 +1,5 @@ json.access_token resource.access_token.token json.account_id resource.active_account_user&.account_id -json.availability_status resource.availability_status json.available_name resource.available_name json.avatar_url resource.avatar_url json.confirmed resource.confirmed? @@ -22,5 +21,7 @@ json.accounts do json.name account_user.account.name json.active_at account_user.active_at json.role account_user.role + json.availability_status account_user.availability_status + json.auto_offline account_user.auto_offline end end diff --git a/app/views/api/v1/profiles/availability.jbuilder b/app/views/api/v1/profiles/availability.jbuilder new file mode 100644 index 000000000..5a6dc2dad --- /dev/null +++ b/app/views/api/v1/profiles/availability.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/user.json.jbuilder', resource: @user diff --git a/app/views/api/v1/profiles/show.json.jbuilder b/app/views/api/v1/profiles/show.json.jbuilder new file mode 100644 index 000000000..5a6dc2dad --- /dev/null +++ b/app/views/api/v1/profiles/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/user.json.jbuilder', resource: @user diff --git a/app/views/api/v1/widget/inbox_members/index.json.jbuilder b/app/views/api/v1/widget/inbox_members/index.json.jbuilder index af38fc1ee..417c23756 100644 --- a/app/views/api/v1/widget/inbox_members/index.json.jbuilder +++ b/app/views/api/v1/widget/inbox_members/index.json.jbuilder @@ -3,6 +3,6 @@ json.payload do json.id inbox_member.user.id json.name inbox_member.user.available_name json.avatar_url inbox_member.user.avatar_url - json.availability_status inbox_member.user.availability_status + json.availability_status inbox_member.user.account_users.find_by(account_id: @current_account.id).availability_status end end diff --git a/app/views/platform/api/v1/models/_user.json.jbuilder b/app/views/platform/api/v1/models/_user.json.jbuilder index 1ec708b8d..4c50efaaa 100644 --- a/app/views/platform/api/v1/models/_user.json.jbuilder +++ b/app/views/platform/api/v1/models/_user.json.jbuilder @@ -1,6 +1,5 @@ json.access_token resource.access_token.token json.account_id resource.active_account_user&.account_id -json.availability_status resource.availability_status json.available_name resource.available_name json.avatar_url resource.avatar_url json.confirmed resource.confirmed? diff --git a/config/routes.rb b/config/routes.rb index 9a210473e..c825911d7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,7 +40,7 @@ Rails.application.routes.draw do resource :contact_merge, only: [:create] end - resources :agents, except: [:show, :edit, :new] + resources :agents, only: [:index, :create, :update, :destroy] resources :agent_bots, only: [:index, :create, :show, :update, :destroy] resources :callbacks, only: [] do @@ -159,7 +159,11 @@ Rails.application.routes.draw do resources :webhooks, only: [:create] end - resource :profile, only: [:show, :update] + resource :profile, only: [:show, :update] do + member do + post :availability + end + end resource :notification_subscriptions, only: [:create] namespace :widget do diff --git a/db/migrate/20210923190418_add_online_status_to_account_users.rb b/db/migrate/20210923190418_add_online_status_to_account_users.rb new file mode 100644 index 000000000..eccc9be1c --- /dev/null +++ b/db/migrate/20210923190418_add_online_status_to_account_users.rb @@ -0,0 +1,21 @@ +class AddOnlineStatusToAccountUsers < ActiveRecord::Migration[6.1] + def change + change_table :account_users, bulk: true do |t| + t.integer :availability, default: 0, null: false + t.boolean :auto_offline, default: true, null: false + end + + update_existing_user_availability + end + + private + + def update_existing_user_availability + User.find_in_batches do |user_batch| + user_batch.each do |user| + availability = user.availability + user.account_users.update(availability: availability) + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7a954f94d..5f23ac780 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -35,6 +35,8 @@ ActiveRecord::Schema.define(version: 2021_09_29_150415) do t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.datetime "active_at" + t.integer "availability", default: 0, null: false + t.boolean "auto_offline", default: true, null: false t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true t.index ["account_id"], name: "index_account_users_on_account_id" t.index ["user_id"], name: "index_account_users_on_user_id" diff --git a/lib/online_status_tracker.rb b/lib/online_status_tracker.rb index 42bc0dca0..8688c4251 100644 --- a/lib/online_status_tracker.rb +++ b/lib/online_status_tracker.rb @@ -1,5 +1,5 @@ module OnlineStatusTracker - PRESENCE_DURATION = 60.seconds + PRESENCE_DURATION = 20.seconds # presence : sorted set with timestamp as the score & object id as value diff --git a/spec/controllers/api/v1/accounts/agents_controller_spec.rb b/spec/controllers/api/v1/accounts/agents_controller_spec.rb index 872b14669..4d8aa4d65 100644 --- a/spec/controllers/api/v1/accounts/agents_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/agents_controller_spec.rb @@ -94,7 +94,7 @@ RSpec.describe 'Agents API', type: :request do expect(response).to have_http_status(:unauthorized) end - it 'modifies an agent' do + it 'modifies an agent name' do put "/api/v1/accounts/#{account.id}/agents/#{other_agent.id}", params: params, headers: admin.create_new_auth_token, @@ -103,6 +103,20 @@ RSpec.describe 'Agents API', type: :request do expect(response).to have_http_status(:success) expect(other_agent.reload.name).to eq(params[:name]) end + + it 'modifies an agents account user attributes' do + put "/api/v1/accounts/#{account.id}/agents/#{other_agent.id}", + params: { role: 'administrator', availability: 'busy', auto_offline: false }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + response_data = JSON.parse(response.body) + expect(response_data['role']).to eq('administrator') + expect(response_data['availability_status']).to eq('busy') + expect(response_data['auto_offline']).to eq(false) + expect(other_agent.account_users.first.role).to eq('administrator') + end end end diff --git a/spec/controllers/api/v1/profiles_controller_spec.rb b/spec/controllers/api/v1/profiles_controller_spec.rb index 3247b5f83..53de5bcfe 100644 --- a/spec/controllers/api/v1/profiles_controller_spec.rb +++ b/spec/controllers/api/v1/profiles_controller_spec.rb @@ -89,16 +89,6 @@ RSpec.describe 'Profile API', type: :request do expect(agent.avatar.attached?).to eq(true) end - it 'updates the availability status' do - put '/api/v1/profile', - params: { profile: { availability: 'offline' } }, - headers: agent.create_new_auth_token, - as: :json - - expect(response).to have_http_status(:success) - expect(::OnlineStatusTracker.get_status(account.id, agent.id)).to eq('offline') - end - it 'updates the ui settings' do put '/api/v1/profile', params: { profile: { ui_settings: { is_contact_sidebar_open: false } } }, @@ -111,4 +101,28 @@ RSpec.describe 'Profile API', type: :request do end end end + + describe 'POST /api/v1/profile/availability' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post '/api/v1/profile/availability' + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, password: 'Test123!', account: account, role: :agent) } + + it 'updates the availability status' do + post '/api/v1/profile/availability', + params: { profile: { availability: 'busy', account_id: account.id } }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(::OnlineStatusTracker.get_status(account.id, agent.id)).to eq('busy') + end + end + end end diff --git a/swagger/definitions/resource/agent.yml b/swagger/definitions/resource/agent.yml index 0473d3d87..e3d506a3b 100644 --- a/swagger/definitions/resource/agent.yml +++ b/swagger/definitions/resource/agent.yml @@ -1,7 +1,7 @@ type: object properties: id: - type: number + type: integer uid: type: string name: @@ -13,12 +13,19 @@ properties: email: type: string account_id: - type: number + type: integer role: type: string enum: ['agent', 'administrator'] confirmed: type: boolean + availability_status: + type: string + enum: ['available', 'busy', 'offline'] + description: The availability status of the agent computed by Chatwoot. + auto_offline: + type: boolean + description: Whether the availability status of agent is configured to go offline automatically when away. custom_attributes: type: object description: Available for users who are created through platform APIs and has custom attributes associated. diff --git a/swagger/index.yml b/swagger/index.yml index 2d1ef704a..908c27509 100644 --- a/swagger/index.yml +++ b/swagger/index.yml @@ -53,6 +53,7 @@ x-tagGroups: - name: Application tags: - Account AgentBots + - Agent - Contact - Conversation - Conversation Assignment diff --git a/swagger/paths/agent_bots/create.yml b/swagger/paths/application/agent_bots/create.yml similarity index 100% rename from swagger/paths/agent_bots/create.yml rename to swagger/paths/application/agent_bots/create.yml diff --git a/swagger/paths/agent_bots/delete.yml b/swagger/paths/application/agent_bots/delete.yml similarity index 100% rename from swagger/paths/agent_bots/delete.yml rename to swagger/paths/application/agent_bots/delete.yml diff --git a/swagger/paths/agent_bots/index.yml b/swagger/paths/application/agent_bots/index.yml similarity index 100% rename from swagger/paths/agent_bots/index.yml rename to swagger/paths/application/agent_bots/index.yml diff --git a/swagger/paths/agent_bots/show.yml b/swagger/paths/application/agent_bots/show.yml similarity index 100% rename from swagger/paths/agent_bots/show.yml rename to swagger/paths/application/agent_bots/show.yml diff --git a/swagger/paths/agent_bots/update.yml b/swagger/paths/application/agent_bots/update.yml similarity index 100% rename from swagger/paths/agent_bots/update.yml rename to swagger/paths/application/agent_bots/update.yml diff --git a/swagger/paths/application/agents/create.yml b/swagger/paths/application/agents/create.yml new file mode 100644 index 000000000..de7381830 --- /dev/null +++ b/swagger/paths/application/agents/create.yml @@ -0,0 +1,42 @@ +tags: + - Agent +operationId: add-new-agent-to-account +summary: Add a New Agent +description: Add a new Agent to Account +security: + - userApiKey: [] +parameters: + - name: data + in: body + required: true + schema: + type: object + properties: + name: + type: string + description: Full Name of the agent + required: true + email: + type: string + description: Email of the Agent + required: true + role: + type: string + enum: ['agent', 'administrator'] + description: Whether its administrator or agent + required: true + availability_status: + type: string + enum: ['available', 'busy', 'offline'] + description: The availability status of the agent. + auto_offline: + type: boolean + description: Whether the availability status of agent is configured to go offline automatically when away. +responses: + 200: + description: Success + schema: + description: 'Newly Created Agent' + $ref: '#/definitions/agent' + 403: + description: Access denied diff --git a/swagger/paths/application/agents/delete.yml b/swagger/paths/application/agents/delete.yml new file mode 100644 index 000000000..533d3f1ca --- /dev/null +++ b/swagger/paths/application/agents/delete.yml @@ -0,0 +1,21 @@ +tags: + - Agent +operationId: delete-agent-from-account +summary: Remove an Agent from Account +description: Remove an Agent from Account +security: + - userApiKey: [] +parameters: + - in: path + name: id + schema: + type: integer + required: true + description: The ID of the agent to be deleted +responses: + 200: + description: Success + 404: + description: Agent not found + 403: + description: Access denied diff --git a/swagger/paths/application/agents/index.yml b/swagger/paths/application/agents/index.yml new file mode 100644 index 000000000..026893a4b --- /dev/null +++ b/swagger/paths/application/agents/index.yml @@ -0,0 +1,17 @@ +tags: + - Agent +operationId: get-account-agents +summary: List Agents in Account +description: Get Details of Agents in an Account +security: + - userApiKey: [] +responses: + 200: + description: Success + schema: + type: array + description: 'Array of all active agents' + items: + $ref: '#/definitions/agent' + 403: + description: Access denied diff --git a/swagger/paths/application/agents/update.yml b/swagger/paths/application/agents/update.yml new file mode 100644 index 000000000..8e4745348 --- /dev/null +++ b/swagger/paths/application/agents/update.yml @@ -0,0 +1,42 @@ +tags: + - Agent +operationId: update-agent-in-account +summary: Update Agent in Account +description: Update an Agent in Account +security: + - userApiKey: [] +parameters: + - in: path + name: id + schema: + type: integer + required: true + description: The ID of the agent to be updated. + - name: data + in: body + required: true + schema: + type: object + properties: + role: + type: string + enum: ['agent', 'administrator'] + description: Whether its administrator or agent + required: true + availability_status: + type: string + enum: ['available', 'busy', 'offline'] + description: The availability status of the agent. + auto_offline: + type: boolean + description: Whether the availability status of agent is configured to go offline automatically when away. +responses: + 200: + description: Success + schema: + description: 'The updated agent' + $ref: '#/definitions/agent' + 404: + description: Agent not found + 403: + description: Access denied \ No newline at end of file diff --git a/swagger/paths/contact_inboxes/create.yml b/swagger/paths/application/contact_inboxes/create.yml similarity index 100% rename from swagger/paths/contact_inboxes/create.yml rename to swagger/paths/application/contact_inboxes/create.yml diff --git a/swagger/paths/contactable_inboxes/get.yml b/swagger/paths/application/contactable_inboxes/get.yml similarity index 100% rename from swagger/paths/contactable_inboxes/get.yml rename to swagger/paths/application/contactable_inboxes/get.yml diff --git a/swagger/paths/contact/conversations.yml b/swagger/paths/application/contacts/conversations.yml similarity index 100% rename from swagger/paths/contact/conversations.yml rename to swagger/paths/application/contacts/conversations.yml diff --git a/swagger/paths/contact/crud.yml b/swagger/paths/application/contacts/crud.yml similarity index 100% rename from swagger/paths/contact/crud.yml rename to swagger/paths/application/contacts/crud.yml diff --git a/swagger/paths/contact/list_create.yml b/swagger/paths/application/contacts/list_create.yml similarity index 100% rename from swagger/paths/contact/list_create.yml rename to swagger/paths/application/contacts/list_create.yml diff --git a/swagger/paths/contact/search.yml b/swagger/paths/application/contacts/search.yml similarity index 100% rename from swagger/paths/contact/search.yml rename to swagger/paths/application/contacts/search.yml diff --git a/swagger/paths/conversation/assignments.yml b/swagger/paths/application/conversation/assignments.yml similarity index 100% rename from swagger/paths/conversation/assignments.yml rename to swagger/paths/application/conversation/assignments.yml diff --git a/swagger/paths/conversation/create.yml b/swagger/paths/application/conversation/create.yml similarity index 100% rename from swagger/paths/conversation/create.yml rename to swagger/paths/application/conversation/create.yml diff --git a/swagger/paths/conversation/index.yml b/swagger/paths/application/conversation/index.yml similarity index 100% rename from swagger/paths/conversation/index.yml rename to swagger/paths/application/conversation/index.yml diff --git a/swagger/paths/conversation/labels/create.yml b/swagger/paths/application/conversation/labels/create.yml similarity index 100% rename from swagger/paths/conversation/labels/create.yml rename to swagger/paths/application/conversation/labels/create.yml diff --git a/swagger/paths/conversation/labels/index.yml b/swagger/paths/application/conversation/labels/index.yml similarity index 100% rename from swagger/paths/conversation/labels/index.yml rename to swagger/paths/application/conversation/labels/index.yml diff --git a/swagger/paths/conversation/messages/create.yml b/swagger/paths/application/conversation/messages/create.yml similarity index 100% rename from swagger/paths/conversation/messages/create.yml rename to swagger/paths/application/conversation/messages/create.yml diff --git a/swagger/paths/conversation/messages/create_attachment.yml b/swagger/paths/application/conversation/messages/create_attachment.yml similarity index 100% rename from swagger/paths/conversation/messages/create_attachment.yml rename to swagger/paths/application/conversation/messages/create_attachment.yml diff --git a/swagger/paths/conversation/messages/delete.yml b/swagger/paths/application/conversation/messages/delete.yml similarity index 100% rename from swagger/paths/conversation/messages/delete.yml rename to swagger/paths/application/conversation/messages/delete.yml diff --git a/swagger/paths/conversation/messages/index.yml b/swagger/paths/application/conversation/messages/index.yml similarity index 100% rename from swagger/paths/conversation/messages/index.yml rename to swagger/paths/application/conversation/messages/index.yml diff --git a/swagger/paths/conversation/show.yml b/swagger/paths/application/conversation/show.yml similarity index 100% rename from swagger/paths/conversation/show.yml rename to swagger/paths/application/conversation/show.yml diff --git a/swagger/paths/conversation/toggle_status.yml b/swagger/paths/application/conversation/toggle_status.yml similarity index 100% rename from swagger/paths/conversation/toggle_status.yml rename to swagger/paths/application/conversation/toggle_status.yml diff --git a/swagger/paths/conversation/update_last_seen.yml b/swagger/paths/application/conversation/update_last_seen.yml similarity index 100% rename from swagger/paths/conversation/update_last_seen.yml rename to swagger/paths/application/conversation/update_last_seen.yml diff --git a/swagger/paths/custom_filters/create.yml b/swagger/paths/application/custom_filters/create.yml similarity index 100% rename from swagger/paths/custom_filters/create.yml rename to swagger/paths/application/custom_filters/create.yml diff --git a/swagger/paths/custom_filters/delete.yml b/swagger/paths/application/custom_filters/delete.yml similarity index 100% rename from swagger/paths/custom_filters/delete.yml rename to swagger/paths/application/custom_filters/delete.yml diff --git a/swagger/paths/custom_filters/index.yml b/swagger/paths/application/custom_filters/index.yml similarity index 100% rename from swagger/paths/custom_filters/index.yml rename to swagger/paths/application/custom_filters/index.yml diff --git a/swagger/paths/custom_filters/show.yml b/swagger/paths/application/custom_filters/show.yml similarity index 100% rename from swagger/paths/custom_filters/show.yml rename to swagger/paths/application/custom_filters/show.yml diff --git a/swagger/paths/custom_filters/update.yml b/swagger/paths/application/custom_filters/update.yml similarity index 100% rename from swagger/paths/custom_filters/update.yml rename to swagger/paths/application/custom_filters/update.yml diff --git a/swagger/paths/inboxes/create.yml b/swagger/paths/application/inboxes/create.yml similarity index 100% rename from swagger/paths/inboxes/create.yml rename to swagger/paths/application/inboxes/create.yml diff --git a/swagger/paths/inboxes/get_agent_bot.yml b/swagger/paths/application/inboxes/get_agent_bot.yml similarity index 100% rename from swagger/paths/inboxes/get_agent_bot.yml rename to swagger/paths/application/inboxes/get_agent_bot.yml diff --git a/swagger/paths/inboxes/inbox_members/create.yml b/swagger/paths/application/inboxes/inbox_members/create.yml similarity index 100% rename from swagger/paths/inboxes/inbox_members/create.yml rename to swagger/paths/application/inboxes/inbox_members/create.yml diff --git a/swagger/paths/inboxes/inbox_members/delete.yml b/swagger/paths/application/inboxes/inbox_members/delete.yml similarity index 100% rename from swagger/paths/inboxes/inbox_members/delete.yml rename to swagger/paths/application/inboxes/inbox_members/delete.yml diff --git a/swagger/paths/inboxes/inbox_members/show.yml b/swagger/paths/application/inboxes/inbox_members/show.yml similarity index 100% rename from swagger/paths/inboxes/inbox_members/show.yml rename to swagger/paths/application/inboxes/inbox_members/show.yml diff --git a/swagger/paths/inboxes/inbox_members/update.yml b/swagger/paths/application/inboxes/inbox_members/update.yml similarity index 100% rename from swagger/paths/inboxes/inbox_members/update.yml rename to swagger/paths/application/inboxes/inbox_members/update.yml diff --git a/swagger/paths/inboxes/index.yml b/swagger/paths/application/inboxes/index.yml similarity index 100% rename from swagger/paths/inboxes/index.yml rename to swagger/paths/application/inboxes/index.yml diff --git a/swagger/paths/inboxes/set_agent_bot.yml b/swagger/paths/application/inboxes/set_agent_bot.yml similarity index 100% rename from swagger/paths/inboxes/set_agent_bot.yml rename to swagger/paths/application/inboxes/set_agent_bot.yml diff --git a/swagger/paths/inboxes/show.yml b/swagger/paths/application/inboxes/show.yml similarity index 100% rename from swagger/paths/inboxes/show.yml rename to swagger/paths/application/inboxes/show.yml diff --git a/swagger/paths/inboxes/update.yml b/swagger/paths/application/inboxes/update.yml similarity index 100% rename from swagger/paths/inboxes/update.yml rename to swagger/paths/application/inboxes/update.yml diff --git a/swagger/paths/integrations/apps/show.yml b/swagger/paths/application/integrations/apps/show.yml similarity index 100% rename from swagger/paths/integrations/apps/show.yml rename to swagger/paths/application/integrations/apps/show.yml diff --git a/swagger/paths/integrations/hooks/create.yml b/swagger/paths/application/integrations/hooks/create.yml similarity index 100% rename from swagger/paths/integrations/hooks/create.yml rename to swagger/paths/application/integrations/hooks/create.yml diff --git a/swagger/paths/integrations/hooks/delete.yml b/swagger/paths/application/integrations/hooks/delete.yml similarity index 100% rename from swagger/paths/integrations/hooks/delete.yml rename to swagger/paths/application/integrations/hooks/delete.yml diff --git a/swagger/paths/integrations/hooks/update.yml b/swagger/paths/application/integrations/hooks/update.yml similarity index 100% rename from swagger/paths/integrations/hooks/update.yml rename to swagger/paths/application/integrations/hooks/update.yml diff --git a/swagger/paths/reports/index.yml b/swagger/paths/application/reports/index.yml similarity index 100% rename from swagger/paths/reports/index.yml rename to swagger/paths/application/reports/index.yml diff --git a/swagger/paths/reports/summary.yml b/swagger/paths/application/reports/summary.yml similarity index 100% rename from swagger/paths/reports/summary.yml rename to swagger/paths/application/reports/summary.yml diff --git a/swagger/paths/teams/create.yml b/swagger/paths/application/teams/create.yml similarity index 100% rename from swagger/paths/teams/create.yml rename to swagger/paths/application/teams/create.yml diff --git a/swagger/paths/teams/delete.yml b/swagger/paths/application/teams/delete.yml similarity index 100% rename from swagger/paths/teams/delete.yml rename to swagger/paths/application/teams/delete.yml diff --git a/swagger/paths/teams/index.yml b/swagger/paths/application/teams/index.yml similarity index 100% rename from swagger/paths/teams/index.yml rename to swagger/paths/application/teams/index.yml diff --git a/swagger/paths/teams/show.yml b/swagger/paths/application/teams/show.yml similarity index 100% rename from swagger/paths/teams/show.yml rename to swagger/paths/application/teams/show.yml diff --git a/swagger/paths/teams/update.yml b/swagger/paths/application/teams/update.yml similarity index 100% rename from swagger/paths/teams/update.yml rename to swagger/paths/application/teams/update.yml diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 4324b2504..4936bc1b7 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -116,63 +116,76 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat # ---------------- end of public api routes-----------# # ------------ Application API routes ------------# -# AgentBots + +# AgentBots /api/v1/accounts/{account_id}/agent_bots: parameters: - $ref: '#/parameters/account_id' get: - $ref: ./agent_bots/index.yml + $ref: ./application/agent_bots/index.yml post: - $ref: ./agent_bots/create.yml + $ref: ./application/agent_bots/create.yml /api/v1/accounts/{account_id}/agent_bots/{id}: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/agent_bot_id' get: - $ref: './agent_bots/show.yml' + $ref: './application/agent_bots/show.yml' patch: - $ref: ./agent_bots/update.yml + $ref: ./application/agent_bots/update.yml delete: - $ref: ./agent_bots/delete.yml + $ref: ./application/agent_bots/delete.yml + +# Agents +/api/v1/accounts/{account_id}/agents: + get: + $ref: ./application/agents/index.yml + post: + $ref: ./application/agents/create.yml +/api/v1/accounts/{account_id}/agents/{id}: + patch: + $ref: ./application/agents/update.yml + delete: + $ref: ./application/agents/delete.yml # Contacts /api/v1/accounts/{account_id}/contacts: - $ref: ./contact/list_create.yml + $ref: ./application/contacts/list_create.yml /api/v1/accounts/{account_id}/contacts/{id}: - $ref: ./contact/crud.yml + $ref: ./application/contacts/crud.yml /api/v1/accounts/{account_id}/contacts/{id}/conversations: - $ref: ./contact/conversations.yml + $ref: ./application/contacts/conversations.yml /api/v1/accounts/{account_id}/contacts/search: - $ref: ./contact/search.yml + $ref: ./application/contacts/search.yml /api/v1/accounts/{account_id}/contacts/{id}/contact_inboxes: - $ref: ./contact_inboxes/create.yml + $ref: ./application/contact_inboxes/create.yml /api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes: - $ref: ./contactable_inboxes/get.yml + $ref: ./application/contactable_inboxes/get.yml # Conversations /api/v1/accounts/{account_id}/conversations: parameters: - $ref: '#/parameters/account_id' - $ref: ./conversation/index.yml + $ref: ./application/conversation/index.yml /api/v1/accounts/{account_id}/conversations/: parameters: - $ref: '#/parameters/account_id' - $ref: ./conversation/create.yml + $ref: ./application/conversation/create.yml /api/v1/accounts/{account_id}/conversations/{converstion_id}: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' get: - $ref: ./conversation/show.yml + $ref: ./application/conversation/show.yml /api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' post: - $ref: ./conversation/toggle_status.yml + $ref: ./application/conversation/toggle_status.yml # Conversations Assignments @@ -181,7 +194,7 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' post: - $ref: ./conversation/assignments.yml + $ref: ./application/conversation/assignments.yml # Conversation Labels @@ -190,56 +203,56 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' get: - $ref: ./conversation/labels/index.yml + $ref: ./application/conversation/labels/index.yml post: - $ref: ./conversation/labels/create.yml + $ref: ./application/conversation/labels/create.yml # Inboxes /api/v1/accounts/{account_id}/inboxes: - $ref: ./inboxes/index.yml + $ref: ./application/inboxes/index.yml /api/v1/accounts/{account_id}/inboxes/{id}/: - $ref: ./inboxes/show.yml + $ref: ./application/inboxes/show.yml /api/v1/accounts/{account_id}/inboxes/: - $ref: ./inboxes/create.yml + $ref: ./application/inboxes/create.yml /api/v1/accounts/{account_id}/inboxes/{id}: - $ref: ./inboxes/update.yml + $ref: ./application/inboxes/update.yml /api/v1/accounts/{account_id}/inboxes/{id}/agent_bot: - $ref: ./inboxes/get_agent_bot.yml + $ref: ./application/inboxes/get_agent_bot.yml /api/v1/accounts/{account_id}/inboxes/{id}/set_agent_bot: - $ref: ./inboxes/set_agent_bot.yml + $ref: ./application/inboxes/set_agent_bot.yml # Inbox Members /api/v1/accounts/{account_id}/inbox_members: get: - $ref: ./inboxes/inbox_members/show.yml + $ref: ./application/inboxes/inbox_members/show.yml post: - $ref: ./inboxes/inbox_members/create.yml + $ref: ./application/inboxes/inbox_members/create.yml patch: - $ref: ./inboxes/inbox_members/update.yml + $ref: ./application/inboxes/inbox_members/update.yml delete: - $ref: ./inboxes/inbox_members/delete.yml + $ref: ./application/inboxes/inbox_members/delete.yml # Messages /api/v1/accounts/{account_id}/conversations/{id}/messages: - $ref: ./conversation/messages/create_attachment.yml + $ref: ./application/conversation/messages/create_attachment.yml /api/v1/accounts/{account_id}/conversations/{converstion_id}/messages: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' get: - $ref: ./conversation/messages/index.yml + $ref: ./application/conversation/messages/index.yml post: - $ref: ./conversation/messages/create.yml + $ref: ./application/conversation/messages/create.yml /api/v1/accounts/{account_id}/conversations/{conversation_id}/messages/{message_id}: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' - $ref: '#/parameters/message_id' delete: - $ref: ./conversation/messages/delete.yml + $ref: ./application/conversation/messages/delete.yml @@ -248,14 +261,14 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat parameters: - $ref: '#/parameters/account_id' get: - $ref: './integrations/apps/show.yml' + $ref: './application/integrations/apps/show.yml' /api/v1/accounts/{account_id}/integrations/hooks: post: - $ref: './integrations/hooks/create.yml' + $ref: './application/integrations/hooks/create.yml' patch: - $ref: ./integrations/hooks/update.yml + $ref: ./application/integrations/hooks/update.yml delete: - $ref: ./integrations/hooks/delete.yml + $ref: ./application/integrations/hooks/delete.yml @@ -269,19 +282,19 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat parameters: - $ref: '#/parameters/account_id' get: - $ref: ./teams/index.yml + $ref: ./application/teams/index.yml post: - $ref: ./teams/create.yml + $ref: ./application/teams/create.yml /api/v1/accounts/{account_id}/teams/{id}: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/team_id' get: - $ref: './teams/show.yml' + $ref: './application/teams/show.yml' patch: - $ref: ./teams/update.yml + $ref: ./application/teams/update.yml delete: - $ref: ./teams/delete.yml + $ref: ./application/teams/delete.yml ### Custom Filters goes here @@ -297,19 +310,19 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat required: false description: The type of custom filter get: - $ref: ./custom_filters/index.yml + $ref: ./application/custom_filters/index.yml post: - $ref: ./custom_filters/create.yml + $ref: ./application/custom_filters/create.yml /api/v1/accounts/{account_id}/custom_filters/{custom_filter_id}: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/custom_filter_id' get: - $ref: './custom_filters/show.yml' + $ref: './application/custom_filters/show.yml' patch: - $ref: ./custom_filters/update.yml + $ref: ./application/custom_filters/update.yml delete: - $ref: ./custom_filters/delete.yml + $ref: ./application/custom_filters/delete.yml ### Reports @@ -335,7 +348,7 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat type: string description: The timestamp from where report should stop. get: - $ref: './reports/index.yml' + $ref: './application/reports/index.yml' # Summary /api/v2/accounts/{id}/reports/summary: @@ -358,4 +371,4 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat type: string description: The timestamp from where report should stop. get: - $ref: './reports/summary.yml' + $ref: './application/reports/summary.yml' diff --git a/swagger/swagger.json b/swagger/swagger.json index 450c18f4d..0d8570d99 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1103,6 +1103,219 @@ } } }, + "/api/v1/accounts/{account_id}/agents": { + "get": { + "tags": [ + "Agent" + ], + "operationId": "get-account-agents", + "summary": "List Agents in Account", + "description": "Get Details of Agents in an Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "array", + "description": "Array of all active agents", + "items": { + "$ref": "#/definitions/agent" + } + } + }, + "403": { + "description": "Access denied" + } + } + }, + "post": { + "tags": [ + "Agent" + ], + "operationId": "add-new-agent-to-account", + "summary": "Add a New Agent", + "description": "Add a new Agent to Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "parameters": [ + { + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Full Name of the agent", + "required": true + }, + "email": { + "type": "string", + "description": "Email of the Agent", + "required": true + }, + "role": { + "type": "string", + "enum": [ + "agent", + "administrator" + ], + "description": "Whether its administrator or agent", + "required": true + }, + "availability_status": { + "type": "string", + "enum": [ + "available", + "busy", + "offline" + ], + "description": "The availability status of the agent." + }, + "auto_offline": { + "type": "boolean", + "description": "Whether the availability status of agent is configured to go offline automatically when away." + } + } + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/agent" + } + }, + "403": { + "description": "Access denied" + } + } + } + }, + "/api/v1/accounts/{account_id}/agents/{id}": { + "patch": { + "tags": [ + "Agent" + ], + "operationId": "update-agent-in-account", + "summary": "Update Agent in Account", + "description": "Update an Agent in Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true, + "description": "The ID of the agent to be updated." + }, + { + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "agent", + "administrator" + ], + "description": "Whether its administrator or agent", + "required": true + }, + "availability_status": { + "type": "string", + "enum": [ + "available", + "busy", + "offline" + ], + "description": "The availability status of the agent." + }, + "auto_offline": { + "type": "boolean", + "description": "Whether the availability status of agent is configured to go offline automatically when away." + } + } + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/agent" + } + }, + "404": { + "description": "Agent not found" + }, + "403": { + "description": "Access denied" + } + } + }, + "delete": { + "tags": [ + "Agent" + ], + "operationId": "delete-agent-from-account", + "summary": "Remove an Agent from Account", + "description": "Remove an Agent from Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true, + "description": "The ID of the agent to be deleted" + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Agent not found" + }, + "403": { + "description": "Access denied" + } + } + } + }, "/api/v1/accounts/{account_id}/contacts": { "get": { "tags": [ @@ -3432,7 +3645,7 @@ "type": "object", "properties": { "id": { - "type": "number" + "type": "integer" }, "uid": { "type": "string" @@ -3450,7 +3663,7 @@ "type": "string" }, "account_id": { - "type": "number" + "type": "integer" }, "role": { "type": "string", @@ -3462,6 +3675,19 @@ "confirmed": { "type": "boolean" }, + "availability_status": { + "type": "string", + "enum": [ + "available", + "busy", + "offline" + ], + "description": "The availability status of the agent computed by Chatwoot." + }, + "auto_offline": { + "type": "boolean", + "description": "Whether the availability status of agent is configured to go offline automatically when away." + }, "custom_attributes": { "type": "object", "description": "Available for users who are created through platform APIs and has custom attributes associated." @@ -4556,6 +4782,7 @@ "name": "Application", "tags": [ "Account AgentBots", + "Agent", "Contact", "Conversation", "Conversation Assignment", From 700721ea6d1b4d8c44d2fa8c8f1fcbd898b9b3f3 Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Thu, 7 Oct 2021 14:36:54 +0530 Subject: [PATCH 45/76] fix: Issue when Instagram response body is empty Fixes #3138 --- app/services/instagram/send_on_instagram_service.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/services/instagram/send_on_instagram_service.rb b/app/services/instagram/send_on_instagram_service.rb index c47b18188..dde8cf3d0 100644 --- a/app/services/instagram/send_on_instagram_service.rb +++ b/app/services/instagram/send_on_instagram_service.rb @@ -62,9 +62,8 @@ class Instagram::SendOnInstagramService < Base::SendOnChannelService body: message_content, query: query ) - # response = HTTParty.post(url, options) - Rails.logger.info("Instagram response: #{response} : #{message_content}") if response[:body][:error] + Rails.logger.info("Instagram response: #{response} : #{message_content}") if response[:body] response[:body] end From 8c192559fee9c899cfd16933b10a33943fa54ccc Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 7 Oct 2021 18:06:43 +0530 Subject: [PATCH 46/76] chore: Rate limits on widget conversation endpoints (#3162) - Limit widget conversation creation to 6 per 12 hours - Enable rack attack by default --- .env.example | 2 +- config/initializers/rack_attack.rb | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index dd4052f69..ade61ba74 100644 --- a/.env.example +++ b/.env.example @@ -169,7 +169,7 @@ USE_INBOX_AVATAR_FOR_BOT=true ## Rack Attack configuration ## To prevent and throttle abusive requests -# ENABLE_RACK_ATTACK=false +# ENABLE_RACK_ATTACK=true ## Running chatwoot as an API only server diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 1e1aa9cf8..acbf7e138 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -52,6 +52,16 @@ class Rack::Attack req.ip if req.path == '/api/v1/accounts' && req.post? end + ## Prevent Conversation Bombing on Widget APIs ### + throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req| + req.ip if req.path == '/api/v1/widget/conversations' && req.post? + end + + ## Prevent Contact update Bombing in Widget API ### + throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req| + req.ip if req.path == '/api/v1/widget/contacts' && (req.patch? || req.put?) + end + # ref: https://github.com/rack/rack-attack/issues/399 throttle('login/email', limit: 20, period: 5.minutes) do |req| if req.path == '/auth/sign_in' && req.post? @@ -75,4 +85,4 @@ ActiveSupport::Notifications.subscribe('throttle.rack_attack') do |_name, _start Rails.logger.info "[Rack::Attack][Blocked] remote_ip: \"#{payload[:request].remote_ip}\", path: \"#{payload[:request].path}\"" end -Rack::Attack.enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', false)) +Rack::Attack.enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) From 5d3cce12d5b07adc44275dae72b337b594d476bc Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Fri, 8 Oct 2021 12:14:23 +0530 Subject: [PATCH 47/76] fix: Disable rack attack gem in circleCI (#3167) --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index c5a6430a3..fbdc6cc14 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,6 +17,7 @@ defaults: &defaults environment: - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f - RAILS_LOG_TO_STDOUT: false + - ENABLE_RACK_ATTACK: false jobs: build: <<: *defaults From b9e85a628b0561a2cc12ebcb307d634cf6efeba7 Mon Sep 17 00:00:00 2001 From: Fayaz Ahmed <15716057+fayazara@users.noreply.github.com> Date: Fri, 8 Oct 2021 12:53:24 +0530 Subject: [PATCH 48/76] Feat : Toggle to enforce user validation in Chatwoots web SDK (#3137) * If enabled, enforces user validation with identifier_hash * Fixes the hmac flag payload * Adds missing i18n label for checkbox * If enabled, Adds EOF on json file * If applied, Handles HMAC Disable option Co-authored-by: Tejaswini Chile Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../dashboard/i18n/locale/en/inboxMgmt.json | 5 ++++ .../dashboard/settings/inbox/Settings.vue | 24 +++++++++++++++++++ app/models/channel/web_widget.rb | 2 +- app/views/api/v1/models/_inbox.json.jbuilder | 1 + 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 1d935dd64..b05a730e6 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -302,6 +302,9 @@ "ENABLE_CSAT": { "ENABLED": "Enabled", "DISABLED": "Disabled" + }, + "ENABLE_HMAC": { + "LABEL": "Enable" } }, "DELETE": { @@ -351,6 +354,8 @@ "AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.", "HMAC_VERIFICATION": "User Identity Validation", "HMAC_DESCRIPTION": "Inorder to validate the user's identity, the SDK allows you to pass an `identifier_hash` for each user. You can generate HMAC using 'sha256' with the key shown here.", + "HMAC_MANDATORY_VERIFICATION": "Enforce User Identity Validation", + "HMAC_MANDATORY_DESCRIPTION": "If enabled, Chatwoot SDKs setUser method will not work unless the `identifier_hash` is provided for each user.", "INBOX_IDENTIFIER": "Inbox Identifier", "INBOX_IDENTIFIER_SUB_TEXT": "Use the `inbox_identifier` token shown here to authentication your API clients.", "FORWARD_EMAIL_TITLE": "Forward to Email", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 63e74e2fc..b33ce4ed3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -316,6 +316,24 @@ > + +
+ + +
+
@@ -377,6 +395,7 @@ export default { avatarUrl: '', selectedAgents: [], greetingEnabled: true, + hmacMandatory: null, greetingMessage: '', autoAssignment: false, emailCollectEnabled: false, @@ -511,6 +530,9 @@ export default { e.target.value ); }, + handleHmacFlag() { + this.updateInbox(); + }, toggleInput(selected, current) { if (selected.includes(current)) { const newSelectedFlags = selected.filter(flag => flag !== current); @@ -533,6 +555,7 @@ export default { this.selectedInboxName = this.inbox.name; this.webhookUrl = this.inbox.webhook_url; this.greetingEnabled = this.inbox.greeting_enabled || false; + this.hmacMandatory = this.inbox.hmac_mandatory || false; this.greetingMessage = this.inbox.greeting_message || ''; this.autoAssignment = this.inbox.enable_auto_assignment; this.emailCollectEnabled = this.inbox.enable_email_collect; @@ -589,6 +612,7 @@ export default { welcome_tagline: this.channelWelcomeTagline || '', selectedFeatureFlags: this.selectedFeatureFlags, reply_time: this.replyTime || 'in_a_few_minutes', + hmac_mandatory: this.hmacMandatory, }, }; if (this.avatarFile) { diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb index 38f6e2eb5..debee3bd5 100644 --- a/app/models/channel/web_widget.rb +++ b/app/models/channel/web_widget.rb @@ -29,7 +29,7 @@ class Channel::WebWidget < ApplicationRecord include FlagShihTzu self.table_name = 'channel_web_widgets' - EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled, + EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled, :hmac_mandatory, { pre_chat_form_options: [:pre_chat_message, :require_email] }, { selected_feature_flags: [] }].freeze diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 5c25cedd0..e27ebcac4 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -20,6 +20,7 @@ json.callback_webhook_url resource.callback_webhook_url ## WebWidget Attributes json.widget_color resource.channel.try(:widget_color) json.website_url resource.channel.try(:website_url) +json.hmac_mandatory resource.channel.try(:hmac_mandatory) json.welcome_title resource.channel.try(:welcome_title) json.welcome_tagline resource.channel.try(:welcome_tagline) json.web_widget_script resource.channel.try(:web_widget_script) From ec2dc1b61b628ba3e77c0f0a328a2f42c7dad4f5 Mon Sep 17 00:00:00 2001 From: Sanju Date: Fri, 8 Oct 2021 13:55:21 +0530 Subject: [PATCH 49/76] fix: Update the styles for ol & li - lists in dashboard (#3110) --- .../components/widgets/conversation/bubble/Text.vue | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue index be1cc555e..281ca630c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue @@ -60,11 +60,9 @@ export default { .text-content { overflow: auto; - &::v-deep { - ul, - ol { - margin-left: var(--space-normal); - } + ul, + ol { + padding-left: var(--space-two); } table { all: revert; From 1c4afb10dfaceed44b4e8f06184182f4ecca2048 Mon Sep 17 00:00:00 2001 From: Sanju Date: Fri, 8 Oct 2021 14:08:13 +0530 Subject: [PATCH 50/76] fix: Open live-chat widget clicking on any unread message (#3153) Co-authored-by: Pranav Raj S --- app/javascript/widget/components/UnreadMessage.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/javascript/widget/components/UnreadMessage.vue b/app/javascript/widget/components/UnreadMessage.vue index e8ee1cca4..48a6b4686 100644 --- a/app/javascript/widget/components/UnreadMessage.vue +++ b/app/javascript/widget/components/UnreadMessage.vue @@ -83,6 +83,8 @@ export default { onClickMessage() { if (this.campaignId) { bus.$emit('on-campaign-view-clicked', this.campaignId); + } else { + bus.$emit('on-unread-view-clicked'); } }, }, From 0e0632be228e0d91c23dd8d9addea71fc6470805 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 8 Oct 2021 15:45:45 +0530 Subject: [PATCH 51/76] chore: Minor Housekeeping tasks (#3169) - Limit Rack attack to production environments - Make the long-running data migration optional --- .circleci/config.yml | 1 - config/initializers/rack_attack.rb | 2 +- .../20210923190418_add_online_status_to_account_users.rb | 5 +---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index fbdc6cc14..c5a6430a3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,7 +17,6 @@ defaults: &defaults environment: - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f - RAILS_LOG_TO_STDOUT: false - - ENABLE_RACK_ATTACK: false jobs: build: <<: *defaults diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index acbf7e138..39556b6f2 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -85,4 +85,4 @@ ActiveSupport::Notifications.subscribe('throttle.rack_attack') do |_name, _start Rails.logger.info "[Rack::Attack][Blocked] remote_ip: \"#{payload[:request].remote_ip}\", path: \"#{payload[:request].path}\"" end -Rack::Attack.enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) +Rack::Attack.enabled = Rails.env.production? ? ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) : false diff --git a/db/migrate/20210923190418_add_online_status_to_account_users.rb b/db/migrate/20210923190418_add_online_status_to_account_users.rb index eccc9be1c..00b2d4999 100644 --- a/db/migrate/20210923190418_add_online_status_to_account_users.rb +++ b/db/migrate/20210923190418_add_online_status_to_account_users.rb @@ -4,12 +4,9 @@ class AddOnlineStatusToAccountUsers < ActiveRecord::Migration[6.1] t.integer :availability, default: 0, null: false t.boolean :auto_offline, default: true, null: false end - - update_existing_user_availability end - private - + # run as a seperate data migration if you want to migrate the user statuses def update_existing_user_availability User.find_in_batches do |user_batch| user_batch.each do |user| From 68e697c379006e41ed088a73c08cd05ee622fc06 Mon Sep 17 00:00:00 2001 From: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com> Date: Mon, 11 Oct 2021 13:00:48 +0530 Subject: [PATCH 52/76] feat: Support cc and bcc in email replies (#3098) Co-authored-by: Tejaswini Co-authored-by: Pranav Raj S --- app/builders/messages/message_builder.rb | 11 +++++ app/javascript/dashboard/api/inbox/message.js | 10 ++++ .../widgets/conversation/Message.vue | 10 +++- .../widgets/conversation/ReplyBox.vue | 25 ++++++++++ .../widgets/conversation/ReplyEmailHead.vue | 49 ++++++++++++------- .../widgets/conversation/bubble/MailHead.vue | 12 ++++- app/mailers/conversation_reply_mailer.rb | 17 ++++++- .../mailers/conversation_reply_mailer_spec.rb | 26 +++++++++- 8 files changed, 135 insertions(+), 25 deletions(-) diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index b6ae5409d..a30394edc 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -16,6 +16,7 @@ class Messages::MessageBuilder def perform @message = @conversation.messages.build(message_params) process_attachments + process_emails @message.save! @message end @@ -34,6 +35,16 @@ class Messages::MessageBuilder end end + def process_emails + return unless @conversation.inbox&.inbox_type == 'Email' + + cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails] + bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails] + + @message.content_attributes[:cc_emails] = cc_emails + @message.content_attributes[:bcc_emails] = bcc_emails + end + def message_type if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' raise StandardError, 'Incoming messages are only allowed in Api inboxes' diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 98c250e60..39bb5eb04 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -8,6 +8,8 @@ export const buildCreatePayload = ({ contentAttributes, echoId, file, + ccEmails, + bccEmails, }) => { let payload; if (file) { @@ -18,12 +20,16 @@ export const buildCreatePayload = ({ } payload.append('private', isPrivate); payload.append('echo_id', echoId); + payload.append('cc_emails', ccEmails); + payload.append('bcc_emails', bccEmails); } else { payload = { content: message, private: isPrivate, echo_id: echoId, content_attributes: contentAttributes, + cc_emails: ccEmails, + bcc_emails: bccEmails, }; } return payload; @@ -41,6 +47,8 @@ class MessageApi extends ApiClient { contentAttributes, echo_id: echoId, file, + ccEmails, + bccEmails, }) { return axios({ method: 'post', @@ -51,6 +59,8 @@ class MessageApi extends ApiClient { contentAttributes, echoId, file, + ccEmails, + bccEmails, }), }); } diff --git a/app/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue index e5de1b040..b3674681a 100644 --- a/app/javascript/dashboard/components/widgets/conversation/Message.vue +++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue @@ -3,8 +3,9 @@
0); }, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index fedd96927..2de33e6af 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -20,6 +20,11 @@ v-on-clickaway="hideEmojiPicker" :on-click="emojiOnClick" /> + diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyEmailHead.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyEmailHead.vue index 4f30f03bd..b8e193783 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyEmailHead.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyEmailHead.vue @@ -1,17 +1,17 @@ + @@ -190,12 +217,6 @@ export default { left: var(--space-normal); } -::v-deep .multiselect__tags .option__title { - display: inline-flex; - align-items: center; - margin-left: var(--space-small); -} - .footer { margin-top: var(--space-medium); display: flex; @@ -206,4 +227,8 @@ export default { .error .message { margin-top: 0; } + +.label--merge-warning { + margin-left: var(--space-small); +} diff --git a/app/javascript/dashboard/modules/contact/components/MergeContactSummary.vue b/app/javascript/dashboard/modules/contact/components/MergeContactSummary.vue index 5d028bd6a..1f238e509 100644 --- a/app/javascript/dashboard/modules/contact/components/MergeContactSummary.vue +++ b/app/javascript/dashboard/modules/contact/components/MergeContactSummary.vue @@ -5,7 +5,7 @@
  • - + -
    - - {{ $t('EDIT_CONTACT.BUTTON_LABEL') }} - -
    -
    - - {{ $t('DELETE_CONTACT.BUTTON_LABEL') }} - -
    -
-
-
- - - -
+
+ + + +
+
diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js index 96a7c811a..801157bb4 100644 --- a/app/javascript/dashboard/store/modules/contacts/actions.js +++ b/app/javascript/dashboard/store/modules/contacts/actions.js @@ -4,6 +4,7 @@ import { } from 'shared/helpers/CustomErrors'; import types from '../../mutation-types'; import ContactAPI from '../../../api/contacts'; +import AccountActionsAPI from '../../../api/accountActions'; export const actions = { search: async ({ commit }, { search, page, sortAttr, label }) => { @@ -137,6 +138,18 @@ export const actions = { commit(types.SET_CONTACT_ITEM, data); }, + merge: async ({ commit }, { childId, parentId }) => { + commit(types.SET_CONTACT_UI_FLAG, { isMerging: true }); + try { + const response = await AccountActionsAPI.merge(parentId, childId); + commit(types.SET_CONTACT_ITEM, response.data); + } catch (error) { + throw new Error(error); + } finally { + commit(types.SET_CONTACT_UI_FLAG, { isMerging: false }); + } + }, + deleteContactThroughConversations: ({ commit }, id) => { commit(types.DELETE_CONTACT, id); commit(types.CLEAR_CONTACT_CONVERSATIONS, id, { root: true }); diff --git a/app/javascript/dashboard/store/modules/contacts/index.js b/app/javascript/dashboard/store/modules/contacts/index.js index f1982e4be..b2657ca78 100644 --- a/app/javascript/dashboard/store/modules/contacts/index.js +++ b/app/javascript/dashboard/store/modules/contacts/index.js @@ -13,6 +13,7 @@ const state = { isFetchingItem: false, isFetchingInboxes: false, isUpdating: false, + isMerging: false, isDeleting: false, }, sortOrder: [], diff --git a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js index 03d49d8d0..dc49f4731 100644 --- a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js @@ -168,6 +168,30 @@ describe('#actions', () => { }); }); + describe('#merge', () => { + it('sends correct mutations if API is success', async () => { + axios.post.mockResolvedValue({ + data: contactList[0], + }); + await actions.merge({ commit }, { childId: 0, parentId: 1 }); + expect(commit.mock.calls).toEqual([ + [types.SET_CONTACT_UI_FLAG, { isMerging: true }], + [types.SET_CONTACT_ITEM, contactList[0]], + [types.SET_CONTACT_UI_FLAG, { isMerging: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.post.mockRejectedValue({ message: 'Incorrect header' }); + await expect( + actions.merge({ commit }, { childId: 0, parentId: 1 }) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.SET_CONTACT_UI_FLAG, { isMerging: true }], + [types.SET_CONTACT_UI_FLAG, { isMerging: false }], + ]); + }); + }); + describe('#deleteContactThroughConversations', () => { it('returns correct mutations', () => { actions.deleteContactThroughConversations({ commit }, contactList[0].id); From 5799b9fa265526ffdbc693adc4b0a9fcbe6b6e18 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 13 Oct 2021 18:58:30 +0530 Subject: [PATCH 66/76] chore: Fix the prop warning issue in contact merge modal (#3211) --- .../modules/contact/components/ContactDropdownItem.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/modules/contact/components/ContactDropdownItem.vue b/app/javascript/dashboard/modules/contact/components/ContactDropdownItem.vue index c4467c75f..ecee39509 100644 --- a/app/javascript/dashboard/modules/contact/components/ContactDropdownItem.vue +++ b/app/javascript/dashboard/modules/contact/components/ContactDropdownItem.vue @@ -47,8 +47,8 @@ export default { default: '', }, identifier: { - type: String, - default: '', + type: [String, Number], + required: true, }, }, }; From 5c30bc3e2be5ebe7500b3e6af4fa5cd980c8b804 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 14 Oct 2021 11:51:00 +0530 Subject: [PATCH 67/76] fix: Read message appears on page refresh in the widget (#3175) --- app/javascript/widget/App.vue | 6 ++++-- app/javascript/widget/helpers/actionCable.js | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index 2f2113b57..ef9d63aac 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -36,6 +36,7 @@ export default { widgetPosition: 'right', showPopoutButton: false, isWebWidgetTriggered: false, + isWidgetOpen: false, }; }, computed: { @@ -134,8 +135,8 @@ export default { this.hideMessageBubble = !!hideBubble; }, registerUnreadEvents() { - bus.$on('on-agent-message-recieved', () => { - if (!this.isIFrame) { + bus.$on('on-agent-message-received', () => { + if (!this.isIFrame || this.isWidgetOpen) { this.setUserLastSeen(); } this.setUnreadView(); @@ -257,6 +258,7 @@ export default { this.showUnreadView = false; this.showCampaignView = false; } else if (message.event === 'toggle-open') { + this.isWidgetOpen = message.isOpen; this.toggleOpen(); } }); diff --git a/app/javascript/widget/helpers/actionCable.js b/app/javascript/widget/helpers/actionCable.js index 32bdd644f..c4cf483c6 100644 --- a/app/javascript/widget/helpers/actionCable.js +++ b/app/javascript/widget/helpers/actionCable.js @@ -34,7 +34,7 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store .dispatch('conversation/addOrUpdateMessage', data) .then(() => { - window.bus.$emit('on-agent-message-recieved'); + window.bus.$emit('on-agent-message-received'); }); }; From 99abbb8158153f02f39550a334bb01821c2fdf62 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Thu, 14 Oct 2021 12:55:46 +0530 Subject: [PATCH 68/76] feat: Display `sent` status of emails in email channel (#3125) --- .codeclimate.yml | 4 ++ .../widgets/conversation/ReplyBox.vue | 14 +++--- .../widgets/conversation/bubble/Actions.vue | 18 +++++++- .../dashboard/i18n/locale/en/chatlist.json | 1 + .../dashboard/settings/inbox/Settings.vue | 3 ++ app/javascript/shared/mixins/inboxMixin.js | 17 +++---- app/mailboxes/application_mailbox.rb | 2 +- app/mailboxes/reply_mailbox.rb | 4 +- app/mailers/conversation_reply_mailer.rb | 42 +++++++++++++----- app/models/contact_inbox.rb | 2 +- app/models/message.rb | 10 ++--- .../email_reply.html.erb | 8 ++++ .../conversation_reply_email_worker.rb | 8 ++-- app/workers/email_reply_worker.rb | 14 ++++++ .../mailers/conversation_reply_mailer_spec.rb | 35 +++++++++++---- spec/models/message_spec.rb | 8 ++++ .../conversation_reply_email_worker_spec.rb | 8 ++-- spec/workers/email_reply_worker_spec.rb | 44 +++++++++++++++++++ 18 files changed, 187 insertions(+), 55 deletions(-) create mode 100644 app/views/mailers/conversation_reply_mailer/email_reply.html.erb create mode 100644 app/workers/email_reply_worker.rb create mode 100644 spec/workers/email_reply_worker_spec.rb diff --git a/.codeclimate.yml b/.codeclimate.yml index ac38c27bb..50d5360fd 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -14,6 +14,10 @@ plugins: checks: similar-code: enabled: false + method-count: + enabled: true + config: + threshold: 25 exclude_patterns: - "spec/" - "**/specs/" diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 2de33e6af..661515adf 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -22,8 +22,8 @@ /> diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue index 59a0a9896..597a5504d 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue @@ -1,6 +1,12 @@