From dd176dd5f25378aef587a1f1a1d24b0346cb1c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Wed, 15 Nov 2017 21:35:10 -0500 Subject: [PATCH] Notification delivery (#6072) * ability for users to choose how they receive notifications add type field to more notifications, the type field is used to determine what to do based on user setting(none,notification,email,notificationemail) * change var name to types * cleanup * add event types for privileged users * remove unused language keys * fix uids check * changed if statements * upgrade script to preserver old settings --- public/language/en-GB/notifications.json | 18 ++- public/language/en-GB/user.json | 2 - src/controllers/accounts/settings.js | 68 ++++++++++- src/flags.js | 1 + src/groups/membership.js | 1 + src/messaging/notifications.js | 43 ------- src/notifications.js | 125 +++++++++++++++----- src/posts/queue.js | 44 ++++--- src/topics/follow.js | 34 ------ src/upgrades/1.7.1/notification-settings.js | 48 ++++++++ src/user.js | 16 ++- src/user/approval.js | 1 + src/user/settings.js | 6 + src/views/emails/notification.tpl | 57 +++++++++ 14 files changed, 328 insertions(+), 136 deletions(-) create mode 100644 src/upgrades/1.7.1/notification-settings.js create mode 100644 src/views/emails/notification.tpl diff --git a/public/language/en-GB/notifications.json b/public/language/en-GB/notifications.json index 7812eabf7c..50214f3f95 100644 --- a/public/language/en-GB/notifications.json +++ b/public/language/en-GB/notifications.json @@ -10,6 +10,7 @@ "continue_to": "Continue to %1", "return_to": "Return to %1", "new_notification": "New Notification", + "new_notification_from": "You have a new Notification from %1", "you_have_unread_notifications": "You have unread notifications.", "all": "All", @@ -50,5 +51,20 @@ "email-confirmed": "Email Confirmed", "email-confirmed-message": "Thank you for validating your email. Your account is now fully activated.", "email-confirm-error-message": "There was a problem validating your email address. Perhaps the code was invalid or has expired.", - "email-confirm-sent": "Confirmation email sent." + "email-confirm-sent": "Confirmation email sent.", + + "none": "None", + "notification_only": "Notification Only", + "email_only": "Email Only", + "notification_and_email": "Notification & Email", + "notificationType_upvote": "When someone upvotes your post", + "notificationType_new-topic": "When someone you follow posts a topic", + "notificationType_new-reply": "When a new reply is posted in a topic you are watching", + "notificationType_follow": "When someone starts following you", + "notificationType_new-chat": "When you receive a chat message", + "notificationType_group-invite": "When you receive a group invite", + "notificationType_new-register": "When someone gets added to registration queue", + "notificationType_post-queue": "When a new post is queued", + "notificationType_new-post-flag": "When a post is flagged", + "notificationType_new-user-flag": "When a user is flagged" } diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index 3916663282..a5bbf4d1ff 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -84,8 +84,6 @@ "digest_daily": "Daily", "digest_weekly": "Weekly", "digest_monthly": "Monthly", - "send_chat_notifications": "Send an email if a new chat message arrives and I am not online", - "send_post_notifications": "Send an email when replies are made to topics I am subscribed to", "settings-require-reload": "Some setting changes require a reload. Click here to reload the page.", "has_no_follower": "This user doesn't have any followers :(", diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js index 515cb33a4e..ea9c10126b 100644 --- a/src/controllers/accounts/settings.js +++ b/src/controllers/accounts/settings.js @@ -84,13 +84,19 @@ settingsController.get = function (req, res, callback) { plugins.fireHook('filter:user.customSettings', { settings: results.settings, customSettings: [], uid: req.uid }, next); }, function (data, next) { - getHomePageRoutes(userData, function (err, routes) { - userData.homePageRoutes = routes; - next(err, data); - }); - }, - function (data) { userData.customSettings = data.customSettings; + async.parallel({ + notificationSettings: function (next) { + getNotificationSettings(userData, next); + }, + routes: function (next) { + getHomePageRoutes(userData, next); + }, + }, next); + }, + function (results) { + userData.homePageRoutes = results.routes; + userData.notificationSettings = results.notificationSettings; userData.disableEmailSubscriptions = parseInt(meta.config.disableEmailSubscriptions, 10) === 1; userData.dailyDigestFreqOptions = [ @@ -149,6 +155,56 @@ settingsController.get = function (req, res, callback) { ], callback); }; +function getNotificationSettings(userData, callback) { + var types = [ + 'notificationType_upvote', + 'notificationType_new-topic', + 'notificationType_new-reply', + 'notificationType_follow', + 'notificationType_new-chat', + 'notificationType_group-invite', + ]; + + var privilegedTypes = []; + + async.waterfall([ + function (next) { + user.getPrivileges(userData.uid, next); + }, + function (privileges, next) { + if (privileges.isAdmin) { + privilegedTypes.push('notificationType_new-register'); + } + if (privileges.isAdmin || privileges.isGlobalMod || privileges.isModeratorOfAnyCategory) { + privilegedTypes.push('notificationType_post-queue', 'notificationType_new-post-flag'); + } + if (privileges.isAdmin || privileges.isGlobalMod) { + privilegedTypes.push('notificationType_new-user-flag'); + } + plugins.fireHook('filter:user.notificationTypes', { + userData: userData, + types: types, + privilegedTypes: privilegedTypes, + }, next); + }, + function (results, next) { + function modifyType(type) { + var setting = userData.settings[type] || 'notification'; + + return { + name: type, + label: '[[notifications:' + type + ']]', + none: setting === 'none', + notification: setting === 'notification', + email: setting === 'email', + notificationemail: setting === 'notificationemail', + }; + } + var notificationSettings = results.types.map(modifyType).concat(results.privilegedTypes.map(modifyType)); + next(null, notificationSettings); + }, + ], callback); +} function getHomePageRoutes(userData, callback) { async.waterfall([ diff --git a/src/flags.js b/src/flags.js index df6ec625f3..093757251b 100644 --- a/src/flags.js +++ b/src/flags.js @@ -696,6 +696,7 @@ Flags.notify = function (flagObj, uid, callback) { } notifications.create({ + type: 'new-user-flag', bodyShort: '[[notifications:user_flagged_user, ' + flagObj.reporter.username + ', ' + flagObj.target.username + ']]', bodyLong: flagObj.description, path: '/uid/' + flagObj.targetId, diff --git a/src/groups/membership.js b/src/groups/membership.js index 7547159910..f11eebcc0b 100644 --- a/src/groups/membership.js +++ b/src/groups/membership.js @@ -161,6 +161,7 @@ module.exports = function (Groups) { async.waterfall([ async.apply(inviteOrRequestMembership, groupName, uid, 'invite'), async.apply(notifications.create, { + type: 'group-invite', bodyShort: '[[groups:invited.notification_title, ' + groupName + ']]', bodyLong: '', nid: 'group:' + groupName + ':uid:' + uid + ':invite', diff --git a/src/messaging/notifications.js b/src/messaging/notifications.js index a3512f478b..3116c31a2b 100644 --- a/src/messaging/notifications.js +++ b/src/messaging/notifications.js @@ -1,12 +1,9 @@ 'use strict'; var async = require('async'); -var winston = require('winston'); var user = require('../user'); -var emailer = require('../emailer'); var notifications = require('../notifications'); -var meta = require('../meta'); var sockets = require('../socket.io'); var plugins = require('../plugins'); @@ -92,46 +89,6 @@ module.exports = function (Messaging) { if (notification) { notifications.push(notification, uids); } - sendNotificationEmails(uids, messageObj); - } - }); - } - - function sendNotificationEmails(uids, messageObj) { - if (parseInt(meta.config.disableEmailSubscriptions, 10) === 1) { - return; - } - - async.waterfall([ - function (next) { - async.parallel({ - userData: function (next) { - user.getUsersFields(uids, ['uid', 'username', 'userslug'], next); - }, - userSettings: function (next) { - user.getMultipleUserSettings(uids, next); - }, - }, next); - }, - - function (results, next) { - results.userData = results.userData.filter(function (userData, index) { - return userData && results.userSettings[index] && results.userSettings[index].sendChatNotifications; - }); - async.each(results.userData, function (userData, next) { - emailer.send('notif_chat', userData.uid, { - subject: '[[email:notif.chat.subject, ' + messageObj.fromUser.username + ']]', - summary: '[[notifications:new_message_from, ' + messageObj.fromUser.username + ']]', - message: messageObj, - roomId: messageObj.roomId, - username: userData.username, - userslug: userData.userslug, - }, next); - }, next); - }, - ], function (err) { - if (err) { - return winston.error(err); } }); } diff --git a/src/notifications.js b/src/notifications.js index cbc58dae8e..193427e8aa 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -13,6 +13,7 @@ var meta = require('./meta'); var batch = require('./batch'); var plugins = require('./plugins'); var utils = require('./utils'); +var emailer = require('./emailer'); var Notifications = module.exports; @@ -178,9 +179,78 @@ Notifications.push = function (notification, uids, callback) { }; function pushToUids(uids, notification, callback) { - var oneWeekAgo = Date.now() - 604800000; - var unreadKeys = []; - var readKeys = []; + function sendNotification(uids, callback) { + if (!uids.length) { + return callback(); + } + var oneWeekAgo = Date.now() - 604800000; + var unreadKeys = []; + var readKeys = []; + async.waterfall([ + function (next) { + uids.forEach(function (uid) { + unreadKeys.push('uid:' + uid + ':notifications:unread'); + readKeys.push('uid:' + uid + ':notifications:read'); + }); + + db.sortedSetsAdd(unreadKeys, notification.datetime, notification.nid, next); + }, + function (next) { + db.sortedSetsRemove(readKeys, notification.nid, next); + }, + function (next) { + db.sortedSetsRemoveRangeByScore(unreadKeys, '-inf', oneWeekAgo, next); + }, + function (next) { + db.sortedSetsRemoveRangeByScore(readKeys, '-inf', oneWeekAgo, next); + }, + function (next) { + var websockets = require('./socket.io'); + if (websockets.server) { + uids.forEach(function (uid) { + websockets.in('uid_' + uid).emit('event:new_notification', notification); + }); + } + next(); + }, + ], callback); + } + + function sendEmail(uids, callback) { + async.eachLimit(uids, 3, function (uid, next) { + emailer.send('notification', uid, { + path: notification.path, + subject: '[' + (meta.config.title || 'NodeBB') + '] ' + notification.bodyShort, + intro: '[[notifications:new_notification_from, ' + meta.config.title + ']]', + body: notification.bodyLong || notification.bodyShort, + showUnsubscribe: true, + }, next); + }, callback); + } + + function getUidsBySettings(uids, callback) { + var uidsToNotify = []; + var uidsToEmail = []; + async.waterfall([ + function (next) { + User.getMultipleUserSettings(uids, next); + }, + function (usersSettings, next) { + usersSettings.forEach(function (userSettings) { + var setting = userSettings['notificationType_' + notification.type] || 'notification'; + + if (setting === 'notification' || setting === 'notificationemail') { + uidsToNotify.push(userSettings.uid); + } + + if (setting === 'email' || setting === 'notificationemail') { + uidsToEmail.push(userSettings.uid); + } + }); + next(null, { uidsToNotify: uidsToNotify, uidsToEmail: uidsToEmail }); + }, + ], callback); + } async.waterfall([ function (next) { @@ -190,35 +260,32 @@ function pushToUids(uids, notification, callback) { if (!data || !data.notification || !data.uids || !data.uids.length) { return callback(); } - - uids = data.uids; notification = data.notification; - - uids.forEach(function (uid) { - unreadKeys.push('uid:' + uid + ':notifications:unread'); - readKeys.push('uid:' + uid + ':notifications:read'); - }); - - db.sortedSetsAdd(unreadKeys, notification.datetime, notification.nid, next); - }, - function (next) { - db.sortedSetsRemove(readKeys, notification.nid, next); - }, - function (next) { - db.sortedSetsRemoveRangeByScore(unreadKeys, '-inf', oneWeekAgo, next); + if (notification.type) { + getUidsBySettings(data.uids, next); + } else { + next(null, { uidsToNotify: data.uids, uidsToEmail: [] }); + } }, - function (next) { - db.sortedSetsRemoveRangeByScore(readKeys, '-inf', oneWeekAgo, next); + function (results, next) { + async.parallel([ + function (next) { + sendNotification(results.uidsToNotify, next); + }, + function (next) { + sendEmail(results.uidsToEmail, next); + }, + ], function (err) { + next(err, results); + }); }, - function (next) { - var websockets = require('./socket.io'); - if (websockets.server) { - uids.forEach(function (uid) { - websockets.in('uid_' + uid).emit('event:new_notification', notification); - }); - } - - plugins.fireHook('action:notification.pushed', { notification: notification, uids: uids }); + function (results, next) { + plugins.fireHook('action:notification.pushed', { + notification: notification, + uids: results.uidsToNotify, + uidsNotified: results.uidsToNotify, + uidsEmailed: results.uidsToEmail, + }); next(); }, ], callback); diff --git a/src/posts/queue.js b/src/posts/queue.js index 553fbb764e..6d8001f2f7 100644 --- a/src/posts/queue.js +++ b/src/posts/queue.js @@ -53,17 +53,25 @@ module.exports = function (Posts) { user.setUserField(data.uid, 'lastqueuetime', Date.now(), next); }, function (next) { - notifications.create({ - nid: 'post-queued-' + id, - mergeId: 'post-queue', - bodyShort: '[[notifications:post_awaiting_review]]', - bodyLong: data.content, - path: '/post-queue', + async.parallel({ + notification: function (next) { + notifications.create({ + type: 'post-queue', + nid: 'post-queue-' + id, + mergeId: 'post-queue', + bodyShort: '[[notifications:post_awaiting_review]]', + bodyLong: data.content, + path: '/post-queue', + }, next); + }, + cid: function (next) { + getCid(type, data, next); + }, }, next); }, - function (notification, next) { - if (notification) { - notifications.pushGroups(notification, ['administrators', 'Global Moderators'], next); + function (results, next) { + if (results.notification) { + notifications.pushGroups(results.notification, ['administrators', 'Global Moderators', 'cid:' + results.cid + ':privileges:moderate'], next); } else { next(); } @@ -79,20 +87,26 @@ module.exports = function (Posts) { ], callback); }; + function getCid(type, data, callback) { + if (type === 'topic') { + return setImmediate(callback, null, data.cid); + } else if (type === 'reply') { + topics.getTopicField(data.tid, 'cid', callback); + } else { + return setImmediate(callback, null, null); + } + } + function canPost(type, data, callback) { async.waterfall([ function (next) { - if (type === 'topic') { - next(null, data.cid); - } else if (type === 'reply') { - topics.getTopicField(data.tid, 'cid', next); - } + getCid(type, data, next); }, function (cid, next) { async.parallel({ canPost: function (next) { if (type === 'topic') { - privileges.categories.can('topics:create', data.cid, data.uid, next); + privileges.categories.can('topics:create', cid, data.uid, next); } else if (type === 'reply') { privileges.categories.can('topics:reply', cid, data.uid, next); } diff --git a/src/topics/follow.js b/src/topics/follow.js index f1bad3ccf3..cf8754bcc5 100644 --- a/src/topics/follow.js +++ b/src/topics/follow.js @@ -2,15 +2,11 @@ 'use strict'; var async = require('async'); -var winston = require('winston'); var db = require('../database'); -var user = require('../user'); var posts = require('../posts'); var notifications = require('../notifications'); var privileges = require('../privileges'); -var meta = require('../meta'); -var emailer = require('../emailer'); var plugins = require('../plugins'); var utils = require('../utils'); @@ -239,36 +235,6 @@ module.exports = function (Topics) { notifications.push(notification, followers); } - if (parseInt(meta.config.disableEmailSubscriptions, 10) === 1) { - return next(); - } - - async.eachLimit(followers, 3, function (toUid, next) { - async.parallel({ - userData: async.apply(user.getUserFields, toUid, ['username', 'userslug']), - userSettings: async.apply(user.getSettings, toUid), - }, function (err, data) { - if (err) { - return next(err); - } - - if (data.userSettings.sendPostNotifications) { - emailer.send('notif_post', toUid, { - pid: postData.pid, - subject: '[' + (meta.config.title || 'NodeBB') + '] ' + title, - intro: '[[notifications:user_posted_to, ' + postData.user.username + ', ' + titleEscaped + ']]', - postBody: postData.content.replace(/"\/\//g, '"https://'), - username: data.userData.username, - userslug: data.userData.userslug, - topicSlug: postData.topic.slug, - showUnsubscribe: true, - }, next); - } else { - winston.debug('[topics.notifyFollowers] uid ' + toUid + ' does not have post notifications enabled, skipping.'); - next(); - } - }); - }); next(); }, ], callback); diff --git a/src/upgrades/1.7.1/notification-settings.js b/src/upgrades/1.7.1/notification-settings.js new file mode 100644 index 0000000000..df6fe27404 --- /dev/null +++ b/src/upgrades/1.7.1/notification-settings.js @@ -0,0 +1,48 @@ +'use strict'; + +var async = require('async'); +var batch = require('../../batch'); +var db = require('../../database'); + +module.exports = { + name: 'Convert old notification digest settings', + timestamp: Date.UTC(2017, 10, 15), + method: function (callback) { + var progress = this.progress; + + batch.processSortedSet('users:joindate', function (uids, next) { + async.eachLimit(uids, 500, function (uid, next) { + progress.incr(); + async.waterfall([ + function (next) { + db.getObjectFields('user:' + uid + ':settings', ['sendChatNotifications', 'sendPostNotifications'], next); + }, + function (userSettings, _next) { + if (!userSettings) { + return next(); + } + var tasks = []; + if (parseInt(userSettings.sendChatNotifications, 10) === 1) { + tasks.push(async.apply(db.setObjectField, 'user:' + uid + ':settings', 'notificationType_new-chat', 'notificationemail')); + } + if (parseInt(userSettings.sendPostNotifications, 10) === 1) { + tasks.push(async.apply(db.setObjectField, 'user:' + uid + ':settings', 'notificationType_new-reply', 'notificationemail')); + } + if (!tasks.length) { + return next(); + } + + async.series(tasks, function (err) { + _next(err); + }); + }, + function (next) { + db.deleteObjectFields('user:' + uid + ':settings', ['sendChatNotifications', 'sendPostNotifications'], next); + }, + ], next); + }, next); + }, { + progress: progress, + }, callback); + }, +}; diff --git a/src/user.js b/src/user.js index 3275e25db3..2ad441c5ce 100644 --- a/src/user.js +++ b/src/user.js @@ -208,13 +208,17 @@ User.isGlobalModerator = function (uid, callback) { privileges.users.isGlobalModerator(uid, callback); }; +User.getPrivileges = function (uid, callback) { + async.parallel({ + isAdmin: async.apply(User.isAdministrator, uid), + isGlobalModerator: async.apply(User.isGlobalModerator, uid), + isModeratorOfAnyCategory: async.apply(User.isModeratorOfAnyCategory, uid), + }, callback); +}; + User.isPrivileged = function (uid, callback) { - async.parallel([ - async.apply(User.isAdministrator, uid), - async.apply(User.isGlobalModerator, uid), - async.apply(User.isModeratorOfAnyCategory, uid), - ], function (err, results) { - callback(err, results ? results.some(Boolean) : false); + User.getPrivileges(uid, function (err, results) { + callback(err, results ? (results.isAdmin || results.isGlobalModerator || results.isModeratorOfAnyCategory) : false); }); }; diff --git a/src/user/approval.js b/src/user/approval.js index 96c25d8ad5..710b66930a 100644 --- a/src/user/approval.js +++ b/src/user/approval.js @@ -49,6 +49,7 @@ module.exports = function (User) { async.waterfall([ function (next) { notifications.create({ + type: 'new-register', bodyShort: '[[notifications:new_register, ' + username + ']]', nid: 'new_register:' + username, path: '/admin/manage/registration', diff --git a/src/user/settings.js b/src/user/settings.js index f713b113d8..784213e603 100644 --- a/src/user/settings.js +++ b/src/user/settings.js @@ -131,6 +131,12 @@ module.exports = function (User) { notificationSound: data.notificationSound, incomingChatSound: data.incomingChatSound, outgoingChatSound: data.outgoingChatSound, + notificationType_upvote: data.notificationType_upvote, + 'notificationType_new-topic': data['notificationType_new-topic'], + 'notificationType_new-reply': data['notificationType_new-reply'], + notificationType_follow: data.notificationType_follow, + 'notificationType_new-chat': data['notificationType_new-chat'], + 'notificationType_group-invite': data['notificationType_group-invite'], }; if (data.bootswatchSkin) { diff --git a/src/views/emails/notification.tpl b/src/views/emails/notification.tpl new file mode 100644 index 0000000000..b0f79b042c --- /dev/null +++ b/src/views/emails/notification.tpl @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + +
+

{intro}

+
+ {body} +
+ + + + + +
+ +     [[email:notif.cta]]     + +
+ +
+

[[email:closing]]

+

{site_title}

+
+
+ + +