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:
@@ -8,7 +8,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
def update_last_seen
|
||||
head :ok && return if conversation.nil?
|
||||
|
||||
conversation.user_last_seen_at = DateTime.now.utc
|
||||
conversation.contact_last_seen_at = DateTime.now.utc
|
||||
conversation.save!
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"TITLE": "Email Notifications",
|
||||
"NOTE": "Update your email notification preferences here",
|
||||
"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": {
|
||||
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
|
||||
@@ -37,6 +38,7 @@
|
||||
"NOTE": "Update your push notification preferences here",
|
||||
"CONVERSATION_ASSIGNMENT": "Send push notifications when a conversation is assigned to me",
|
||||
"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.",
|
||||
"REQUEST_PUSH": "Enable push notifications"
|
||||
},
|
||||
|
||||
@@ -43,6 +43,23 @@
|
||||
}}
|
||||
</label>
|
||||
</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 v-if="vapidPublicKey" class="profile--settings--row row push-row">
|
||||
@@ -105,6 +122,23 @@
|
||||
}}
|
||||
</label>
|
||||
</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>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default [
|
||||
inbox_id: 1,
|
||||
status: 0,
|
||||
timestamp: 1578555084,
|
||||
user_last_seen_at: 0,
|
||||
contact_last_seen_at: 0,
|
||||
agent_last_seen_at: 1578555084,
|
||||
unread_count: 0,
|
||||
},
|
||||
@@ -75,7 +75,7 @@ export default [
|
||||
inbox_id: 2,
|
||||
status: 0,
|
||||
timestamp: 1578555084,
|
||||
user_last_seen_at: 0,
|
||||
contact_last_seen_at: 0,
|
||||
agent_last_seen_at: 1578555084,
|
||||
unread_count: 0,
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ const toggleTyping = async ({ typingStatus }) => {
|
||||
const setUserLastSeenAt = async ({ lastSeen }) => {
|
||||
return API.post(
|
||||
`/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 }) => {
|
||||
try {
|
||||
const { data } = await getConversationAPI();
|
||||
const { user_last_seen_at: lastSeen } = data;
|
||||
const { contact_last_seen_at: lastSeen } = data;
|
||||
commit(SET_CONVERSATION_ATTRIBUTES, data);
|
||||
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ class ContactAvatarJob < ApplicationJob
|
||||
def perform(contact, avatar_url)
|
||||
avatar_resource = LocalResource.new(avatar_url)
|
||||
contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
|
||||
rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED, SocketError => e
|
||||
Rails.logger.info "invalid url #{file_url} : #{e.message}"
|
||||
rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED, SocketError, NoMethodError => e
|
||||
Rails.logger.info "invalid url #{avatar_url} : #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,9 @@ class Notification::EmailNotificationJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,4 +26,20 @@ class NotificationListener < BaseListener
|
||||
primary_actor: conversation
|
||||
).perform
|
||||
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
|
||||
|
||||
@@ -19,6 +19,18 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
|
||||
send_mail_with_liquid(to: @agent.email, subject: subject) and return
|
||||
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
|
||||
|
||||
def liquid_droppables
|
||||
|
||||
@@ -50,7 +50,13 @@ class ApplicationMailer < ActionMailer::Base
|
||||
}
|
||||
end
|
||||
|
||||
def locale_from_account(account)
|
||||
I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil
|
||||
end
|
||||
|
||||
def ensure_current_account(account)
|
||||
Current.account = account if account.present?
|
||||
locale ||= locale_from_account(account) if account.present?
|
||||
I18n.locale = locale || I18n.default_locale
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
return unless smtp_config_set_or_development?
|
||||
|
||||
init_conversation_attributes(conversation)
|
||||
return if conversation_already_viewed?
|
||||
|
||||
recap_messages = @conversation.messages.chat.where('created_at < ?', message_queued_time).last(10)
|
||||
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?
|
||||
|
||||
init_conversation_attributes(conversation)
|
||||
return if conversation_already_viewed?
|
||||
|
||||
@messages = @conversation.messages.chat.outgoing.where('created_at >= ?', message_queued_time)
|
||||
return false if @messages.count.zero?
|
||||
@@ -63,6 +65,18 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
@agent = @conversation.assignee
|
||||
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
|
||||
@assignee_name ||= @agent&.available_name || 'Notifications'
|
||||
end
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
# id :integer not null, primary key
|
||||
# additional_attributes :jsonb
|
||||
# agent_last_seen_at :datetime
|
||||
# contact_last_seen_at :datetime
|
||||
# identifier :string
|
||||
# locked :boolean default(FALSE)
|
||||
# status :integer default("open"), not null
|
||||
# user_last_seen_at :datetime
|
||||
# uuid :uuid not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -166,7 +166,7 @@ class Conversation < ApplicationRecord
|
||||
{
|
||||
CONVERSATION_OPENED => -> { saved_change_to_status? && open? },
|
||||
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? },
|
||||
ASSIGNEE_CHANGED => -> { saved_change_to_assignee_id? },
|
||||
CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? }
|
||||
|
||||
@@ -150,14 +150,28 @@ class Message < ApplicationRecord
|
||||
::MessageTemplates::HookExecutionService.new(message: self).perform
|
||||
end
|
||||
|
||||
def notify_via_mail
|
||||
if Redis::Alfred.get(conversation_mail_key).nil? && conversation.contact.email? && outgoing? && !private
|
||||
# set a redis key for the conversation so that we don't need to send email for every
|
||||
# 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)
|
||||
def email_notifiable_message?
|
||||
return false unless outgoing?
|
||||
return false if private?
|
||||
|
||||
# Since this is live chat, send the email after few minutes so the only one email with
|
||||
# last few messages coupled together is sent rather than email for each message
|
||||
true
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,7 +31,8 @@ class Notification < ApplicationRecord
|
||||
|
||||
NOTIFICATION_TYPES = {
|
||||
conversation_creation: 1,
|
||||
conversation_assignment: 2
|
||||
conversation_assignment: 2,
|
||||
assigned_conversation_new_message: 3
|
||||
}.freeze
|
||||
|
||||
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 "New message in your assigned conversation [ID -#{primary_actor.display_id}]." if notification_type == 'assigned_conversation_new_message'
|
||||
|
||||
''
|
||||
end
|
||||
|
||||
@@ -71,6 +74,7 @@ class Notification < ApplicationRecord
|
||||
|
||||
def process_notification_delivery
|
||||
Notification::PushNotificationJob.perform_later(self)
|
||||
|
||||
# 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
|
||||
# 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
|
||||
{
|
||||
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
|
||||
}
|
||||
end
|
||||
|
||||
@@ -64,6 +64,8 @@ class Notification::PushNotificationService
|
||||
)
|
||||
rescue Webpush::ExpiredSubscription
|
||||
subscription.destroy!
|
||||
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.info "Webpush operation error: #{e.message}"
|
||||
end
|
||||
|
||||
def send_fcm_push(subscription)
|
||||
|
||||
@@ -22,7 +22,7 @@ json.status conversation.status
|
||||
json.muted conversation.muted?
|
||||
json.can_reply conversation.can_reply?
|
||||
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.unread_count conversation.unread_incoming_messages.count
|
||||
json.additional_attributes conversation.additional_attributes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
if @conversation
|
||||
json.id @conversation.display_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
|
||||
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>
|
||||
Reference in New Issue
Block a user