Feature: Add web push notification permission in frontend (#766)

Add webpush notification permission in frontend

Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
Pranav Raj S 2020-05-06 00:10:56 +05:30 committed by GitHub
parent 5bd7a4c511
commit e9131ea558
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 651 additions and 318 deletions

View file

@ -71,14 +71,14 @@ SENTRY_DSN=
# Disable if you want to write logs to a file # Disable if you want to write logs to a file
RAILS_LOG_TO_STDOUT=true RAILS_LOG_TO_STDOUT=true
LOG_LEVEL=info LOG_LEVEL=info
LOG_SIZE=500 LOG_SIZE=500
# Credentials to access sidekiq dashboard in production # Credentials to access sidekiq dashboard in production
SIDEKIQ_AUTH_USERNAME= SIDEKIQ_AUTH_USERNAME=
SIDEKIQ_AUTH_PASSWORD= SIDEKIQ_AUTH_PASSWORD=
### This environment variables are only required if you are setting up social media channels ### This environment variables are only required if you are setting up social media channels
#facebook #facebook
FB_VERIFY_TOKEN= FB_VERIFY_TOKEN=
FB_APP_SECRET= FB_APP_SECRET=
FB_APP_ID= FB_APP_ID=
@ -101,3 +101,8 @@ CHARGEBEE_API_KEY=
CHARGEBEE_SITE= CHARGEBEE_SITE=
CHARGEBEE_WEBHOOK_USERNAME= CHARGEBEE_WEBHOOK_USERNAME=
CHARGEBEE_WEBHOOK_PASSWORD= CHARGEBEE_WEBHOOK_PASSWORD=
## Push Notification
## generate a new key value here : https://d3v.one/vapid-key-generator/
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=

View file

@ -13,7 +13,7 @@ Layout/LineLength:
Metrics/ClassLength: Metrics/ClassLength:
Max: 125 Max: 125
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 15 Max: 25
Style/Documentation: Style/Documentation:
Enabled: false Enabled: false
Style/FrozenStringLiteralComment: Style/FrozenStringLiteralComment:

View file

@ -80,6 +80,9 @@ gem 'sidekiq'
##-- used for single column multiple binary flags in notification settings/feature flagging --## ##-- used for single column multiple binary flags in notification settings/feature flagging --##
gem 'flag_shih_tzu' gem 'flag_shih_tzu'
##-- Push notification service --##
gem 'webpush'
group :development do group :development do
gem 'annotate' gem 'annotate'
gem 'bullet' gem 'bullet'

View file

@ -219,6 +219,7 @@ GEM
haikunator (1.1.0) haikunator (1.1.0)
hana (1.3.5) hana (1.3.5)
hashie (4.1.0) hashie (4.1.0)
hkdf (0.3.0)
http-accept (1.7.0) http-accept (1.7.0)
http-cookie (1.0.3) http-cookie (1.0.3)
domain_name (~> 0.5) domain_name (~> 0.5)
@ -489,6 +490,9 @@ GEM
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
webpush (1.0.0)
hkdf (~> 0.2)
jwt (~> 2.0)
websocket-driver (0.7.1) websocket-driver (0.7.1)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.4) websocket-extensions (0.1.4)
@ -532,7 +536,7 @@ DEPENDENCIES
letter_opener letter_opener
listen listen
mini_magick mini_magick
mock_redis mock_redis!
pg pg
pry-rails pry-rails
puma puma
@ -568,6 +572,7 @@ DEPENDENCIES
valid_email2 valid_email2
web-console web-console
webpacker webpacker
webpush
wisper (= 2.0.0) wisper (= 2.0.0)
RUBY VERSION RUBY VERSION

View file

@ -0,0 +1,28 @@
class NotificationSubscriptionBuilder
pattr_initialize [:params, :user!]
def perform
# if multiple accounts were used to login in same browser
move_subscription_to_user if identifier_subscription && identifier_subscription.user_id != user.id
build_identifier_subscription if identifier_subscription.blank?
identifier_subscription
end
private
def identifier
@identifier ||= params[:subscription_attributes][:endpoint] if params[:subscription_type] == 'browser_push'
end
def identifier_subscription
@identifier_subscription ||= NotificationSubscription.find_by(identifier: identifier)
end
def move_subscription_to_user
@identifier_subscription.update(user_id: user.id)
end
def build_identifier_subscription
user.notification_subscriptions.create(params.merge(identifier: identifier))
end
end

View file

@ -20,10 +20,11 @@ class Api::V1::Accounts::NotificationSettingsController < Api::BaseController
end end
def notification_setting_params def notification_setting_params
params.require(:notification_settings).permit(selected_email_flags: []) params.require(:notification_settings).permit(selected_email_flags: [], selected_push_flags: [])
end end
def update_flags def update_flags
@notification_setting.selected_email_flags = notification_setting_params[:selected_email_flags] @notification_setting.selected_email_flags = notification_setting_params[:selected_email_flags]
@notification_setting.selected_push_flags = notification_setting_params[:selected_push_flags]
end end
end end

View file

@ -2,7 +2,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
before_action :set_user before_action :set_user
def create def create
notification_subscription = @user.notification_subscriptions.create(notification_subscription_params) notification_subscription = NotificationSubscriptionBuilder.new(user: @user, params: notification_subscription_params).perform
render json: notification_subscription render json: notification_subscription
end end

View file

@ -4,7 +4,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
before_action :set_contact before_action :set_contact
def toggle_typing def toggle_typing
head :ok if conversation.nil? head :ok && return if conversation.nil?
if permitted_params[:typing_status] == 'on' if permitted_params[:typing_status] == 'on'
trigger_typing_event(CONVERSATION_TYPING_ON) trigger_typing_event(CONVERSATION_TYPING_ON)

View file

@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class NotificationSubscriptions extends ApiClient {
constructor() {
super('notification_subscriptions');
}
}
export default new NotificationSubscriptions();

View file

@ -1,6 +1,6 @@
<template> <template>
<button <button
type="submit" :type="type"
:disabled="disabled" :disabled="disabled"
:class="computedClass" :class="computedClass"
@click="onClick" @click="onClick"
@ -39,6 +39,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
type: {
type: String,
default: 'submit',
},
}, },
computed: { computed: {
computedClass() { computedClass() {

View file

@ -0,0 +1,93 @@
/* eslint-disable no-console */
import NotificationSubscriptions from '../api/notificationSubscription';
import auth from '../api/auth';
export const verifyServiceWorkerExistence = (callback = () => {}) => {
if (!('serviceWorker' in navigator)) {
// Service Worker isn't supported on this browser, disable or hide UI.
return;
}
if (!('PushManager' in window)) {
// Push isn't supported on this browser, disable or hide UI.
return;
}
navigator.serviceWorker
.register('/sw.js')
.then(registration => callback(registration))
.catch(registrationError => {
// eslint-disable-next-line
console.log('SW registration failed: ', registrationError);
});
};
export const hasPushPermissions = () => {
if ('Notification' in window) {
return Notification.permission === 'granted';
}
return false;
};
const generateKeys = str =>
btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, '-')
.replace(/\//g, '_');
export const getPushSubscriptionPayload = subscription => ({
subscription_type: 'browser_push',
subscription_attributes: {
endpoint: subscription.endpoint,
p256dh: generateKeys(subscription.getKey('p256dh')),
auth: generateKeys(subscription.getKey('auth')),
},
});
export const sendRegistrationToServer = subscription => {
if (auth.isLoggedIn()) {
return NotificationSubscriptions.create(
getPushSubscriptionPayload(subscription)
);
}
return null;
};
export const registerSubscription = (onSuccess = () => {}) => {
if (!window.chatwootConfig.vapidPublicKey) {
return;
}
navigator.serviceWorker.ready
.then(serviceWorkerRegistration =>
serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: window.chatwootConfig.vapidPublicKey,
})
)
.then(sendRegistrationToServer)
.then(() => {
onSuccess();
})
.catch(() => {
window.bus.$emit(
'newToastMessage',
'This browser does not support desktop notification'
);
});
};
export const requestPushPermissions = ({ onSuccess }) => {
if (!('Notification' in window)) {
window.bus.$emit(
'newToastMessage',
'This browser does not support desktop notification'
);
} else if (Notification.permission === 'granted') {
registerSubscription(onSuccess);
} else if (Notification.permission !== 'denied') {
Notification.requestPermission(permission => {
if (permission === 'granted') {
registerSubscription(onSuccess);
}
});
}
};

View file

@ -10,11 +10,11 @@
"REMOVE_IMAGE": "Remove", "REMOVE_IMAGE": "Remove",
"UPLOAD_IMAGE": "Upload image", "UPLOAD_IMAGE": "Upload image",
"UPDATE_IMAGE": "Update image", "UPDATE_IMAGE": "Update image",
"PROFILE_SECTION" : { "PROFILE_SECTION": {
"TITLE": "Profile", "TITLE": "Profile",
"NOTE": "Your email address is your identity and is used to log in." "NOTE": "Your email address is your identity and is used to log in."
}, },
"PASSWORD_SECTION" : { "PASSWORD_SECTION": {
"TITLE": "Password", "TITLE": "Password",
"NOTE": "Updating your password would reset your logins in multiple devices." "NOTE": "Updating your password would reset your logins in multiple devices."
}, },
@ -22,15 +22,25 @@
"TITLE": "Access Token", "TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration" "NOTE": "This token can be used if you are building an API based integration"
}, },
"EMAIL_NOTIFICATIONS_SECTION" : { "EMAIL_NOTIFICATIONS_SECTION": {
"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"
"UPDATE_SUCCESS": "Your email notification preferences are updated successfully", },
"API": {
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
"UPDATE_ERROR": "There is an error while updating the preferences, please try again" "UPDATE_ERROR": "There is an error while updating the preferences, please try again"
}, },
"PROFILE_IMAGE":{ "PUSH_NOTIFICATIONS_SECTION": {
"TITLE": "Push Notifications",
"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",
"HAS_ENABLED_PUSH": "You have enabled push for this browser.",
"REQUEST_PUSH": "Enable push notifications"
},
"PROFILE_IMAGE": {
"LABEL": "Profile Image" "LABEL": "Profile Image"
}, },
"NAME": { "NAME": {

View file

@ -1,114 +0,0 @@
<template>
<div class="profile--settings--row row">
<div class="columns small-3 ">
<h4 class="block-title">
{{ $t('PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.NOTE') }}</p>
</div>
<div class="columns small-9">
<div>
<input
v-model="selectedNotifications"
class="email-notification--checkbox"
type="checkbox"
value="email_conversation_creation"
@input="handleInput"
/>
<label for="conversation_creation">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.CONVERSATION_CREATION'
)
}}
</label>
</div>
<div>
<input
v-model="selectedNotifications"
class="email-notification--checkbox"
type="checkbox"
value="email_conversation_assignment"
@input="handleInput"
/>
<label for="conversation_assignment">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.CONVERSATION_ASSIGNMENT'
)
}}
</label>
</div>
</div>
</div>
</template>
<script>
/* global bus */
import { mapGetters } from 'vuex';
export default {
data() {
return {
selectedNotifications: [],
};
},
computed: {
...mapGetters({
selectedEmailFlags: 'userNotificationSettings/getSelectedEmailFlags',
}),
},
watch: {
selectedEmailFlags(value) {
this.selectedNotifications = value;
},
},
mounted() {
this.$store.dispatch('userNotificationSettings/get');
},
methods: {
async handleInput(e) {
const selectedValue = e.target.value;
if (this.selectedEmailFlags.includes(e.target.value)) {
const selectedEmailFlags = this.selectedEmailFlags.filter(
flag => flag !== selectedValue
);
this.selectedNotifications = selectedEmailFlags;
} else {
this.selectedNotifications = [
...this.selectedEmailFlags,
selectedValue,
];
}
try {
this.$store.dispatch(
'userNotificationSettings/update',
this.selectedNotifications
);
bus.$emit(
'newToastMessage',
this.$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.UPDATE_SUCCESS'
)
);
} catch (error) {
bus.$emit(
'newToastMessage',
this.$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.UPDATE_ERROR'
)
);
}
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables.scss';
.email-notification--checkbox {
font-size: $font-size-large;
}
</style>

View file

@ -82,7 +82,7 @@
</label> </label>
</div> </div>
</div> </div>
<email-notifications /> <notification-settings />
<div class="profile--settings--row row"> <div class="profile--settings--row row">
<div class="columns small-3 "> <div class="columns small-3 ">
<h4 class="block-title"> <h4 class="block-title">
@ -111,11 +111,11 @@ import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { required, minLength, email } from 'vuelidate/lib/validators'; import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from '../../../../store/utils/api'; import { clearCookiesOnLogout } from '../../../../store/utils/api';
import EmailNotifications from './EmailNotifications'; import NotificationSettings from './NotificationSettings';
export default { export default {
components: { components: {
EmailNotifications, NotificationSettings,
Thumbnail, Thumbnail,
}, },
data() { data() {

View file

@ -0,0 +1,226 @@
<template>
<div>
<div class="profile--settings--row row">
<div class="columns small-3 ">
<h4 class="block-title">
{{ $t('PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.TITLE') }}
</h4>
<p>
{{ $t('PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.NOTE') }}
</p>
</div>
<div class="columns small-9">
<div>
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_conversation_creation"
@input="handleEmailInput"
/>
<label for="conversation_creation">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.CONVERSATION_CREATION'
)
}}
</label>
</div>
<div>
<input
v-model="selectedEmailFlags"
class="notification--checkbox"
type="checkbox"
value="email_conversation_assignment"
@input="handleEmailInput"
/>
<label for="conversation_assignment">
{{
$t(
'PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.CONVERSATION_ASSIGNMENT'
)
}}
</label>
</div>
</div>
</div>
<div v-if="vapidPublicKey" class="profile--settings--row row push-row">
<div class="columns small-3 ">
<h4 class="block-title">
{{ $t('PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.TITLE') }}
</h4>
<p>{{ $t('PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.NOTE') }}</p>
</div>
<div class="columns small-9">
<p v-if="hasEnabledPushPermissions">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.HAS_ENABLED_PUSH'
)
}}
</p>
<div v-else>
<woot-submit-button
:button-text="
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.REQUEST_PUSH'
)
"
class="button nice small"
type="button"
@click="onRequestPermissions"
/>
</div>
<div>
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_conversation_creation"
@input="handlePushInput"
/>
<label for="conversation_creation">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.CONVERSATION_CREATION'
)
}}
</label>
</div>
<div>
<input
v-model="selectedPushFlags"
class="notification--checkbox"
type="checkbox"
value="push_conversation_assignment"
@input="handlePushInput"
/>
<label for="conversation_assignment">
{{
$t(
'PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.CONVERSATION_ASSIGNMENT'
)
}}
</label>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import configMixin from 'shared/mixins/configMixin';
import {
hasPushPermissions,
requestPushPermissions,
verifyServiceWorkerExistence,
} from '../../../../helper/pushHelper';
export default {
mixins: [alertMixin, configMixin],
data() {
return {
selectedEmailFlags: [],
selectedPushFlags: [],
hasEnabledPushPermissions: false,
};
},
computed: {
...mapGetters({
emailFlags: 'userNotificationSettings/getSelectedEmailFlags',
pushFlags: 'userNotificationSettings/getSelectedPushFlags',
}),
},
watch: {
emailFlags(value) {
this.selectedEmailFlags = value;
},
pushFlags(value) {
this.selectedPushFlags = value;
},
},
mounted() {
if (hasPushPermissions()) {
this.getPushSubscription();
}
this.$store.dispatch('userNotificationSettings/get');
},
methods: {
onRegistrationSuccess() {
this.hasEnabledPushPermissions = true;
},
onRequestPermissions() {
requestPushPermissions({
onSuccess: this.onRegistrationSuccess,
});
},
getPushSubscription() {
verifyServiceWorkerExistence(registration =>
registration.pushManager
.getSubscription()
.then(subscription => {
console.log(subscription);
if (!subscription) {
this.hasEnabledPushPermissions = false;
} else {
this.hasEnabledPushPermissions = true;
}
})
.catch(error => console.log(error))
);
},
async updateNotificationSettings() {
try {
this.$store.dispatch('userNotificationSettings/update', {
selectedEmailFlags: this.selectedEmailFlags,
selectedPushFlags: this.selectedPushFlags,
});
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
} catch (error) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_ERROR'));
}
},
handleEmailInput(e) {
this.selectedEmailFlags = this.toggleInput(
this.selectedEmailFlags,
e.target.value
);
this.updateNotificationSettings();
},
handlePushInput(e) {
this.selectedPushFlags = this.toggleInput(
this.selectedPushFlags,
e.target.value
);
this.updateNotificationSettings();
},
toggleInput(selected, current) {
if (selected.includes(current)) {
const newSelectedFlags = selected.filter(flag => flag !== current);
return newSelectedFlags;
}
return [...selected, current];
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables.scss';
.notification--checkbox {
font-size: $font-size-large;
}
// Hide on Safari
.push-row:not(:root:root) {
display: none;
}
</style>

View file

@ -17,6 +17,9 @@ export const getters = {
getSelectedEmailFlags: $state => { getSelectedEmailFlags: $state => {
return $state.record.selected_email_flags; return $state.record.selected_email_flags;
}, },
getSelectedPushFlags: $state => {
return $state.record.selected_push_flags;
},
}; };
export const actions = { export const actions = {
@ -35,12 +38,13 @@ export const actions = {
} }
}, },
update: async ({ commit }, params) => { update: async ({ commit }, { selectedEmailFlags, selectedPushFlags }) => {
commit(types.default.SET_USER_NOTIFICATION_UI_FLAG, { isUpdating: true }); commit(types.default.SET_USER_NOTIFICATION_UI_FLAG, { isUpdating: true });
try { try {
const response = await UserNotificationSettings.update({ const response = await UserNotificationSettings.update({
notification_settings: { notification_settings: {
selected_email_flags: params, selected_email_flags: selectedEmailFlags,
selected_push_flags: selectedPushFlags,
}, },
}); });
commit(types.default.SET_USER_NOTIFICATION, response.data); commit(types.default.SET_USER_NOTIFICATION, response.data);

View file

@ -26,6 +26,10 @@ import router from '../dashboard/routes';
import store from '../dashboard/store'; import store from '../dashboard/store';
import vueActionCable from '../dashboard/helper/actionCable'; import vueActionCable from '../dashboard/helper/actionCable';
import constants from '../dashboard/constants'; import constants from '../dashboard/constants';
import {
verifyServiceWorkerExistence,
registerSubscription,
} from '../dashboard/helper/pushHelper';
Vue.config.env = process.env; Vue.config.env = process.env;
@ -66,15 +70,12 @@ window.onload = () => {
vueActionCable.init(); vueActionCable.init();
}; };
if ('serviceWorker' in navigator) { window.addEventListener('load', () => {
window.addEventListener('load', () => { verifyServiceWorkerExistence(registration =>
navigator.serviceWorker registration.pushManager.getSubscription().then(subscription => {
.register('/sw.js') if (subscription) {
.then(registration => { registerSubscription();
console.log('SW registered: ', registration); }
}) })
.catch(registrationError => { );
console.log('SW registration failed: ', registrationError); });
});
});
}

View file

@ -6,5 +6,8 @@ export default {
twilioCallbackURL() { twilioCallbackURL() {
return `${this.hostURL}/twilio/callback`; return `${this.hostURL}/twilio/callback`;
}, },
vapidPublicKey() {
return window.chatwootConfig.vapidPublicKey;
},
}, },
}; };

View file

@ -0,0 +1,7 @@
class Notification::EmailNotificationJob < ApplicationJob
queue_as :default
def perform(notification)
Notification::EmailNotificationService.new(notification: notification).perform
end
end

View file

@ -0,0 +1,7 @@
class Notification::PushNotificationJob < ApplicationJob
queue_as :default
def perform(notification)
Notification::PushNotificationService.new(notification: notification).perform
end
end

View file

@ -19,6 +19,7 @@ class AgentBot < ApplicationRecord
def push_event_data def push_event_data
{ {
id: id,
name: name, name: name,
avatar_url: avatar_url, avatar_url: avatar_url,
type: 'agent_bot' type: 'agent_bot'

View file

@ -41,7 +41,9 @@ class Notification < ApplicationRecord
private private
def process_notification_delivery def process_notification_delivery
Notification::EmailNotificationService.new(notification: self).perform Notification::PushNotificationJob.perform_later(self)
# Notification::PushNotificationService.new(notification: self).perform
# Queuing after 2 minutes so that we won't send emails for read notifications
Notification::EmailNotificationJob.set(wait: 2.minutes).perform_later(self)
end end
end end

View file

@ -3,6 +3,7 @@
# Table name: notification_subscriptions # Table name: notification_subscriptions
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# identifier :string
# subscription_attributes :jsonb not null # subscription_attributes :jsonb not null
# subscription_type :integer not null # subscription_type :integer not null
# created_at :datetime not null # created_at :datetime not null
@ -11,11 +12,13 @@
# #
# Indexes # Indexes
# #
# index_notification_subscriptions_on_user_id (user_id) # index_notification_subscriptions_on_identifier (identifier) UNIQUE
# index_notification_subscriptions_on_user_id (user_id)
# #
class NotificationSubscription < ApplicationRecord class NotificationSubscription < ApplicationRecord
belongs_to :user belongs_to :user
validates :identifier, presence: true
SUBSCRIPTION_TYPES = { SUBSCRIPTION_TYPES = {
browser_push: 1, browser_push: 1,

View file

@ -128,6 +128,7 @@ class User < ApplicationRecord
def push_event_data def push_event_data
{ {
id: id,
name: name, name: name,
avatar_url: avatar_url, avatar_url: avatar_url,
type: 'user' type: 'user'

View file

@ -2,11 +2,14 @@ class Notification::EmailNotificationService
pattr_initialize [:notification!] pattr_initialize [:notification!]
def perform def perform
# don't send emails if user read the push notification already
return if notification.read_at.present?
return unless user_subscribed_to_notification? return unless user_subscribed_to_notification?
# TODO : Clean up whatever happening over here # TODO : Clean up whatever happening over here
# Segregate the mailers properly
AgentNotifications::ConversationNotificationsMailer.public_send(notification AgentNotifications::ConversationNotificationsMailer.public_send(notification
.notification_type.to_s, notification.primary_actor, notification.user).deliver_later .notification_type.to_s, notification.primary_actor, notification.user).deliver_now
end end
private private

View file

@ -1,17 +1,73 @@
class Notification::PushNotificationService class Notification::PushNotificationService
include Rails.application.routes.url_helpers
pattr_initialize [:notification!] pattr_initialize [:notification!]
def perform def perform
return unless user_subscribed_to_notification? return unless user_subscribed_to_notification?
# TODO: implement the push delivery logic here
notification_subscriptions.each do |subscription|
send_browser_push(subscription) if subscription.browser_push?
end
end end
private private
delegate :user, to: :notification
delegate :notification_subscriptions, to: :user
delegate :notification_settings, to: :user
def user_subscribed_to_notification? def user_subscribed_to_notification?
notification_setting = notification.user.notification_settings.find_by(account_id: notification.account.id) notification_setting = notification_settings.find_by(account_id: notification.account.id)
return true if notification_setting.public_send("push_#{notification.notification_type}?") return true if notification_setting.public_send("push_#{notification.notification_type}?")
false false
end end
def conversation
@conversation ||= notification.primary_actor
end
def push_message_title
if notification.notification_type == 'conversation_creation'
return "A new conversation [ID -#{conversation.display_id}] has been created in #{conversation.inbox.name}"
end
if notification.notification_type == 'conversation_assignment'
return "A new conversation [ID -#{conversation.display_id}] has been assigned to you."
end
''
end
def push_message
{
title: push_message_title,
tag: "#{notification.notification_type}_#{conversation.display_id}",
url: push_url
}
end
def push_url
app_account_conversation_url(account_id: conversation.account_id, id: conversation.display_id)
end
def send_browser_push(subscription)
Webpush.payload_send(
message: JSON.generate(push_message),
endpoint: subscription.subscription_attributes['endpoint'],
p256dh: subscription.subscription_attributes['p256dh'],
auth: subscription.subscription_attributes['auth'],
vapid: {
subject: push_url,
public_key: ENV['VAPID_PUBLIC_KEY'],
private_key: ENV['VAPID_PRIVATE_KEY']
},
ssl_timeout: 5,
open_timeout: 5,
read_timeout: 5
)
rescue Webpush::ExpiredSubscription
subscription.destroy!
end
end end

View file

@ -3,3 +3,5 @@ json.user_id @notification_setting.user_id
json.account_id @notification_setting.account_id json.account_id @notification_setting.account_id
json.all_email_flags @notification_setting.all_email_flags json.all_email_flags @notification_setting.all_email_flags
json.selected_email_flags @notification_setting.selected_email_flags json.selected_email_flags @notification_setting.selected_email_flags
json.all_push_flags @notification_setting.all_push_flags
json.selected_push_flags @notification_setting.selected_push_flags

View file

@ -34,7 +34,10 @@
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>', hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
fbAppId: '<%= ENV.fetch('FB_APP_ID', nil) %>', fbAppId: '<%= ENV.fetch('FB_APP_ID', nil) %>',
billingEnabled: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('BILLING_ENABLED', false)) %>, billingEnabled: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('BILLING_ENABLED', false)) %>,
signupEnabled: '<%= ENV.fetch('ENABLE_ACCOUNT_SIGNUP', true) %>' signupEnabled: '<%= ENV.fetch('ENABLE_ACCOUNT_SIGNUP', true) %>',
<% if ENV['VAPID_PUBLIC_KEY'] %>
vapidPublicKey: new Uint8Array(<%= Base64.urlsafe_decode64(ENV['VAPID_PUBLIC_KEY']).bytes %>),
<% end %>
} }
</script> </script>
</body> </body>

View file

@ -0,0 +1,6 @@
class AddIndexOnNotificationSubscriptions < ActiveRecord::Migration[6.0]
def change
add_column :notification_subscriptions, :identifier, :string
add_index :notification_subscriptions, :identifier, unique: true
end
end

View file

@ -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_05_03_151130) do ActiveRecord::Schema.define(version: 2020_05_04_144712) 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"
@ -284,6 +284,8 @@ ActiveRecord::Schema.define(version: 2020_05_03_151130) do
t.jsonb "subscription_attributes", default: "{}", null: false t.jsonb "subscription_attributes", default: "{}", null: false
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.string "identifier"
t.index ["identifier"], name: "index_notification_subscriptions_on_identifier", unique: true
t.index ["user_id"], name: "index_notification_subscriptions_on_user_id" t.index ["user_id"], name: "index_notification_subscriptions_on_user_id"
end end
@ -316,6 +318,20 @@ ActiveRecord::Schema.define(version: 2020_05_03_151130) do
t.boolean "payment_source_added", default: false t.boolean "payment_source_added", default: false
end end
create_table "super_admins", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
t.datetime "remember_created_at"
t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at"
t.datetime "last_sign_in_at"
t.inet "current_sign_in_ip"
t.inet "last_sign_in_ip"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["email"], name: "index_super_admins_on_email", unique: true
end
create_table "taggings", id: :serial, force: :cascade do |t| create_table "taggings", id: :serial, force: :cascade do |t|
t.integer "tag_id" t.integer "tag_id"
t.string "taggable_type" t.string "taggable_type"

View file

@ -1,137 +1,37 @@
/* eslint-disable */ /* eslint-disable no-restricted-globals, no-console */
/** * /* globals clients */
* self.addEventListener('push', event => {
* The rest of the code is auto-generated. Please don't update this file let notification = event.data && event.data.json();
* directly; instead, make changes to your Workbox build configuration
* and re-run your build process.
* See https://goo.gl/2aRDsh
*/
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js"); event.waitUntil(
self.registration.showNotification(notification.title, {
self.addEventListener('message', (event) => { tag: notification.tag,
if (event.data && event.data.type === 'SKIP_WAITING') { data: {
self.skipWaiting(); url: notification.url,
} },
})
);
}); });
/** self.addEventListener('notificationclick', event => {
* The workboxSW.precacheAndRoute() method efficiently caches and responds to let notification = event.notification;
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab event.waitUntil(
*/ clients.matchAll({ type: 'window' }).then(windowClients => {
self.__precacheManifest = [ let matchingWindowClients = windowClients.filter(
{ client => client.url === notification.data.url
"url": "android-icon-144x144.png", );
"revision": "d9e3ad004635d6d3154da20ef6e53077"
}, if (matchingWindowClients.length) {
{ let firstWindow = matchingWindowClients[0];
"url": "android-icon-192x192.png", if (firstWindow && 'focus' in firstWindow) {
"revision": "8f2f76058ff81bb03e390ed941f68a70" firstWindow.focus();
}, return;
{ }
"url": "android-icon-36x36.png", }
"revision": "70b2fa97615a1ccf8fa373674928d0e3" if (clients.openWindow) {
}, clients.openWindow(notification.data.url);
{ }
"url": "android-icon-48x48.png", })
"revision": "c0e8a16e2ea4430deddac82979f97c60" );
}, });
{
"url": "android-icon-72x72.png",
"revision": "98f4881cce0daf4b89f0b30825b16d80"
},
{
"url": "android-icon-96x96.png",
"revision": "02cf787c7a88eb898976d79ad0b4e041"
},
{
"url": "apple-icon-114x114.png",
"revision": "544c150aa39d3ecfd6071e3c54d1503e"
},
{
"url": "apple-icon-120x120.png",
"revision": "3b10208d8f4b09c5c3631eb5e4e67d9a"
},
{
"url": "apple-icon-144x144.png",
"revision": "d9e3ad004635d6d3154da20ef6e53077"
},
{
"url": "apple-icon-152x152.png",
"revision": "a866770945a41e5bcf29706f37e5beba"
},
{
"url": "apple-icon-180x180.png",
"revision": "327e9272f10374d2859d2a26c86698ec"
},
{
"url": "apple-icon-57x57.png",
"revision": "ee6e09647e6a26e29655ed4091a6d577"
},
{
"url": "apple-icon-60x60.png",
"revision": "136acdd5567a57f0b30c4704c93ce412"
},
{
"url": "apple-icon-72x72.png",
"revision": "98f4881cce0daf4b89f0b30825b16d80"
},
{
"url": "apple-icon-76x76.png",
"revision": "5de2acd8f66a8fa583830286231abe88"
},
{
"url": "apple-icon-precomposed.png",
"revision": "03175edf677b78aae0c7ce1c90996bcc"
},
{
"url": "apple-icon.png",
"revision": "03175edf677b78aae0c7ce1c90996bcc"
},
{
"url": "apple-touch-icon-precomposed.png",
"revision": "d41d8cd98f00b204e9800998ecf8427e"
},
{
"url": "apple-touch-icon.png",
"revision": "d41d8cd98f00b204e9800998ecf8427e"
},
{
"url": "favicon-16x16.png",
"revision": "df49c81fbfd18e43ea9199153f1d5e1f"
},
{
"url": "favicon-32x32.png",
"revision": "e781cbd8ca95543e247fa913eef30f9c"
},
{
"url": "favicon-512x512.png",
"revision": "48e48806ef9cbe9edcbe81a08713dc7f"
},
{
"url": "favicon-96x96.png",
"revision": "02cf787c7a88eb898976d79ad0b4e041"
},
{
"url": "favicon.ico",
"revision": "788f4b1590d83444281e0c96792fd42b"
},
{
"url": "ms-icon-144x144.png",
"revision": "d9e3ad004635d6d3154da20ef6e53077"
},
{
"url": "ms-icon-150x150.png",
"revision": "0770f6909fd7676a02922cd34d23ff15"
},
{
"url": "ms-icon-310x310.png",
"revision": "492181f5f2a4c199936f7f03c70e4914"
},
{
"url": "ms-icon-70x70.png",
"revision": "c1b4c1be97c6768c0e5547c2b07bf2a2"
}
].concat(self.__precacheManifest || []);
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});

View file

@ -46,7 +46,7 @@ describe ::ContactMergeAction do
expect do expect do
described_class.new(account: new_account, base_contact: base_contact, described_class.new(account: new_account, base_contact: base_contact,
mergee_contact: mergee_contact).perform mergee_contact: mergee_contact).perform
end .to raise_error('contact does not belong to the account') end.to raise_error('contact does not belong to the account')
end end
end end
end end

View file

@ -70,7 +70,7 @@ RSpec.describe 'Contacts API', type: :request do
expect do expect do
post "/api/v1/accounts/#{account.id}/contacts", headers: admin.create_new_auth_token, post "/api/v1/accounts/#{account.id}/contacts", headers: admin.create_new_auth_token,
params: valid_params params: valid_params
end .to change(Contact, :count).by(1) end.to change(Contact, :count).by(1)
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
end end

View file

@ -17,14 +17,66 @@ RSpec.describe 'Notifications Subscriptions API', type: :request do
it 'creates a notification subscriptions' do it 'creates a notification subscriptions' do
post '/api/v1/notification_subscriptions', post '/api/v1/notification_subscriptions',
params: { notification_subscription: { subscription_type: 'browser_push', 'subscription_attributes': { test: 'test' } } }, params: {
notification_subscription: {
subscription_type: 'browser_push',
'subscription_attributes': {
endpoint: 'test',
p256dh: 'test',
auth: 'test'
}
}
},
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body) json_response = JSON.parse(response.body)
expect(json_response['subscription_type']).to eq('browser_push') expect(json_response['subscription_type']).to eq('browser_push')
expect(json_response['subscription_attributes']).to eq({ 'test' => 'test' }) expect(json_response['subscription_attributes']['auth']).to eq('test')
end
it 'returns existing notification subscription if subscription exists' do
subscription = create(:notification_subscription, user: agent)
post '/api/v1/notification_subscriptions',
params: {
notification_subscription: {
subscription_type: 'browser_push',
'subscription_attributes': {
endpoint: 'test',
p256dh: 'test',
auth: 'test'
}
}
},
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['id']).to eq(subscription.id)
end
it 'move notification subscription to user if its of another user' do
subscription = create(:notification_subscription, user: create(:user))
post '/api/v1/notification_subscriptions',
params: {
notification_subscription: {
subscription_type: 'browser_push',
'subscription_attributes': {
endpoint: 'test',
p256dh: 'test',
auth: 'test'
}
}
},
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['id']).to eq(subscription.id)
expect(json_response['user_id']).to eq(agent.id)
end end
end end
end end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
FactoryBot.define do
factory :notification_subscription do
user
identifier { 'test' }
subscription_type { 'browser_push' }
subscription_attributes { { endpoint: 'test', auth: 'test' } }
end
end

View file

@ -11,16 +11,11 @@ describe NotificationListener do
describe 'conversation_created' do describe 'conversation_created' do
let(:event_name) { :'conversation.created' } let(:event_name) { :'conversation.created' }
before do
creation_mailer = double
allow(AgentNotifications::ConversationNotificationsMailer).to receive(:conversation_creation).and_return(creation_mailer)
allow(creation_mailer).to receive(:deliver_later).and_return(true)
end
context 'when conversation is created' do context 'when conversation is created' do
it 'sends email to inbox members who have notifications turned on' do it 'creates notifications for inbox members who have notifications turned on' do
notification_setting = agent_with_notification.notification_settings.first notification_setting = agent_with_notification.notification_settings.first
notification_setting.selected_email_flags = [:email_conversation_creation] notification_setting.selected_email_flags = [:email_conversation_creation]
notification_setting.selected_push_flags = []
notification_setting.save! notification_setting.save!
create(:inbox_member, user: agent_with_notification, inbox: inbox) create(:inbox_member, user: agent_with_notification, inbox: inbox)
@ -29,13 +24,13 @@ describe NotificationListener do
event = Events::Base.new(event_name, Time.zone.now, conversation: conversation) event = Events::Base.new(event_name, Time.zone.now, conversation: conversation)
listener.conversation_created(event) listener.conversation_created(event)
expect(AgentNotifications::ConversationNotificationsMailer).to have_received(:conversation_creation) expect(notification_setting.user.notifications.count).to eq(1)
.with(conversation, agent_with_notification)
end end
it 'does not send and email to inbox members who have notifications turned off' do it 'does not create notification for inbox members who have notifications turned off' do
notification_setting = agent_with_notification.notification_settings.first notification_setting = agent_with_out_notification.notification_settings.first
notification_setting.unselect_all_email_flags notification_setting.unselect_all_email_flags
notification_setting.unselect_all_push_flags
notification_setting.save! notification_setting.save!
create(:inbox_member, user: agent_with_out_notification, inbox: inbox) create(:inbox_member, user: agent_with_out_notification, inbox: inbox)
@ -44,8 +39,7 @@ describe NotificationListener do
event = Events::Base.new(event_name, Time.zone.now, conversation: conversation) event = Events::Base.new(event_name, Time.zone.now, conversation: conversation)
listener.conversation_created(event) listener.conversation_created(event)
expect(AgentNotifications::ConversationNotificationsMailer).not_to have_received(:conversation_creation) expect(notification_setting.user.notifications.count).to eq(0)
.with(conversation, agent_with_out_notification)
end end
end end
end end

View file

@ -122,28 +122,19 @@ RSpec.describe Conversation, type: :model do
expect(conversation.reload.assignee).to eq(agent) expect(conversation.reload.assignee).to eq(agent)
end end
it 'send assignment mailer' do it 'creates a new notification for the agent' do
allow(AgentNotifications::ConversationNotificationsMailer).to receive(:conversation_assignment).and_return(assignment_mailer)
allow(assignment_mailer).to receive(:deliver_later)
Current.user = conversation.assignee
expect(update_assignee).to eq(true) expect(update_assignee).to eq(true)
# send_email_notification_to_assignee expect(agent.notifications.count).to eq(1)
expect(AgentNotifications::ConversationNotificationsMailer).to have_received(:conversation_assignment).with(conversation, agent)
expect(assignment_mailer).to have_received(:deliver_later) if ENV.fetch('SMTP_ADDRESS', nil).present?
end end
it 'does not send assignment mailer if notification setting is turned off' do it 'does not create assignment notification if notification setting is turned off' do
allow(AgentNotifications::ConversationNotificationsMailer).to receive(:conversation_assignment).and_return(assignment_mailer)
notification_setting = agent.notification_settings.first notification_setting = agent.notification_settings.first
notification_setting.unselect_all_email_flags notification_setting.unselect_all_email_flags
notification_setting.unselect_all_push_flags
notification_setting.save! notification_setting.save!
expect(update_assignee).to eq(true) expect(update_assignee).to eq(true)
expect(AgentNotifications::ConversationNotificationsMailer).not_to have_received(:conversation_assignment).with(conversation, agent) expect(agent.notifications.count).to eq(0)
end end
end end