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
v1.18.x
Barış Soner Uşaklı 7 years ago committed by GitHub
parent e68e5122e2
commit dd176dd5f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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"
}

@ -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 :(",

@ -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([

@ -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,

@ -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',

@ -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);
}
});
}

@ -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);

@ -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);
}

@ -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);

@ -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);
},
};

@ -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);
});
};

@ -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',

@ -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) {

@ -0,0 +1,57 @@
<!-- IMPORT emails/partials/header.tpl -->
<!-- Email Body : BEGIN -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="100%" style="max-width: 600px;">
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td bgcolor="#efeff0" style="text-align: center; background-image: url({url}/assets/images/emails/triangularbackground.png); background-size: cover; background-repeat: no-repeat;">
<img src="{url}/assets/images/emails/unreadpost.png" width="300" height="300" border="0" align="center" style="width: 300px; height: 300px; max-width: 300px; height: auto; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;" class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td bgcolor="#efeff0">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 40px 40px 0px 40px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;">
<p style="margin: 0 0 20px 0;">{intro}</p>
</td>
</tr>
<tr>
<td style="padding: 0px 60px 40px 60px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;">
{body}
</td>
</tr>
<tr>
<td style="padding: 0 40px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;">
<!-- Button : BEGIN -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin: auto;">
<tr>
<td style="border-radius: 3px; background: #222222; text-align: center;" class="button-td">
<a href="{url}{path}" style="background: #222222; border: 15px solid #222222; font-family: sans-serif; font-size: 13px; line-height: 1.1; text-align: center; text-decoration: none; display: block; border-radius: 3px; font-weight: bold;" class="button-a">
<span style="color:#ffffff;" class="button-link">&nbsp;&nbsp;&nbsp;&nbsp;[[email:notif.cta]]&nbsp;&nbsp;&nbsp;&nbsp;</span>
</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
<tr>
<td style="padding: 40px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;">
<h2 style="margin: 0 0 10px 0; font-family: sans-serif; font-size: 18px; line-height: 21px; color: #333333; font-weight: bold;">[[email:closing]]</h2>
<p style="margin: 0;">{site_title}</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
</table>
<!-- Email Body : END -->
<!-- IMPORT emails/partials/footer.tpl -->
Loading…
Cancel
Save