apostrophe
Version:
The Apostrophe Content Management System.
304 lines (281 loc) • 11.1 kB
JavaScript
// This module provides a framework for triggering notifications
// within the Apostrophe admin UI. Notifications may be triggered
// either on the browser or the server side, via `apos.notice`.
//
// ## Options
//
// ### `queryInterval`: interval in milliseconds between MongoDB
// queries while long polling for notifications. Defaults to 1000
// (1 second). Set it longer if you prefer fewer queries, however
// these are indexed queries on a small amount of information and
// should not significantly impact your app.
//
// ### `longPollingTimeout`: maximum lifetime in milliseconds of a long
// polling HTTP request before a response with no notifications is sent.
// Defaults to 10000 (10 seconds) to avoid typical proxy server timeouts.
// Until it times out the request will keep making MongoDB queries to
// see if any new notifications are available (long polling).
var _ = require('@sailshq/lodash');
var Promise = require('bluebird');
module.exports = {
extend: 'apostrophe-module',
construct: function(self, options) {
self.pushAssets = function() {
self.pushAsset('script', 'user', { when: 'user' });
self.pushAsset('stylesheet', 'user', { when: 'user' });
};
// Call with `req`, then a message, followed by any interpolated strings
// which must correspond to %s placeholders in `message` (variable number
// of arguments), followed by an `options` object if desired.
//
// If you do not have a `req` it is acceptable to pass a user `_id` string
// in place of `req`. Someone must be the recipient.
//
// `options.type` styles the notification and may be set to `error`,
// `warn` or `success`. If not set, a "plain" default style is used.
//
// If `options.dismiss` is set to `true`, the message will auto-dismiss after 5 seconds.
// If it is set to a number of seconds, it will dismiss after that number of seconds.
// Otherwise it will not dismiss unless clicked.
//
// The message is internationalized, which is why the use of
// %s placeholders for any inserted titles, etc. is important.
//
// Throws an error if there is no `req.user`.
//
// This method is aliased as `apos.notify` for convenience.
//
// The method returns a promise, which you may await if you need
// to be absolutely certain the notification has been committed
// to the database, for instance before exiting a command line task.
// You may also pass a callback as a final argument.
self.trigger = function(req, message, options) {
var callback = arguments[arguments.length - 1];
if (typeof callback === 'function') {
return self.trigger.apply(self, Array.prototype.slice.call(arguments, 0, arguments.length - 1)).then(function() {
return callback(null);
}).catch(function(err) {
return callback(err);
});
}
if ((typeof req) === 'string') {
// String was passed, assume it is a user _id
req = {
user: {
_id: req
}
};
}
if (!req.user) {
throw 'forbidden';
}
var strings = [];
var i = 2;
var index = 0;
while (true) {
index = message.indexOf('%s', index);
if (index === -1) {
break;
}
// Don't match the same one over and over
index += 2;
if ((i >= arguments.length) || ((typeof (arguments[i]) === 'object'))) {
throw new Error('Bad notification call: number of %s placeholders does not match number of string arguments after message');
}
strings.push(arguments[i++]);
}
if ((i === (arguments.length - 1)) && (typeof (arguments[i]) === 'object')) {
options = arguments[i++];
} else {
options = {};
}
if (i !== arguments.length) {
throw new Error('Bad notification call: number of %s placeholders does not match number of string arguments after message');
}
var notification = {
_id: self.apos.utils.generateId(),
createdAt: new Date(),
userId: req.user._id,
message: message,
strings: strings
};
if (options.dismiss === true) {
options.dismiss = 5;
}
_.merge(notification, options);
return self.db.insert(notification);
};
// Send a new notification for the user.
self.apiRoute('post', 'trigger', function(req, res, next) {
// Saving the session where we don't have to
// increases the risk of race conditions
self.apos.utils.readOnlySession(req);
var type = self.apos.launder.select(req.body.type, [ 'error', 'warn', 'success', 'info' ], 'info');
var message = self.apos.launder.string(req.body.message);
var strings = self.apos.launder.strings(req.body.strings);
var dismiss = self.apos.launder.integer(req.body.dismiss);
var pulse = self.apos.launder.boolean(req.body.pulse);
// TODO what is this one for?
var id = self.apos.launder.id(req.body.id);
try {
self.trigger.apply(self, [ req, message ].concat(strings).concat([ {
dismiss: dismiss,
type: type,
pulse: pulse,
id: id
} ]));
} catch (err) {
return next(err);
}
return next(null);
});
// Dismiss the notification indicated by `req.body._id`.
self.apiRoute('post', 'dismiss', function(req, res, next) {
// Saving the session where we don't have to
// increases the risk of race conditions
self.apos.utils.readOnlySession(req);
var _id = self.apos.launder.id(req.body._id);
return self.db.remove({
_id: _id
}).then(function() {
// Explicitly do not return the details from mongodb, just
// success
return next(null);
}).catch(next);
});
// This middleware is essentially a POST route at
// `/modules/apostrophe-global/poll-notifications`. It is implemented
// as middleware to allow it to run before `req.data.global` is loaded,
// which can be a very expensive operation on some sites and should
// thus not be required before a high-frequency polling operation.
//
// Poll for active notifications. Responds with:
//
// `{ status: 'ok', notifications: [ ... ], dismissed: [ id1... ] }`
//
// Each notification has an `html` property containing
// its rendered, localized markup, as well as `_id`, `createdAt`
// and `id` (if one was provided when it was triggered).
//
// The client must provide `req.body.displayingIds`,
// an array of notification `_id` properties it is already displaying.
// Without this, all notifications that have not been dismissed via the
// dismiss route are sent.
//
// If any of the ids in `displayingIds` have been recently dismissed,
// the response will include them in its `dismissed` property.
//
// Waits up to 10 seconds for new notifications (long polling),
// but then respond with an empty array to avoid proxy server timeouts.
//
// As usual POST is used to avoid unwanted caching of the response.
self.expressMiddleware = function(req, res, next) {
if (req.url !== (self.action + '/poll-notifications')) {
return next();
}
// Saving the session where we don't have to
// increases the risk of race conditions, especially
// in this very frequent route, resulting in the loss
// of workflow mode changes, workflow locale changes, etc.
self.apos.utils.readOnlySession(req);
if (!(req.user && req.user._id)) {
return self.apiResponder(req, 'invalid');
}
var start = Date.now();
var displayingIds = self.apos.launder.ids(req.body.displayingIds);
return attempt();
function attempt() {
if (Date.now() - start >= (self.options.longPollingTimeout || 10000)) {
return res.send({
status: 'ok',
notifications: [],
dismissed: []
});
}
return Promise.try(function() {
return self.find(req, { displayingIds: displayingIds });
}).then(function(result) {
var notifications = result.notifications;
var dismissed = result.dismissed;
if ((!notifications.length) && (!dismissed.length)) {
return Promise.delay(self.options.queryInterval || 1000).then(attempt);
}
_.each(notifications, function(notification) {
var args = [ notification.message ].concat(notification.strings);
var message = req.__.apply(req, args);
var params = _.clone(notification);
params.message = message;
notification.html = self.render(
req,
'notification',
params
);
});
return self.apiResponder(req, null, {
notifications: notifications,
dismissed: dismissed
});
});
}
};
// Resolves with an object with `notifications` and `dismissed`
// properties.
//
// Returns a promise if no callback is passed.
//
// If `options.displayingIds` is set, notifications
// whose `_id` properties appear in it are not returned.
self.find = function(req, options, callback) {
if (callback) {
return self.find(req, options).then(function(result) {
return callback(null, result);
}).catch(callback);
}
return self.db.find({
userId: req.user._id
}).sort({ createdAt: 1 }).toArray().then(function(notifications) {
return {
notifications: _.filter(notifications,
function(notification) {
return !_.includes(options.displayingIds || [], notification._id);
}
),
dismissed: _.difference(options.displayingIds || [], _.pluck(notifications, '_id'))
};
}).catch(function(err) {
if (self.apos.db.closed) {
// The database connection was intentionally closed,
// which often triggers a race condition with
// long polling requests. Send an empty response
return {
notifications: [],
dismissed: []
};
} else {
throw err;
}
});
};
self.ensureCollection = function(callback) {
self.db = self.apos.db.collection('aposNotifications');
return callback(null);
};
self.ensureIndexes = function(callback) {
return self.db.ensureIndex({ userId: 1, createdAt: 1 }, callback);
};
},
afterConstruct: function(self, callback) {
self.pushAssets();
self.pushCreateSingleton();
self.apos.notify = self.trigger;
return self.ensureCollection(function(err) {
if (err) {
return callback(err);
}
self.on('apostrophe:migrate', 'ensureIndexesPromisified', function() {
return require('bluebird').promisify(self.ensureIndexes)();
});
return callback(null);
});
}
};