feat: Notification on new messages in conversation (#1204)
fixes: #895 fixes: #1118 fixes: #1075 Co-authored-by: Pranav Raj S <pranav@thoughtwoot.com>
This commit is contained in:
parent
3b92c744d6
commit
31c07771e8
36 changed files with 259 additions and 94 deletions
|
@ -75,6 +75,7 @@ Metrics/AbcSize:
|
||||||
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
|
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
|
||||||
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
|
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
|
||||||
Metrics/CyclomaticComplexity:
|
Metrics/CyclomaticComplexity:
|
||||||
|
Max: 7
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
|
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
|
||||||
Rails/ReversibleMigration:
|
Rails/ReversibleMigration:
|
||||||
|
|
104
Gemfile.lock
104
Gemfile.lock
|
@ -18,56 +18,56 @@ GEM
|
||||||
specs:
|
specs:
|
||||||
action-cable-testing (0.6.1)
|
action-cable-testing (0.6.1)
|
||||||
actioncable (>= 5.0)
|
actioncable (>= 5.0)
|
||||||
actioncable (6.0.3.2)
|
actioncable (6.0.3.3)
|
||||||
actionpack (= 6.0.3.2)
|
actionpack (= 6.0.3.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailbox (6.0.3.2)
|
actionmailbox (6.0.3.3)
|
||||||
actionpack (= 6.0.3.2)
|
actionpack (= 6.0.3.3)
|
||||||
activejob (= 6.0.3.2)
|
activejob (= 6.0.3.3)
|
||||||
activerecord (= 6.0.3.2)
|
activerecord (= 6.0.3.3)
|
||||||
activestorage (= 6.0.3.2)
|
activestorage (= 6.0.3.3)
|
||||||
activesupport (= 6.0.3.2)
|
activesupport (= 6.0.3.3)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
actionmailer (6.0.3.2)
|
actionmailer (6.0.3.3)
|
||||||
actionpack (= 6.0.3.2)
|
actionpack (= 6.0.3.3)
|
||||||
actionview (= 6.0.3.2)
|
actionview (= 6.0.3.3)
|
||||||
activejob (= 6.0.3.2)
|
activejob (= 6.0.3.3)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
actionpack (6.0.3.2)
|
actionpack (6.0.3.3)
|
||||||
actionview (= 6.0.3.2)
|
actionview (= 6.0.3.3)
|
||||||
activesupport (= 6.0.3.2)
|
activesupport (= 6.0.3.3)
|
||||||
rack (~> 2.0, >= 2.0.8)
|
rack (~> 2.0, >= 2.0.8)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||||
actiontext (6.0.3.2)
|
actiontext (6.0.3.3)
|
||||||
actionpack (= 6.0.3.2)
|
actionpack (= 6.0.3.3)
|
||||||
activerecord (= 6.0.3.2)
|
activerecord (= 6.0.3.3)
|
||||||
activestorage (= 6.0.3.2)
|
activestorage (= 6.0.3.3)
|
||||||
activesupport (= 6.0.3.2)
|
activesupport (= 6.0.3.3)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (6.0.3.2)
|
actionview (6.0.3.3)
|
||||||
activesupport (= 6.0.3.2)
|
activesupport (= 6.0.3.3)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.4)
|
erubi (~> 1.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||||
activejob (6.0.3.2)
|
activejob (6.0.3.3)
|
||||||
activesupport (= 6.0.3.2)
|
activesupport (= 6.0.3.3)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (6.0.3.2)
|
activemodel (6.0.3.3)
|
||||||
activesupport (= 6.0.3.2)
|
activesupport (= 6.0.3.3)
|
||||||
activerecord (6.0.3.2)
|
activerecord (6.0.3.3)
|
||||||
activemodel (= 6.0.3.2)
|
activemodel (= 6.0.3.3)
|
||||||
activesupport (= 6.0.3.2)
|
activesupport (= 6.0.3.3)
|
||||||
activestorage (6.0.3.2)
|
activestorage (6.0.3.3)
|
||||||
actionpack (= 6.0.3.2)
|
actionpack (= 6.0.3.3)
|
||||||
activejob (= 6.0.3.2)
|
activejob (= 6.0.3.3)
|
||||||
activerecord (= 6.0.3.2)
|
activerecord (= 6.0.3.3)
|
||||||
marcel (~> 0.3.1)
|
marcel (~> 0.3.1)
|
||||||
activesupport (6.0.3.2)
|
activesupport (6.0.3.3)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
minitest (~> 5.1)
|
minitest (~> 5.1)
|
||||||
|
@ -299,7 +299,7 @@ GEM
|
||||||
mini_magick (4.10.1)
|
mini_magick (4.10.1)
|
||||||
mini_mime (1.0.2)
|
mini_mime (1.0.2)
|
||||||
mini_portile2 (2.4.0)
|
mini_portile2 (2.4.0)
|
||||||
minitest (5.14.1)
|
minitest (5.14.2)
|
||||||
momentjs-rails (2.20.1)
|
momentjs-rails (2.20.1)
|
||||||
railties (>= 3.1)
|
railties (>= 3.1)
|
||||||
msgpack (1.3.3)
|
msgpack (1.3.3)
|
||||||
|
@ -307,7 +307,7 @@ GEM
|
||||||
multi_xml (0.6.0)
|
multi_xml (0.6.0)
|
||||||
multipart-post (2.1.1)
|
multipart-post (2.1.1)
|
||||||
netrc (0.11.0)
|
netrc (0.11.0)
|
||||||
nio4r (2.5.2)
|
nio4r (2.5.3)
|
||||||
nokogiri (1.10.10)
|
nokogiri (1.10.10)
|
||||||
mini_portile2 (~> 2.4.0)
|
mini_portile2 (~> 2.4.0)
|
||||||
oauth (0.5.4)
|
oauth (0.5.4)
|
||||||
|
@ -336,29 +336,29 @@ GEM
|
||||||
rack
|
rack
|
||||||
rack-test (1.1.0)
|
rack-test (1.1.0)
|
||||||
rack (>= 1.0, < 3)
|
rack (>= 1.0, < 3)
|
||||||
rails (6.0.3.2)
|
rails (6.0.3.3)
|
||||||
actioncable (= 6.0.3.2)
|
actioncable (= 6.0.3.3)
|
||||||
actionmailbox (= 6.0.3.2)
|
actionmailbox (= 6.0.3.3)
|
||||||
actionmailer (= 6.0.3.2)
|
actionmailer (= 6.0.3.3)
|
||||||
actionpack (= 6.0.3.2)
|
actionpack (= 6.0.3.3)
|
||||||
actiontext (= 6.0.3.2)
|
actiontext (= 6.0.3.3)
|
||||||
actionview (= 6.0.3.2)
|
actionview (= 6.0.3.3)
|
||||||
activejob (= 6.0.3.2)
|
activejob (= 6.0.3.3)
|
||||||
activemodel (= 6.0.3.2)
|
activemodel (= 6.0.3.3)
|
||||||
activerecord (= 6.0.3.2)
|
activerecord (= 6.0.3.3)
|
||||||
activestorage (= 6.0.3.2)
|
activestorage (= 6.0.3.3)
|
||||||
activesupport (= 6.0.3.2)
|
activesupport (= 6.0.3.3)
|
||||||
bundler (>= 1.3.0)
|
bundler (>= 1.3.0)
|
||||||
railties (= 6.0.3.2)
|
railties (= 6.0.3.3)
|
||||||
sprockets-rails (>= 2.0.0)
|
sprockets-rails (>= 2.0.0)
|
||||||
rails-dom-testing (2.0.3)
|
rails-dom-testing (2.0.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.3.0)
|
rails-html-sanitizer (1.3.0)
|
||||||
loofah (~> 2.3)
|
loofah (~> 2.3)
|
||||||
railties (6.0.3.2)
|
railties (6.0.3.3)
|
||||||
actionpack (= 6.0.3.2)
|
actionpack (= 6.0.3.3)
|
||||||
activesupport (= 6.0.3.2)
|
activesupport (= 6.0.3.3)
|
||||||
method_source
|
method_source
|
||||||
rake (>= 0.8.7)
|
rake (>= 0.8.7)
|
||||||
thor (>= 0.20.3, < 2.0)
|
thor (>= 0.20.3, < 2.0)
|
||||||
|
|
|
@ -8,7 +8,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||||
def update_last_seen
|
def update_last_seen
|
||||||
head :ok && return if conversation.nil?
|
head :ok && return if conversation.nil?
|
||||||
|
|
||||||
conversation.user_last_seen_at = DateTime.now.utc
|
conversation.contact_last_seen_at = DateTime.now.utc
|
||||||
conversation.save!
|
conversation.save!
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,7 +26,8 @@
|
||||||
"TITLE": "Email Notifications",
|
"TITLE": "Email Notifications",
|
||||||
"NOTE": "Update your email notification preferences here",
|
"NOTE": "Update your email notification preferences here",
|
||||||
"CONVERSATION_ASSIGNMENT": "Send email notifications when a conversation is assigned to me",
|
"CONVERSATION_ASSIGNMENT": "Send email notifications when a conversation is assigned to me",
|
||||||
"CONVERSATION_CREATION": "Send email notifications when a new conversation is created"
|
"CONVERSATION_CREATION": "Send email notifications when a new conversation is created",
|
||||||
|
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in an assigned conversation"
|
||||||
},
|
},
|
||||||
"API": {
|
"API": {
|
||||||
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
|
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
|
||||||
|
@ -37,6 +38,7 @@
|
||||||
"NOTE": "Update your push notification preferences here",
|
"NOTE": "Update your push notification preferences here",
|
||||||
"CONVERSATION_ASSIGNMENT": "Send push notifications when a conversation is assigned to me",
|
"CONVERSATION_ASSIGNMENT": "Send push notifications when a conversation is assigned to me",
|
||||||
"CONVERSATION_CREATION": "Send push notifications when a new conversation is created",
|
"CONVERSATION_CREATION": "Send push notifications when a new conversation is created",
|
||||||
|
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in an assigned conversation",
|
||||||
"HAS_ENABLED_PUSH": "You have enabled push for this browser.",
|
"HAS_ENABLED_PUSH": "You have enabled push for this browser.",
|
||||||
"REQUEST_PUSH": "Enable push notifications"
|
"REQUEST_PUSH": "Enable push notifications"
|
||||||
},
|
},
|
||||||
|
|
|
@ -43,6 +43,23 @@
|
||||||
}}
|
}}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
v-model="selectedEmailFlags"
|
||||||
|
class="notification--checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
value="email_assigned_conversation_new_message"
|
||||||
|
@input="handleEmailInput"
|
||||||
|
/>
|
||||||
|
<label for="assigned_conversation_new_message">
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.ASSIGNED_CONVERSATION_NEW_MESSAGE'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="vapidPublicKey" class="profile--settings--row row push-row">
|
<div v-if="vapidPublicKey" class="profile--settings--row row push-row">
|
||||||
|
@ -105,6 +122,23 @@
|
||||||
}}
|
}}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
v-model="selectedPushFlags"
|
||||||
|
class="notification--checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
value="push_assigned_conversation_new_message"
|
||||||
|
@input="handlePushInput"
|
||||||
|
/>
|
||||||
|
<label for="assigned_conversation_new_message">
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.ASSIGNED_CONVERSATION_NEW_MESSAGE'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -35,7 +35,7 @@ export default [
|
||||||
inbox_id: 1,
|
inbox_id: 1,
|
||||||
status: 0,
|
status: 0,
|
||||||
timestamp: 1578555084,
|
timestamp: 1578555084,
|
||||||
user_last_seen_at: 0,
|
contact_last_seen_at: 0,
|
||||||
agent_last_seen_at: 1578555084,
|
agent_last_seen_at: 1578555084,
|
||||||
unread_count: 0,
|
unread_count: 0,
|
||||||
},
|
},
|
||||||
|
@ -75,7 +75,7 @@ export default [
|
||||||
inbox_id: 2,
|
inbox_id: 2,
|
||||||
status: 0,
|
status: 0,
|
||||||
timestamp: 1578555084,
|
timestamp: 1578555084,
|
||||||
user_last_seen_at: 0,
|
contact_last_seen_at: 0,
|
||||||
agent_last_seen_at: 1578555084,
|
agent_last_seen_at: 1578555084,
|
||||||
unread_count: 0,
|
unread_count: 0,
|
||||||
},
|
},
|
||||||
|
|
|
@ -33,7 +33,7 @@ const toggleTyping = async ({ typingStatus }) => {
|
||||||
const setUserLastSeenAt = async ({ lastSeen }) => {
|
const setUserLastSeenAt = async ({ lastSeen }) => {
|
||||||
return API.post(
|
return API.post(
|
||||||
`/api/v1/widget/conversations/update_last_seen${window.location.search}`,
|
`/api/v1/widget/conversations/update_last_seen${window.location.search}`,
|
||||||
{ user_last_seen_at: lastSeen }
|
{ contact_last_seen_at: lastSeen }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ export const actions = {
|
||||||
get: async ({ commit }) => {
|
get: async ({ commit }) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await getConversationAPI();
|
const { data } = await getConversationAPI();
|
||||||
const { user_last_seen_at: lastSeen } = data;
|
const { contact_last_seen_at: lastSeen } = data;
|
||||||
commit(SET_CONVERSATION_ATTRIBUTES, data);
|
commit(SET_CONVERSATION_ATTRIBUTES, data);
|
||||||
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
|
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -4,7 +4,7 @@ class ContactAvatarJob < ApplicationJob
|
||||||
def perform(contact, avatar_url)
|
def perform(contact, avatar_url)
|
||||||
avatar_resource = LocalResource.new(avatar_url)
|
avatar_resource = LocalResource.new(avatar_url)
|
||||||
contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
|
contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
|
||||||
rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED, SocketError => e
|
rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED, SocketError, NoMethodError => e
|
||||||
Rails.logger.info "invalid url #{file_url} : #{e.message}"
|
Rails.logger.info "invalid url #{avatar_url} : #{e.message}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,6 +2,9 @@ class Notification::EmailNotificationJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :default
|
||||||
|
|
||||||
def perform(notification)
|
def perform(notification)
|
||||||
|
# no need to send email if notification has been read already
|
||||||
|
return if notification.read_at.present?
|
||||||
|
|
||||||
Notification::EmailNotificationService.new(notification: notification).perform
|
Notification::EmailNotificationService.new(notification: notification).perform
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,4 +26,20 @@ class NotificationListener < BaseListener
|
||||||
primary_actor: conversation
|
primary_actor: conversation
|
||||||
).perform
|
).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def message_created(event)
|
||||||
|
message, account = extract_message_and_account(event)
|
||||||
|
conversation = message.conversation
|
||||||
|
|
||||||
|
# only want to notify agents about customer messages
|
||||||
|
return unless message.incoming?
|
||||||
|
return unless conversation.assignee
|
||||||
|
|
||||||
|
NotificationBuilder.new(
|
||||||
|
notification_type: 'assigned_conversation_new_message',
|
||||||
|
user: conversation.assignee,
|
||||||
|
account: account,
|
||||||
|
primary_actor: conversation
|
||||||
|
).perform
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -19,6 +19,18 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
|
||||||
send_mail_with_liquid(to: @agent.email, subject: subject) and return
|
send_mail_with_liquid(to: @agent.email, subject: subject) and return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def assigned_conversation_new_message(conversation, agent)
|
||||||
|
return unless smtp_config_set_or_development?
|
||||||
|
# Don't spam with email notifications if agent is online
|
||||||
|
return if ::OnlineStatusTracker.get_presence(conversation.account.id, 'User', agent.id)
|
||||||
|
|
||||||
|
@agent = agent
|
||||||
|
@conversation = conversation
|
||||||
|
subject = "#{@agent.available_name}, New message in your assigned conversation [ID - #{@conversation.display_id}]."
|
||||||
|
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
|
||||||
|
send_mail_with_liquid(to: @agent.email, subject: subject) and return
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def liquid_droppables
|
def liquid_droppables
|
||||||
|
|
|
@ -50,7 +50,13 @@ class ApplicationMailer < ActionMailer::Base
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def locale_from_account(account)
|
||||||
|
I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil
|
||||||
|
end
|
||||||
|
|
||||||
def ensure_current_account(account)
|
def ensure_current_account(account)
|
||||||
Current.account = account if account.present?
|
Current.account = account if account.present?
|
||||||
|
locale ||= locale_from_account(account) if account.present?
|
||||||
|
I18n.locale = locale || I18n.default_locale
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,6 +6,7 @@ class ConversationReplyMailer < ApplicationMailer
|
||||||
return unless smtp_config_set_or_development?
|
return unless smtp_config_set_or_development?
|
||||||
|
|
||||||
init_conversation_attributes(conversation)
|
init_conversation_attributes(conversation)
|
||||||
|
return if conversation_already_viewed?
|
||||||
|
|
||||||
recap_messages = @conversation.messages.chat.where('created_at < ?', message_queued_time).last(10)
|
recap_messages = @conversation.messages.chat.where('created_at < ?', message_queued_time).last(10)
|
||||||
new_messages = @conversation.messages.chat.where('created_at >= ?', message_queued_time)
|
new_messages = @conversation.messages.chat.where('created_at >= ?', message_queued_time)
|
||||||
|
@ -26,6 +27,7 @@ class ConversationReplyMailer < ApplicationMailer
|
||||||
return unless smtp_config_set_or_development?
|
return unless smtp_config_set_or_development?
|
||||||
|
|
||||||
init_conversation_attributes(conversation)
|
init_conversation_attributes(conversation)
|
||||||
|
return if conversation_already_viewed?
|
||||||
|
|
||||||
@messages = @conversation.messages.chat.outgoing.where('created_at >= ?', message_queued_time)
|
@messages = @conversation.messages.chat.outgoing.where('created_at >= ?', message_queued_time)
|
||||||
return false if @messages.count.zero?
|
return false if @messages.count.zero?
|
||||||
|
@ -63,6 +65,18 @@ class ConversationReplyMailer < ApplicationMailer
|
||||||
@agent = @conversation.assignee
|
@agent = @conversation.assignee
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def conversation_already_viewed?
|
||||||
|
# whether contact already saw the message on widget
|
||||||
|
return unless @conversation.contact_last_seen_at
|
||||||
|
return unless last_outgoing_message&.created_at
|
||||||
|
|
||||||
|
@conversation.contact_last_seen_at > last_outgoing_message&.created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def last_outgoing_message
|
||||||
|
@conversation.messages.chat.where.not(message_type: :incoming)&.last
|
||||||
|
end
|
||||||
|
|
||||||
def assignee_name
|
def assignee_name
|
||||||
@assignee_name ||= @agent&.available_name || 'Notifications'
|
@assignee_name ||= @agent&.available_name || 'Notifications'
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,10 +5,10 @@
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# additional_attributes :jsonb
|
# additional_attributes :jsonb
|
||||||
# agent_last_seen_at :datetime
|
# agent_last_seen_at :datetime
|
||||||
|
# contact_last_seen_at :datetime
|
||||||
# identifier :string
|
# identifier :string
|
||||||
# locked :boolean default(FALSE)
|
# locked :boolean default(FALSE)
|
||||||
# status :integer default("open"), not null
|
# status :integer default("open"), not null
|
||||||
# user_last_seen_at :datetime
|
|
||||||
# uuid :uuid not null
|
# uuid :uuid not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
|
@ -166,7 +166,7 @@ class Conversation < ApplicationRecord
|
||||||
{
|
{
|
||||||
CONVERSATION_OPENED => -> { saved_change_to_status? && open? },
|
CONVERSATION_OPENED => -> { saved_change_to_status? && open? },
|
||||||
CONVERSATION_RESOLVED => -> { saved_change_to_status? && resolved? },
|
CONVERSATION_RESOLVED => -> { saved_change_to_status? && resolved? },
|
||||||
CONVERSATION_READ => -> { saved_change_to_user_last_seen_at? },
|
CONVERSATION_READ => -> { saved_change_to_contact_last_seen_at? },
|
||||||
CONVERSATION_LOCK_TOGGLE => -> { saved_change_to_locked? },
|
CONVERSATION_LOCK_TOGGLE => -> { saved_change_to_locked? },
|
||||||
ASSIGNEE_CHANGED => -> { saved_change_to_assignee_id? },
|
ASSIGNEE_CHANGED => -> { saved_change_to_assignee_id? },
|
||||||
CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? }
|
CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? }
|
||||||
|
|
|
@ -150,14 +150,28 @@ class Message < ApplicationRecord
|
||||||
::MessageTemplates::HookExecutionService.new(message: self).perform
|
::MessageTemplates::HookExecutionService.new(message: self).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def notify_via_mail
|
def email_notifiable_message?
|
||||||
if Redis::Alfred.get(conversation_mail_key).nil? && conversation.contact.email? && outgoing? && !private
|
return false unless outgoing?
|
||||||
# set a redis key for the conversation so that we don't need to send email for every
|
return false if private?
|
||||||
# new message that comes in and we dont enque the delayed sidekiq job for every message
|
|
||||||
Redis::Alfred.setex(conversation_mail_key, Time.zone.now)
|
|
||||||
|
|
||||||
# Since this is live chat, send the email after few minutes so the only one email with
|
true
|
||||||
# last few messages coupled together is sent rather than email for each message
|
end
|
||||||
|
|
||||||
|
def can_notify_via_mail?
|
||||||
|
return unless email_notifiable_message?
|
||||||
|
return false if conversation.contact.email.blank?
|
||||||
|
return false unless %w[Website Email].include? inbox.inbox_type
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_via_mail
|
||||||
|
return unless can_notify_via_mail?
|
||||||
|
|
||||||
|
# set a redis key for the conversation so that we don't need to send email for every new message
|
||||||
|
# last few messages coupled together is sent every 2 minutes rather than one email for each message
|
||||||
|
if Redis::Alfred.get(conversation_mail_key).nil?
|
||||||
|
Redis::Alfred.setex(conversation_mail_key, Time.zone.now)
|
||||||
ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, Time.zone.now)
|
ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, Time.zone.now)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -31,7 +31,8 @@ class Notification < ApplicationRecord
|
||||||
|
|
||||||
NOTIFICATION_TYPES = {
|
NOTIFICATION_TYPES = {
|
||||||
conversation_creation: 1,
|
conversation_creation: 1,
|
||||||
conversation_assignment: 2
|
conversation_assignment: 2,
|
||||||
|
assigned_conversation_new_message: 3
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
enum notification_type: NOTIFICATION_TYPES
|
enum notification_type: NOTIFICATION_TYPES
|
||||||
|
@ -64,6 +65,8 @@ class Notification < ApplicationRecord
|
||||||
|
|
||||||
return "A new conversation [ID -#{primary_actor.display_id}] has been assigned to you." if notification_type == 'conversation_assignment'
|
return "A new conversation [ID -#{primary_actor.display_id}] has been assigned to you." if notification_type == 'conversation_assignment'
|
||||||
|
|
||||||
|
return "New message in your assigned conversation [ID -#{primary_actor.display_id}]." if notification_type == 'assigned_conversation_new_message'
|
||||||
|
|
||||||
''
|
''
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -71,6 +74,7 @@ class Notification < ApplicationRecord
|
||||||
|
|
||||||
def process_notification_delivery
|
def process_notification_delivery
|
||||||
Notification::PushNotificationJob.perform_later(self)
|
Notification::PushNotificationJob.perform_later(self)
|
||||||
|
|
||||||
# Should we do something about the case where user subscribed to both push and email ?
|
# Should we do something about the case where user subscribed to both push and email ?
|
||||||
# In future, we could probably add condition here to enqueue the job for 30 seconds later
|
# In future, we could probably add condition here to enqueue the job for 30 seconds later
|
||||||
# when push enabled and then check in email job whether notification has been read already.
|
# when push enabled and then check in email job whether notification has been read already.
|
||||||
|
|
|
@ -31,7 +31,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
|
||||||
def push_timestamps
|
def push_timestamps
|
||||||
{
|
{
|
||||||
agent_last_seen_at: agent_last_seen_at.to_i,
|
agent_last_seen_at: agent_last_seen_at.to_i,
|
||||||
user_last_seen_at: user_last_seen_at.to_i,
|
contact_last_seen_at: contact_last_seen_at.to_i,
|
||||||
timestamp: created_at.to_i
|
timestamp: created_at.to_i
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
|
@ -64,6 +64,8 @@ class Notification::PushNotificationService
|
||||||
)
|
)
|
||||||
rescue Webpush::ExpiredSubscription
|
rescue Webpush::ExpiredSubscription
|
||||||
subscription.destroy!
|
subscription.destroy!
|
||||||
|
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
|
||||||
|
Rails.logger.info "Webpush operation error: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_fcm_push(subscription)
|
def send_fcm_push(subscription)
|
||||||
|
|
|
@ -22,7 +22,7 @@ json.status conversation.status
|
||||||
json.muted conversation.muted?
|
json.muted conversation.muted?
|
||||||
json.can_reply conversation.can_reply?
|
json.can_reply conversation.can_reply?
|
||||||
json.timestamp conversation.messages.last.try(:created_at).try(:to_i)
|
json.timestamp conversation.messages.last.try(:created_at).try(:to_i)
|
||||||
json.user_last_seen_at conversation.user_last_seen_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.agent_last_seen_at conversation.agent_last_seen_at.to_i
|
||||||
json.unread_count conversation.unread_incoming_messages.count
|
json.unread_count conversation.unread_incoming_messages.count
|
||||||
json.additional_attributes conversation.additional_attributes
|
json.additional_attributes conversation.additional_attributes
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
if @conversation
|
if @conversation
|
||||||
json.id @conversation.display_id
|
json.id @conversation.display_id
|
||||||
json.inbox_id @conversation.inbox_id
|
json.inbox_id @conversation.inbox_id
|
||||||
json.user_last_seen_at @conversation.user_last_seen_at.to_i
|
json.contact_last_seen_at @conversation.contact_last_seen_at.to_i
|
||||||
json.status @conversation.status
|
json.status @conversation.status
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
<p>Hi {{user.available_name}},</p>
|
||||||
|
|
||||||
|
<p>You have received a new message in your assigned conversation.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Click <a href="{{action_url}}">here</a> to get cracking.
|
||||||
|
</p>
|
5
db/migrate/20200907094912_rename_user_last_seen.rb
Normal file
5
db/migrate/20200907094912_rename_user_last_seen.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class RenameUserLastSeen < ActiveRecord::Migration[6.0]
|
||||||
|
def change
|
||||||
|
rename_column :conversations, :user_last_seen_at, :contact_last_seen_at
|
||||||
|
end
|
||||||
|
end
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2020_08_28_175931) do
|
ActiveRecord::Schema.define(version: 2020_09_07_094912) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
|
@ -219,7 +219,7 @@ ActiveRecord::Schema.define(version: 2020_08_28_175931) do
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.bigint "contact_id"
|
t.bigint "contact_id"
|
||||||
t.integer "display_id", null: false
|
t.integer "display_id", null: false
|
||||||
t.datetime "user_last_seen_at"
|
t.datetime "contact_last_seen_at"
|
||||||
t.datetime "agent_last_seen_at"
|
t.datetime "agent_last_seen_at"
|
||||||
t.boolean "locked", default: false
|
t.boolean "locked", default: false
|
||||||
t.jsonb "additional_attributes"
|
t.jsonb "additional_attributes"
|
||||||
|
|
|
@ -33,7 +33,7 @@ When a new message is created in the API channel, you will get a POST request to
|
||||||
"inbox_id": 0,
|
"inbox_id": 0,
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"agent_last_seen_at": 0,
|
"agent_last_seen_at": 0,
|
||||||
"user_last_seen_at": 0,
|
"contact_last_seen_at": 0,
|
||||||
"timestamp": 0
|
"timestamp": 0
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
|
|
|
@ -26,7 +26,7 @@ class Integrations::Facebook::DeliveryStatus
|
||||||
def update_message_status
|
def update_message_status
|
||||||
return unless conversation
|
return unless conversation
|
||||||
|
|
||||||
conversation.user_last_seen_at = @params.at
|
conversation.contact_last_seen_at = @params.at
|
||||||
conversation.save!
|
conversation.save!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -105,7 +105,7 @@
|
||||||
"git add"
|
"git add"
|
||||||
],
|
],
|
||||||
"!(*schema).rb": [
|
"!(*schema).rb": [
|
||||||
"rubocop -a",
|
"bundle exec rubocop -a",
|
||||||
"git add"
|
"git add"
|
||||||
],
|
],
|
||||||
"*.scss": [
|
"*.scss": [
|
||||||
|
|
|
@ -47,7 +47,7 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
|
||||||
context 'with a conversation' do
|
context 'with a conversation' do
|
||||||
it 'returns the correct conversation params' do
|
it 'returns the correct conversation params' do
|
||||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||||
expect(conversation.user_last_seen_at).to eq(nil)
|
expect(conversation.contact_last_seen_at).to eq(nil)
|
||||||
|
|
||||||
post '/api/v1/widget/conversations/update_last_seen',
|
post '/api/v1/widget/conversations/update_last_seen',
|
||||||
headers: { 'X-Auth-Token' => token },
|
headers: { 'X-Auth-Token' => token },
|
||||||
|
@ -56,7 +56,7 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
|
|
||||||
expect(conversation.reload.user_last_seen_at).not_to eq(nil)
|
expect(conversation.reload.contact_last_seen_at).not_to eq(nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,9 +4,10 @@ RSpec.describe ConversationMailbox, type: :mailbox do
|
||||||
include ActionMailbox::TestHelper
|
include ActionMailbox::TestHelper
|
||||||
|
|
||||||
describe 'add mail as reply in a conversation' do
|
describe 'add mail as reply in a conversation' do
|
||||||
let(:agent) { create(:user, email: 'agent1@example.com') }
|
let(:account) { create(:account) }
|
||||||
|
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
||||||
let(:reply_mail) { create_inbound_email_from_fixture('reply.eml') }
|
let(:reply_mail) { create_inbound_email_from_fixture('reply.eml') }
|
||||||
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, greeting_enabled: false)) }
|
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
|
||||||
let(:described_subject) { described_class.receive reply_mail }
|
let(:described_subject) { described_class.receive reply_mail }
|
||||||
let(:serialized_attributes) { %w[text_content html_content number_of_attachments subject date to from in_reply_to cc bcc message_id] }
|
let(:serialized_attributes) { %w[text_content html_content number_of_attachments subject date to from in_reply_to cc bcc message_id] }
|
||||||
|
|
||||||
|
|
|
@ -36,4 +36,21 @@ RSpec.describe AgentNotifications::ConversationNotificationsMailer, type: :maile
|
||||||
expect(mail.to).to eq([agent.email])
|
expect(mail.to).to eq([agent.email])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'assigned_conversation_new_message' do
|
||||||
|
let(:mail) { described_class.assigned_conversation_new_message(conversation, agent).deliver_now }
|
||||||
|
|
||||||
|
it 'renders the subject' do
|
||||||
|
expect(mail.subject).to eq("#{agent.available_name}, New message in your assigned conversation [ID - #{conversation.display_id}].")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'renders the receiver email' do
|
||||||
|
expect(mail.to).to eq([agent.email])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'will not send email if agent is online' do
|
||||||
|
::OnlineStatusTracker.update_presence(conversation.account.id, 'User', agent.id)
|
||||||
|
expect(mail).to eq nil
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,9 +14,9 @@ RSpec.describe ConversationReplyMailer, type: :mailer do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with summary' do
|
context 'with summary' do
|
||||||
let(:conversation) { create(:conversation, assignee: agent) }
|
let(:conversation) { create(:conversation, account: account, assignee: agent) }
|
||||||
let(:message) { create(:message, conversation: conversation) }
|
let(:message) { create(:message, account: account, conversation: conversation) }
|
||||||
let(:private_message) { create(:message, content: 'This is a private message', conversation: conversation) }
|
let(:private_message) { create(:message, account: account, content: 'This is a private message', conversation: conversation) }
|
||||||
let(:mail) { described_class.reply_with_summary(message.conversation, Time.zone.now).deliver_now }
|
let(:mail) { described_class.reply_with_summary(message.conversation, Time.zone.now).deliver_now }
|
||||||
|
|
||||||
it 'renders the subject' do
|
it 'renders the subject' do
|
||||||
|
@ -31,6 +31,12 @@ RSpec.describe ConversationReplyMailer, type: :mailer do
|
||||||
expect(mail.body.decoded).not_to include(private_message.content)
|
expect(mail.body.decoded).not_to include(private_message.content)
|
||||||
expect(mail.body.decoded).to include(message.content)
|
expect(mail.body.decoded).to include(message.content)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'will not send email if conversation is already viewed by contact' do
|
||||||
|
create(:message, message_type: 'outgoing', account: account, conversation: conversation)
|
||||||
|
conversation.update(contact_last_seen_at: Time.zone.now)
|
||||||
|
expect(mail).to eq nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'without assignee' do
|
context 'without assignee' do
|
||||||
|
@ -75,6 +81,12 @@ RSpec.describe ConversationReplyMailer, type: :mailer do
|
||||||
expect(mail.body.decoded).not_to include(message_1.content)
|
expect(mail.body.decoded).not_to include(message_1.content)
|
||||||
expect(mail.body.decoded).to include(message_2.content)
|
expect(mail.body.decoded).to include(message_2.content)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'will not send email if conversation is already viewed by contact' do
|
||||||
|
create(:message, message_type: 'outgoing', account: account, conversation: conversation)
|
||||||
|
conversation.update(contact_last_seen_at: Time.zone.now)
|
||||||
|
expect(mail).to eq nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when custom domain and email is not enabled' do
|
context 'when custom domain and email is not enabled' do
|
||||||
|
|
|
@ -69,7 +69,7 @@ RSpec.describe Conversation, type: :model do
|
||||||
conversation.update(
|
conversation.update(
|
||||||
status: :resolved,
|
status: :resolved,
|
||||||
locked: true,
|
locked: true,
|
||||||
user_last_seen_at: Time.now,
|
contact_last_seen_at: Time.now,
|
||||||
assignee: new_assignee
|
assignee: new_assignee
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
@ -317,7 +317,7 @@ RSpec.describe Conversation, type: :model do
|
||||||
timestamp: conversation.created_at.to_i,
|
timestamp: conversation.created_at.to_i,
|
||||||
can_reply: true,
|
can_reply: true,
|
||||||
channel: 'Channel::WebWidget',
|
channel: 'Channel::WebWidget',
|
||||||
user_last_seen_at: conversation.user_last_seen_at.to_i,
|
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
|
||||||
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
|
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
|
||||||
unread_count: 0
|
unread_count: 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ RSpec.describe Message, type: :model do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when message is created' do
|
context 'when message is created' do
|
||||||
let(:message) { build(:message) }
|
let(:message) { build(:message, account: create(:account)) }
|
||||||
|
|
||||||
it 'triggers ::MessageTemplates::HookExecutionService' do
|
it 'triggers ::MessageTemplates::HookExecutionService' do
|
||||||
hook_execution_service = double
|
hook_execution_service = double
|
||||||
|
@ -23,10 +23,25 @@ RSpec.describe Message, type: :model do
|
||||||
expect(hook_execution_service).to have_received(:perform)
|
expect(hook_execution_service).to have_received(:perform)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'calls notify email method on after save' do
|
it 'calls notify email method on after save for outgoing messages' do
|
||||||
allow(message).to receive(:notify_via_mail).and_return(true)
|
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
|
||||||
|
message.message_type = 'outgoing'
|
||||||
message.save!
|
message.save!
|
||||||
expect(message).to have_received(:notify_via_mail)
|
expect(ConversationReplyEmailWorker).to have_received(:perform_in)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'wont call notify email method for private notes' do
|
||||||
|
message.private = true
|
||||||
|
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
|
||||||
|
message.save!
|
||||||
|
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'wont call notify email method unless its website or email channel' do
|
||||||
|
message.inbox = create(:inbox, account: message.account, channel: build(:channel_api, account: message.account))
|
||||||
|
allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true)
|
||||||
|
message.save!
|
||||||
|
expect(ConversationReplyEmailWorker).not_to have_received(:perform_in)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -25,7 +25,7 @@ RSpec.describe Conversations::EventDataPresenter do
|
||||||
can_reply: conversation.can_reply?,
|
can_reply: conversation.can_reply?,
|
||||||
channel: conversation.inbox.channel_type,
|
channel: conversation.inbox.channel_type,
|
||||||
timestamp: conversation.created_at.to_i,
|
timestamp: conversation.created_at.to_i,
|
||||||
user_last_seen_at: conversation.user_last_seen_at.to_i,
|
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
|
||||||
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
|
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
|
||||||
unread_count: 0
|
unread_count: 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ properties:
|
||||||
timestamp:
|
timestamp:
|
||||||
type: string
|
type: string
|
||||||
description: The time at which conversation was created
|
description: The time at which conversation was created
|
||||||
user_last_seen_at:
|
contact_last_seen_at:
|
||||||
type: string
|
type: string
|
||||||
agent_last_seen_at:
|
agent_last_seen_at:
|
||||||
type: agent_last_seen_at
|
type: agent_last_seen_at
|
||||||
|
|
|
@ -1088,7 +1088,7 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The time at which conversation was created"
|
"description": "The time at which conversation was created"
|
||||||
},
|
},
|
||||||
"user_last_seen_at": {
|
"contact_last_seen_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"agent_last_seen_at": {
|
"agent_last_seen_at": {
|
||||||
|
|
Loading…
Reference in a new issue