const moment = require('moment-timezone'); const semver = require('semver'); const Promise = require('bluebird'); const _ = require('lodash'); const settingsCache = require('../../services/settings/cache'); const ghostVersion = require('../../lib/ghost-version'); const {i18n} = require('../../lib/common'); const errors = require('@tryghost/errors'); const ObjectId = require('bson-objectid'); const api = require('./index'); const internalContext = {context: {internal: true}}; const _private = {}; _private.fetchAllNotifications = () => { let allNotifications = settingsCache.get('notifications'); allNotifications.forEach((notification) => { notification.addedAt = moment(notification.addedAt).toDate(); }); return allNotifications; }; _private.wasSeen = (notification, user) => { if (notification.seenBy === undefined) { return notification.seen; } else { return notification.seenBy.includes(user.id); } }; module.exports = { docName: 'notifications', browse: { permissions: true, query(frame) { let allNotifications = _private.fetchAllNotifications(); allNotifications = _.orderBy(allNotifications, 'addedAt', 'desc'); allNotifications = allNotifications.filter((notification) => { // NOTE: Filtering by version below is just a patch for bigger problem - notifications are not removed // after Ghost update. Logic below should be removed when Ghost upgrade detection // is done (https://github.com/TryGhost/Ghost/issues/10236) and notifications are // be removed permanently on upgrade event. const ghost20RegEx = /Ghost 2.0 is now available/gi; // CASE: do not return old release notification if (notification.message && (!notification.custom || notification.message.match(ghost20RegEx))) { let notificationVersion = notification.message.match(/(\d+\.)(\d+\.)(\d+)/); if (notification.message.match(ghost20RegEx)) { notificationVersion = '2.0.0'; } else if (notificationVersion){ notificationVersion = notificationVersion[0]; } const blogVersion = ghostVersion.full.match(/^(\d+\.)(\d+\.)(\d+)/); if (notificationVersion && blogVersion && semver.gt(notificationVersion, blogVersion[0])) { return true; } else { return false; } } return !_private.wasSeen(notification, frame.user); }); return allNotifications; } }, add: { statusCode(result) { if (result.notifications.length) { return 201; } else { return 200; } }, permissions: true, query(frame) { const defaults = { dismissible: true, location: 'bottom', status: 'alert', id: ObjectId.generate() }; const overrides = { seen: false, addedAt: moment().toDate() }; let notificationsToCheck = frame.data.notifications; let notificationsToAdd = []; const allNotifications = _private.fetchAllNotifications(); notificationsToCheck.forEach((notification) => { const isDuplicate = allNotifications.find((n) => { return n.id === notification.id; }); if (!isDuplicate) { notificationsToAdd.push(Object.assign({}, defaults, notification, overrides)); } }); const hasReleaseNotification = notificationsToCheck.find((notification) => { return !notification.custom; }); // CASE: remove any existing release notifications if a new release notification comes in if (hasReleaseNotification) { _.remove(allNotifications, (el) => { return !el.custom; }); } // CASE: nothing to add, skip if (!notificationsToAdd.length) { return Promise.resolve(); } const releaseNotificationsToAdd = notificationsToAdd.filter((notification) => { return !notification.custom; }); // CASE: reorder notifications before save if (releaseNotificationsToAdd.length > 1) { notificationsToAdd = notificationsToAdd.filter((notification) => { return notification.custom; }); notificationsToAdd.push(_.orderBy(releaseNotificationsToAdd, 'created_at', 'desc')[0]); } return api.settings.edit({ settings: [{ key: 'notifications', // @NOTE: We always need to store all notifications! value: allNotifications.concat(notificationsToAdd) }] }, internalContext).then(() => { return notificationsToAdd; }); } }, destroy: { statusCode: 204, options: ['notification_id'], validation: { options: { notification_id: { required: true } } }, permissions: true, query(frame) { const allNotifications = _private.fetchAllNotifications(); const notificationToMarkAsSeen = allNotifications.find((notification) => { return notification.id === frame.options.notification_id; }); const notificationToMarkAsSeenIndex = allNotifications.findIndex((notification) => { return notification.id === frame.options.notification_id; }); if (notificationToMarkAsSeenIndex > -1 && !notificationToMarkAsSeen.dismissible) { return Promise.reject(new errors.NoPermissionError({ message: i18n.t('errors.api.notifications.noPermissionToDismissNotif') })); } if (notificationToMarkAsSeenIndex < 0) { return Promise.reject(new errors.NotFoundError({ message: i18n.t('errors.api.notifications.notificationDoesNotExist') })); } if (_private.wasSeen(notificationToMarkAsSeen, frame.user)) { return Promise.resolve(); } // @NOTE: We don't remove the notifications, because otherwise we will receive them again from the service. allNotifications[notificationToMarkAsSeenIndex].seen = true; if (!allNotifications[notificationToMarkAsSeenIndex].seenBy) { allNotifications[notificationToMarkAsSeenIndex].seenBy = []; } allNotifications[notificationToMarkAsSeenIndex].seenBy.push(frame.user.id); return api.settings.edit({ settings: [{ key: 'notifications', value: allNotifications }] }, internalContext).return(); } }, /** * Clears all notifications. Method used in tests only * * @private Not exposed over HTTP */ destroyAll: { statusCode: 204, permissions: { method: 'destroy' }, query() { const allNotifications = _private.fetchAllNotifications(); allNotifications.forEach((notification) => { // @NOTE: We don't remove the notifications, because otherwise we will receive them again from the service. notification.seen = true; }); return api.settings.edit({ settings: [{ key: 'notifications', value: allNotifications }] }, internalContext).return(); } } };