Files
talk/plugins/talk-plugin-notifications/server/messages.js
T
2018-06-05 04:28:54 +02:00

249 lines
7.2 KiB
JavaScript

const { map, get, find, groupBy, property } = require('lodash');
const uuid = require('uuid/v4');
const { UNSUBSCRIBE_SUBJECT } = require('./config');
const { getOrganizationName, getNotificationBody } = require('./util');
const { checkDigests } = require('./digests');
// processNewNotifications will handle notifications that are collected after an
// event hook. These notifications will be batched by user and optionally
// queued for digesting or sent immediately depending on the user's settings.
const processNewNotifications = async (ctx, notifications) =>
Promise.all(
map(
// Group all the notifications so we don't have to redo the digest check
// multiple times for the same user.
groupBy(notifications, 'notification.userID'),
async (notifications, userID) => {
// Check to see if the user has digesting enabled.
const hasDigesting = await checkDigests(ctx, userID);
if (hasDigesting) {
// User has digesting enabled, queue the notifications to be sent
// at a later time.
return queueNotifications(ctx, userID, notifications);
}
// User does not have digesting enabled, send the messages the old
// way.
return sendNotificationsBatch(ctx, notifications);
}
)
);
// queueNotifications will queue the notifications onto the User.
const queueNotifications = async (ctx, userID, notifications) => {
// Mutate the notification payloads to what we can store in Mongo safely.
const digests = notifications.map(
({ notification, handler: { category } }) => ({ notification, category })
);
// Pull out some useful tools.
const {
connectors: {
models: { User },
},
} = ctx;
ctx.log.info(
{ notifications: notifications.length, userID },
'now queueing notifications for digesting'
);
// Push the digests into Mongo.
await User.update(
{ id: userID },
{ $push: { 'metadata.notifications.digests': { $each: digests } } }
);
};
// sendNotificationBatch will send a given set of notifications for several
// users that do not have digesting enabled.
const sendNotificationsBatch = async (ctx, notifications) => {
// Get the notification name for the subject.
const organizationName = await getOrganizationName(ctx);
if (!organizationName) {
ctx.log.error(
'could not send the notification, organization name not in settings'
);
return;
}
return Promise.all(
map(
notifications,
async ({ handler, notification: { userID, context } }) => {
const {
connectors: {
services: {
I18n: { t },
},
},
} = ctx;
const { category } = handler;
// Compose the subject for the email.
const subject = t(
`talk-plugin-notifications.categories.${category}.subject`,
organizationName
);
// Load the content into the comment.
const body = await getNotificationBody(ctx, handler, context);
// Send the email now.
return sendNotification(ctx, userID, subject, body);
}
)
);
};
// sendNotification will send the notification to the specified user with the
// given context.
const sendNotification = async (
ctx,
userID,
subject,
body,
template = 'notification'
) => {
const {
connectors: {
secrets: { jwt },
config: { JWT_ISSUER, JWT_AUDIENCE },
services: { Mailer },
},
} = ctx;
try {
const organizationName = await getOrganizationName(ctx);
if (!organizationName) {
ctx.log.error(
'could not send the notification, organization name not in settings'
);
return;
}
// unsubscribeToken is the token used to perform the one-click
// unsubscribe.
const unsubscribeToken = jwt.sign({
jti: uuid(),
iss: JWT_ISSUER,
aud: JWT_AUDIENCE,
sub: UNSUBSCRIBE_SUBJECT,
user: userID,
});
// Send the notification to the user.
const task = await Mailer.send({
template,
locals: { body, organizationName, unsubscribeToken },
subject,
user: userID,
});
ctx.log.info({ jobID: task.id }, 'sent the notification');
} catch (err) {
ctx.log.error(
{ err, message: err.message },
'could not send the notification, an error occurred'
);
return;
}
};
// filterSuperseded will filter all the possible notifications and only send
// those notifications that are not superseded by another type of notification.
const filterSuperseded = (
{ handler: { category }, notification: { userID: destinationUserID } },
index,
notifications
) =>
!notifications.some(
({
handler: { supersedesCategories = [] },
notification: { userID: notificationUserID },
}) =>
// Only allow notifications to supersede another notification if that
// notification is also destined for the same user.
notificationUserID === destinationUserID &&
// If another notification that is destined for the same user also exists
// and declares that it supersedes this one, return true so we can filter
// this one from the list.
supersedesCategories.some(
supersededCategory => supersededCategory === category
)
);
const USER_CONFIRMATION_QUERY = `
query CheckUserConfirmation($userID: ID!) {
user(id: $userID) {
profiles {
provider
... on LocalUserProfile {
confirmedAt
}
}
}
}
`;
// filterVerifiedNotification checks to see if a user has a verified email
// address, and if they do, returns the notification payload again, otherwise,
// returns undefined.
const filterVerifiedNotification = ctx => async notification => {
// Grab the user that we're supposed to be sending the notification to.
const {
notification: { userID },
} = notification;
// Check their confirmed status. This should have already been hit by the
// loaders, so we shouldn't make any more database requests.
const { errors, data } = await ctx.graphql(USER_CONFIRMATION_QUERY, {
userID,
});
if (errors) {
ctx.log.error(
{ err: errors },
'could not query for user confirmation status'
);
return;
}
// Get the first local profile from the user.
const profile = find(get(data, 'user.profiles', []), ['provider', 'local']);
if (!profile) {
ctx.log.warn({ user_id: userID }, 'user did not have a local profile');
return;
}
// Pull out the confirmed status from the profile.
const confirmed = get(profile, 'confirmedAt', null) !== null;
if (!confirmed) {
ctx.log.info(
{ user_id: userID },
'user did not have their local profile confirmed, but had settings enabled, not mailing'
);
return;
}
return notification;
};
// filterVerified performs filtering in a complicated way because we can't use
// Promise.all on a Array.prototype.filter call.
const filterVerified = async (ctx, notifications) => {
notifications = await Promise.all(
notifications.map(filterVerifiedNotification(ctx))
);
// This acts as a poor-mans identity filter to remove all falsy values.
return notifications.filter(property('notification'));
};
module.exports = {
processNewNotifications,
filterSuperseded,
filterVerified,
getNotificationBody,
sendNotification,
};