You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
nodebb/src/notifications.js

606 lines
16 KiB
JavaScript

11 years ago
'use strict';
9 years ago
var async = require('async');
var winston = require('winston');
var cron = require('cron').CronJob;
var nconf = require('nconf');
var _ = require('lodash');
9 years ago
var db = require('./database');
var User = require('./user');
var groups = require('./groups');
var meta = require('./meta');
var batch = require('./batch');
9 years ago
var plugins = require('./plugins');
var utils = require('./utils');
var emailer = require('./emailer');
var Notifications = module.exports;
7 years ago
Notifications.baseTypes = [
'notificationType_upvote',
'notificationType_new-topic',
'notificationType_new-reply',
'notificationType_follow',
'notificationType_new-chat',
'notificationType_group-invite',
6 years ago
'notificationType_group-request-membership',
7 years ago
];
Notifications.privilegedTypes = [
'notificationType_new-register',
'notificationType_post-queue',
'notificationType_new-post-flag',
'notificationType_new-user-flag',
];
Notifications.getAllNotificationTypes = function (callback) {
async.waterfall([
function (next) {
plugins.fireHook('filter:user.notificationTypes', {
types: Notifications.baseTypes.slice(),
privilegedTypes: Notifications.privilegedTypes.slice(),
}, next);
},
function (results, next) {
next(null, results.types.concat(results.privilegedTypes));
},
], callback);
};
Notifications.startJobs = function () {
winston.verbose('[notifications.init] Registering jobs.');
new cron('*/30 * * * *', Notifications.prune, null, true);
};
Notifications.get = function (nid, callback) {
Notifications.getMultiple([nid], function (err, notifications) {
callback(err, Array.isArray(notifications) && notifications.length ? notifications[0] : null);
});
};
Notifications.getMultiple = function (nids, callback) {
if (!Array.isArray(nids) || !nids.length) {
return setImmediate(callback, null, []);
}
var keys = nids.map(function (nid) {
return 'notifications:' + nid;
});
var notifications;
async.waterfall([
function (next) {
db.getObjects(keys, next);
},
function (_notifications, next) {
notifications = _notifications;
var userKeys = notifications.map(function (notification) {
return notification && notification.from;
});
User.getUsersFields(userKeys, ['username', 'userslug', 'picture'], next);
},
function (usersData, next) {
notifications.forEach(function (notification, index) {
if (notification) {
notification.datetimeISO = utils.toISOString(notification.datetime);
if (notification.bodyLong) {
notification.bodyLong = utils.escapeHTML(notification.bodyLong);
}
9 years ago
notification.user = usersData[index];
if (notification.user) {
notification.image = notification.user.picture || null;
if (notification.user.username === '[[global:guest]]') {
9 years ago
notification.bodyShort = notification.bodyShort.replace(/([\s\S]*?),[\s\S]*?,([\s\S]*?)/, '$1, [[global:guest]], $2');
}
} else if (notification.image === 'brand:logo' || !notification.image) {
notification.image = meta.config['brand:logo'] || nconf.get('relative_path') + '/logo.png';
}
}
});
next(null, notifications);
},
], callback);
};
Notifications.filterExists = function (nids, callback) {
async.waterfall([
function (next) {
db.isSortedSetMembers('notifications', nids, next);
},
function (exists, next) {
nids = nids.filter(function (notifId, idx) {
9 years ago
return exists[idx];
});
next(null, nids);
},
], callback);
};
9 years ago
Notifications.findRelated = function (mergeIds, set, callback) {
// A related notification is one in a zset that has the same mergeId
var nids;
9 years ago
async.waterfall([
async.apply(db.getSortedSetRevRange, set, 0, -1),
function (_nids, next) {
nids = _nids;
9 years ago
var keys = nids.map(nid => 'notifications:' + nid);
db.getObjectsFields(keys, ['mergeId'], next);
},
function (sets, next) {
sets = sets.map(set => String(set.mergeId));
var mergeSet = new Set(mergeIds.map(id => String(id)));
next(null, nids.filter((nid, idx) => mergeSet.has(sets[idx])));
},
], callback);
};
9 years ago
Notifications.create = function (data, callback) {
if (!data.nid) {
return callback(new Error('[[error:no-notification-id]]'));
}
data.importance = data.importance || 5;
async.waterfall([
function (next) {
db.getObject('notifications:' + data.nid, next);
},
function (oldNotification, next) {
if (oldNotification) {
if (parseInt(oldNotification.pid, 10) === parseInt(data.pid, 10) && parseInt(oldNotification.importance, 10) > parseInt(data.importance, 10)) {
return callback(null, null);
}
}
var now = Date.now();
data.datetime = now;
async.parallel([
function (next) {
db.sortedSetAdd('notifications', now, data.nid, next);
},
function (next) {
db.setObject('notifications:' + data.nid, data, next);
},
], function (err) {
next(err, data);
});
},
], callback);
};
11 years ago
Notifications.push = function (notification, uids, callback) {
callback = callback || function () {};
if (!notification || !notification.nid) {
return callback();
}
if (!Array.isArray(uids)) {
uids = [uids];
}
uids = _.uniq(uids);
if (!uids.length) {
return callback();
}
setTimeout(function () {
batch.processArray(uids, function (uids, next) {
pushToUids(uids, notification, next);
}, { interval: 1000 }, function (err) {
if (err) {
winston.error(err.stack);
}
});
}, 1000);
callback();
};
function pushToUids(uids, notification, callback) {
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: utils.stripHTMLTags(notification.subject || '[[notifications:new_notification]]'),
intro: utils.stripHTMLTags(notification.bodyShort),
body: notification.bodyLong || '',
notification: notification,
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) {
// Remove uid from recipients list if they have blocked the user triggering the notification
7 years ago
User.blocks.filterUids(notification.from, uids, next);
},
7 years ago
function (uids, next) {
plugins.fireHook('filter:notification.push', { notification: notification, uids: uids }, next);
},
function (data, next) {
8 years ago
if (!data || !data.notification || !data.uids || !data.uids.length) {
return callback();
}
notification = data.notification;
if (notification.type) {
getUidsBySettings(data.uids, next);
} else {
next(null, { uidsToNotify: data.uids, uidsToEmail: [] });
}
},
function (results, next) {
async.parallel([
function (next) {
sendNotification(results.uidsToNotify, next);
},
function (next) {
sendEmail(results.uidsToEmail, next);
},
], function (err) {
next(err, results);
});
},
function (results, next) {
plugins.fireHook('action:notification.pushed', {
notification: notification,
uids: results.uidsToNotify,
uidsNotified: results.uidsToNotify,
uidsEmailed: results.uidsToEmail,
});
next();
},
], callback);
}
Notifications.pushGroup = function (notification, groupName, callback) {
callback = callback || function () {};
async.waterfall([
function (next) {
groups.getMembers(groupName, 0, -1, next);
},
function (members, next) {
Notifications.push(notification, members, next);
},
], callback);
};
Notifications.pushGroups = function (notification, groupNames, callback) {
callback = callback || function () {};
async.waterfall([
function (next) {
groups.getMembersOfGroups(groupNames, next);
},
function (groupMembers, next) {
var members = _.uniq(_.flatten(groupMembers));
Notifications.push(notification, members, next);
},
], callback);
};
Notifications.rescind = function (nid, callback) {
callback = callback || function () {};
async.parallel([
async.apply(db.sortedSetRemove, 'notifications', nid),
async.apply(db.delete, 'notifications:' + nid),
], function (err) {
callback(err);
});
};
Notifications.markRead = function (nid, uid, callback) {
callback = callback || function () {};
if (parseInt(uid, 10) <= 0 || !nid) {
return setImmediate(callback);
}
Notifications.markReadMultiple([nid], uid, callback);
};
9 years ago
Notifications.markUnread = function (nid, uid, callback) {
callback = callback || function () {};
if (parseInt(uid, 10) <= 0 || !nid) {
return setImmediate(callback);
}
async.waterfall([
function (next) {
db.getObject('notifications:' + nid, next);
},
function (notification, next) {
if (!notification) {
return callback(new Error('[[error:no-notification]]'));
}
notification.datetime = notification.datetime || Date.now();
async.parallel([
async.apply(db.sortedSetRemove, 'uid:' + uid + ':notifications:read', nid),
async.apply(db.sortedSetAdd, 'uid:' + uid + ':notifications:unread', notification.datetime, nid),
], next);
},
8 years ago
], function (err) {
callback(err);
});
};
Notifications.markReadMultiple = function (nids, uid, callback) {
callback = callback || function () {};
nids = nids.filter(Boolean);
if (!Array.isArray(nids) || !nids.length) {
return callback();
}
11 years ago
var notificationKeys = nids.map(function (nid) {
return 'notifications:' + nid;
});
async.waterfall([
async.apply(db.getObjectsFields, notificationKeys, ['mergeId']),
function (mergeIds, next) {
// Isolate mergeIds and find related notifications
mergeIds = mergeIds.map(function (set) {
return set.mergeId;
}).reduce(function (memo, mergeId, idx, arr) {
if (mergeId && idx === arr.indexOf(mergeId)) {
memo.push(mergeId);
}
return memo;
}, []);
Notifications.findRelated(mergeIds, 'uid:' + uid + ':notifications:unread', next);
},
function (relatedNids, next) {
notificationKeys = _.union(nids, relatedNids).map(function (nid) {
return 'notifications:' + nid;
});
db.getObjectsFields(notificationKeys, ['nid', 'datetime'], next);
},
8 years ago
function (notificationData, next) {
// Filter out notifications that didn't exist
notificationData = notificationData.filter(function (notification) {
return notification && notification.nid;
});
8 years ago
// Extract nid
nids = notificationData.map(function (notification) {
return notification.nid;
});
8 years ago
var datetimes = notificationData.map(function (notification) {
return (notification && notification.datetime) || Date.now();
});
8 years ago
async.parallel([
function (next) {
db.sortedSetRemove('uid:' + uid + ':notifications:unread', nids, next);
},
function (next) {
db.sortedSetAdd('uid:' + uid + ':notifications:read', datetimes, nids, next);
},
], next);
},
], function (err) {
callback(err);
});
};
Notifications.markAllRead = function (uid, callback) {
8 years ago
async.waterfall([
function (next) {
db.getSortedSetRevRange('uid:' + uid + ':notifications:unread', 0, 99, next);
},
function (nids, next) {
Notifications.markReadMultiple(nids, uid, next);
},
], callback);
};
8 years ago
Notifications.prune = function (callback) {
callback = callback || function () {};
var week = 604800000;
var cutoffTime = Date.now() - week;
8 years ago
async.waterfall([
function (next) {
db.getSortedSetRangeByScore('notifications', 0, 500, '-inf', cutoffTime, next);
},
function (nids, next) {
if (!nids.length) {
8 years ago
return callback();
}
var keys = nids.map(function (nid) {
8 years ago
return 'notifications:' + nid;
});
11 years ago
8 years ago
async.parallel([
function (next) {
db.sortedSetRemove('notifications', nids, next);
},
function (next) {
db.deleteAll(keys, next);
},
], next);
},
], function (err) {
if (err) {
winston.error('Encountered error pruning notifications', err);
8 years ago
}
callback(err);
});
};
Notifications.merge = function (notifications, callback) {
// When passed a set of notification objects, merge any that can be merged
var mergeIds = [
'notifications:upvoted_your_post_in',
'notifications:user_started_following_you',
'notifications:user_posted_to',
'notifications:user_flagged_post_in',
'notifications:user_flagged_user',
'new_register',
'post-queue',
];
var isolated;
var differentiators;
var differentiator;
var modifyIndex;
var set;
notifications = mergeIds.reduce(function (notifications, mergeId) {
isolated = notifications.filter(function (notifObj) {
if (!notifObj || !notifObj.hasOwnProperty('mergeId')) {
return false;
}
11 years ago
return notifObj.mergeId.split('|')[0] === mergeId;
11 years ago
});
11 years ago
if (isolated.length <= 1) {
return notifications; // Nothing to merge
}
// Each isolated mergeId may have multiple differentiators, so process each separately
differentiators = isolated.reduce(function (cur, next) {
differentiator = next.mergeId.split('|')[1] || 0;
if (!cur.includes(differentiator)) {
cur.push(differentiator);
}
11 years ago
return cur;
}, []);
9 years ago
differentiators.forEach(function (differentiator) {
if (differentiator === 0 && differentiators.length === 1) {
set = isolated;
} else {
set = isolated.filter(function (notifObj) {
return notifObj.mergeId === (mergeId + '|' + differentiator);
});
9 years ago
}
modifyIndex = notifications.indexOf(set[0]);
if (modifyIndex === -1 || set.length === 1) {
return notifications;
}
switch (mergeId) {
// intentional fall-through
case 'notifications:upvoted_your_post_in':
case 'notifications:user_started_following_you':
case 'notifications:user_posted_to':
case 'notifications:user_flagged_post_in':
case 'notifications:user_flagged_user':
var usernames = _.uniq(set.map(function (notifObj) {
return notifObj && notifObj.user && notifObj.user.username;
}));
var numUsers = usernames.length;
var title = utils.decodeHTMLEntities(notifications[modifyIndex].topicTitle || '');
var titleEscaped = title.replace(/%/g, '&#37;').replace(/,/g, '&#44;');
titleEscaped = titleEscaped ? (', ' + titleEscaped) : '';
9 years ago
if (numUsers === 2) {
notifications[modifyIndex].bodyShort = '[[' + mergeId + '_dual, ' + usernames.join(', ') + titleEscaped + ']]';
} else if (numUsers > 2) {
notifications[modifyIndex].bodyShort = '[[' + mergeId + '_multiple, ' + usernames[0] + ', ' + (numUsers - 1) + titleEscaped + ']]';
9 years ago
}
9 years ago
notifications[modifyIndex].path = set[set.length - 1].path;
break;
9 years ago
case 'new_register':
notifications[modifyIndex].bodyShort = '[[notifications:' + mergeId + '_multiple, ' + set.length + ']]';
break;
}
9 years ago
// Filter out duplicates
notifications = notifications.filter(function (notifObj, idx) {
if (!notifObj || !notifObj.mergeId) {
return true;
9 years ago
}
9 years ago
return !(notifObj.mergeId === (mergeId + (differentiator ? '|' + differentiator : '')) && idx !== modifyIndex);
9 years ago
});
});
return notifications;
}, notifications);
plugins.fireHook('filter:notifications.merge', {
notifications: notifications,
}, function (err, data) {
callback(err, data.notifications);
});
};
Notifications.async = require('./promisify')(Notifications);