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

@ -101,3 +101,8 @@ CHARGEBEE_API_KEY=
CHARGEBEE_SITE=
CHARGEBEE_WEBHOOK_USERNAME=
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:
Max: 125
RSpec/ExampleLength:
Max: 15
Max: 25
Style/Documentation:
Enabled: false
Style/FrozenStringLiteralComment:

View file

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

View file

@ -219,6 +219,7 @@ GEM
haikunator (1.1.0)
hana (1.3.5)
hashie (4.1.0)
hkdf (0.3.0)
http-accept (1.7.0)
http-cookie (1.0.3)
domain_name (~> 0.5)
@ -489,6 +490,9 @@ GEM
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webpush (1.0.0)
hkdf (~> 0.2)
jwt (~> 2.0)
websocket-driver (0.7.1)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.4)
@ -532,7 +536,7 @@ DEPENDENCIES
letter_opener
listen
mini_magick
mock_redis
mock_redis!
pg
pry-rails
puma
@ -568,6 +572,7 @@ DEPENDENCIES
valid_email2
web-console
webpacker
webpush
wisper (= 2.0.0)
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
def notification_setting_params
params.require(:notification_settings).permit(selected_email_flags: [])
params.require(:notification_settings).permit(selected_email_flags: [], selected_push_flags: [])
end
def update_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

View file

@ -2,7 +2,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
before_action :set_user
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
end

View file

@ -4,7 +4,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
before_action :set_contact
def toggle_typing
head :ok if conversation.nil?
head :ok && return if conversation.nil?
if permitted_params[:typing_status] == '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>
<button
type="submit"
:type="type"
:disabled="disabled"
:class="computedClass"
@click="onClick"
@ -39,6 +39,10 @@ export default {
type: String,
default: '',
},
type: {
type: String,
default: 'submit',
},
},
computed: {
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",
"UPLOAD_IMAGE": "Upload image",
"UPDATE_IMAGE": "Update image",
"PROFILE_SECTION" : {
"PROFILE_SECTION": {
"TITLE": "Profile",
"NOTE": "Your email address is your identity and is used to log in."
},
"PASSWORD_SECTION" : {
"PASSWORD_SECTION": {
"TITLE": "Password",
"NOTE": "Updating your password would reset your logins in multiple devices."
},
@ -22,15 +22,25 @@
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration"
},
"EMAIL_NOTIFICATIONS_SECTION" : {
"EMAIL_NOTIFICATIONS_SECTION": {
"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",
"UPDATE_SUCCESS": "Your email notification preferences are updated successfully",
"CONVERSATION_CREATION": "Send email notifications when a new conversation is created"
},
"API": {
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
"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"
},
"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>
</div>
</div>
<email-notifications />
<notification-settings />
<div class="profile--settings--row row">
<div class="columns small-3 ">
<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 { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from '../../../../store/utils/api';
import EmailNotifications from './EmailNotifications';
import NotificationSettings from './NotificationSettings';
export default {
components: {
EmailNotifications,
NotificationSettings,
Thumbnail,
},
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 => {
return $state.record.selected_email_flags;
},
getSelectedPushFlags: $state => {
return $state.record.selected_push_flags;
},
};
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 });
try {
const response = await UserNotificationSettings.update({
notification_settings: {
selected_email_flags: params,
selected_email_flags: selectedEmailFlags,
selected_push_flags: selectedPushFlags,
},
});
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 vueActionCable from '../dashboard/helper/actionCable';
import constants from '../dashboard/constants';
import {
verifyServiceWorkerExistence,
registerSubscription,
} from '../dashboard/helper/pushHelper';
Vue.config.env = process.env;
@ -66,15 +70,12 @@ window.onload = () => {
vueActionCable.init();
};
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then(registration => {
console.log('SW registered: ', registration);
window.addEventListener('load', () => {
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
registerSubscription();
}
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
);
});

View file

@ -6,5 +6,8 @@ export default {
twilioCallbackURL() {
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
{
id: id,
name: name,
avatar_url: avatar_url,
type: 'agent_bot'

View file

@ -41,7 +41,9 @@ class Notification < ApplicationRecord
private
def process_notification_delivery
Notification::EmailNotificationService.new(notification: self).perform
# Notification::PushNotificationService.new(notification: self).perform
Notification::PushNotificationJob.perform_later(self)
# 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

View file

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

View file

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

View file

@ -2,11 +2,14 @@ class Notification::EmailNotificationService
pattr_initialize [:notification!]
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?
# TODO : Clean up whatever happening over here
# Segregate the mailers properly
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
private

View file

@ -1,17 +1,73 @@
class Notification::PushNotificationService
include Rails.application.routes.url_helpers
pattr_initialize [:notification!]
def perform
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
private
delegate :user, to: :notification
delegate :notification_subscriptions, to: :user
delegate :notification_settings, to: :user
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}?")
false
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

View file

@ -3,3 +3,5 @@ json.user_id @notification_setting.user_id
json.account_id @notification_setting.account_id
json.all_email_flags @notification_setting.all_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', '') %>',
fbAppId: '<%= ENV.fetch('FB_APP_ID', nil) %>',
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>
</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.
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
enable_extension "pgcrypto"
@ -284,6 +284,8 @@ ActiveRecord::Schema.define(version: 2020_05_03_151130) do
t.jsonb "subscription_attributes", default: "{}", null: false
t.datetime "created_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"
end
@ -316,6 +318,20 @@ ActiveRecord::Schema.define(version: 2020_05_03_151130) do
t.boolean "payment_source_added", default: false
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|
t.integer "tag_id"
t.string "taggable_type"

View file

@ -1,137 +1,37 @@
/* eslint-disable */
/** *
*
* The rest of the code is auto-generated. Please don't update this file
* directly; instead, make changes to your Workbox build configuration
* and re-run your build process.
* See https://goo.gl/2aRDsh
*/
/* eslint-disable no-restricted-globals, no-console */
/* globals clients */
self.addEventListener('push', event => {
let notification = event.data && event.data.json();
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
event.waitUntil(
self.registration.showNotification(notification.title, {
tag: notification.tag,
data: {
url: notification.url,
},
})
);
});
/**
* The workboxSW.precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
self.__precacheManifest = [
{
"url": "android-icon-144x144.png",
"revision": "d9e3ad004635d6d3154da20ef6e53077"
},
{
"url": "android-icon-192x192.png",
"revision": "8f2f76058ff81bb03e390ed941f68a70"
},
{
"url": "android-icon-36x36.png",
"revision": "70b2fa97615a1ccf8fa373674928d0e3"
},
{
"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"
self.addEventListener('notificationclick', event => {
let notification = event.notification;
event.waitUntil(
clients.matchAll({ type: 'window' }).then(windowClients => {
let matchingWindowClients = windowClients.filter(
client => client.url === notification.data.url
);
if (matchingWindowClients.length) {
let firstWindow = matchingWindowClients[0];
if (firstWindow && 'focus' in firstWindow) {
firstWindow.focus();
return;
}
].concat(self.__precacheManifest || []);
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});
}
if (clients.openWindow) {
clients.openWindow(notification.data.url);
}
})
);
});

View file

@ -46,7 +46,7 @@ describe ::ContactMergeAction do
expect do
described_class.new(account: new_account, base_contact: base_contact,
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

View file

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

View file

@ -17,14 +17,66 @@ RSpec.describe 'Notifications Subscriptions API', type: :request do
it 'creates a notification subscriptions' do
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,
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
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

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
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
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.selected_email_flags = [:email_conversation_creation]
notification_setting.selected_push_flags = []
notification_setting.save!
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)
listener.conversation_created(event)
expect(AgentNotifications::ConversationNotificationsMailer).to have_received(:conversation_creation)
.with(conversation, agent_with_notification)
expect(notification_setting.user.notifications.count).to eq(1)
end
it 'does not send and email to inbox members who have notifications turned off' do
notification_setting = agent_with_notification.notification_settings.first
it 'does not create notification for inbox members who have notifications turned off' do
notification_setting = agent_with_out_notification.notification_settings.first
notification_setting.unselect_all_email_flags
notification_setting.unselect_all_push_flags
notification_setting.save!
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)
listener.conversation_created(event)
expect(AgentNotifications::ConversationNotificationsMailer).not_to have_received(:conversation_creation)
.with(conversation, agent_with_out_notification)
expect(notification_setting.user.notifications.count).to eq(0)
end
end
end

View file

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