Merge branch 'master' into ignore-user-blank

This commit is contained in:
Kim Gardner
2018-03-08 14:12:23 -05:00
committed by GitHub
44 changed files with 953 additions and 240 deletions
+2
View File
@@ -43,6 +43,8 @@ plugins/*
!plugins/talk-plugin-notifications-category-featured
!plugins/talk-plugin-notifications-category-reply
!plugins/talk-plugin-notifications-category-staff
!plugins/talk-plugin-notifications-digest-daily
!plugins/talk-plugin-notifications-digest-hourly
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-profile-settings
@@ -130,3 +130,12 @@ th.header:nth-child(2), th.header:nth-child(3) {
.loadMore {
margin-top: 24px;
}
.roleDropdown {
width: 150px;
}
.roleOption {
min-width: 100px;
}
@@ -200,24 +200,31 @@ class People extends React.Component {
</td>
<td className="mdl-data-table__cell--non-numeric">
<Dropdown
containerClassName="talk-admin-community-people-dd-role"
className={cn(
'talk-admin-community-people-dd-role',
styles.roleDropdown
)}
value={user.role}
placeholder={t('community.role')}
onChange={role => setUserRole(user.id, role)}
>
<Option
className={styles.roleOption}
value={COMMENTER}
label={t('community.commenter')}
/>
<Option
className={styles.roleOption}
value={STAFF}
label={t('community.staff')}
/>
<Option
className={styles.roleOption}
value={MODERATOR}
label={t('community.moderator')}
/>
<Option
className={styles.roleOption}
value={ADMIN}
label={t('community.admin')}
/>
@@ -89,3 +89,12 @@
display: inline-block;
}
}
.statusDropdown {
width: 150px;
}
.statusDropdownOption {
min-width: 100px;
}
@@ -22,11 +22,20 @@ class Stories extends Component {
const closed = !!(closedAt && new Date(closedAt).getTime() < Date.now());
return (
<Dropdown
className={styles.statusDropdown}
value={closed}
onChange={value => this.props.onStatusChange(value, id)}
>
<Option value={false} label={t('streams.open')} />
<Option value={true} label={t('streams.closed')} />
<Option
value={false}
label={t('streams.open')}
className={styles.statusDropdownOption}
/>
<Option
value={true}
label={t('streams.closed')}
className={styles.statusDropdownOption}
/>
</Dropdown>
);
};
@@ -10,6 +10,7 @@ class TalkProvider extends React.Component {
plugins: this.props.plugins,
rest: this.props.rest,
graphql: this.props.graphql,
introspection: this.props.introspection,
notification: this.props.notification,
localStorage: this.props.localStorage,
sessionStorage: this.props.sessionStorage,
@@ -29,6 +30,7 @@ class TalkProvider extends React.Component {
TalkProvider.childContextTypes = {
pym: PropTypes.object,
introspection: PropTypes.object,
eventEmitter: PropTypes.object,
plugins: PropTypes.object,
rest: PropTypes.func,
+1
View File
@@ -11,6 +11,7 @@ export { default as withSignUp } from './withSignUp';
export { default as withForgotPassword } from './withForgotPassword';
export { default as withSetUsername } from './withSetUsername';
export { default as withPopupAuthHandler } from './withPopupAuthHandler';
export { default as withEnumValues } from './withEnumValues';
export {
default as withResendEmailConfirmation,
} from './withResendEmailConfirmation';
@@ -0,0 +1,24 @@
import React from 'react';
import hoistStatics from 'recompose/hoistStatics';
import PropTypes from 'prop-types';
/**
* WithEnumValues inject given property name for given enum.
*/
export default (enumName, propName) =>
hoistStatics(WrappedComponent => {
class WithEnumValues extends React.Component {
static contextTypes = {
introspection: PropTypes.object,
};
render() {
const inject = {
[propName]: this.context.introspection.getEnumValues(enumName),
};
return <WrappedComponent {...this.props} {...inject} />;
}
}
return WithEnumValues;
});
@@ -19,6 +19,16 @@ class Introspection {
isValidEnumValue(name, value) {
return this._enums[name] && this._enums[name].indexOf(value) >= 0;
}
/**
* getEnumValues returns array of possible values.
* @param {string} name
* @param {string} value
* @return {array}
*/
getEnumValues(name) {
return this._enums[name];
}
}
/**
+10 -5
View File
@@ -1,20 +1,27 @@
.dropdown {
position: relative;
width: 150px;
height: 34px;
background: #2c2c2c;
box-sizing: border-box;
color: white;
border-radius: 3px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
line-height: 20px;
font-size: 0.98em;
border-radius: 3px;
cursor: pointer;
&.disabled {
color: #e5e5e5;
background: #888;
cursor: default;
pointer-events: none;
}
}
.toggle {
padding: 8px 15px;
cursor: pointer;
padding: 8px 45px 8px 15px;
outline: none;
&:focus {
@@ -27,7 +34,6 @@
}
.label {
text-transform: capitalize;
}
.list {
@@ -38,7 +44,6 @@
border-radius: 2px;
background: white;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
width: 100%;
list-style: none;
padding: 0;
margin: 0;
+24 -2
View File
@@ -13,6 +13,12 @@ class Dropdown extends React.Component {
isOpen: false,
};
componentWillReceiveProps(nextProps) {
if (this.state.isOpen && nextProps.disabled) {
this.toggle();
}
}
componentDidUpdate(_, prevState) {
if (!this.state.isOpen && prevState.isOpen) {
// Refocus on the toggle element when menu closes.
@@ -69,6 +75,10 @@ class Dropdown extends React.Component {
};
toggle = () => {
if (this.props.disabled) {
return false;
}
this.setState({
isOpen: !this.state.isOpen,
});
@@ -135,11 +145,21 @@ class Dropdown extends React.Component {
containerClassName,
toggleClassName,
toggleOpenClassName,
disabled,
className,
} = this.props;
return (
<ClickOutside onClickOutside={this.hideMenu}>
<div
className={cn(styles.dropdown, containerClassName, 'dd dd-container')}
className={cn(
styles.dropdown,
className,
containerClassName,
'dd dd-container',
{
[styles.disabled]: disabled,
}
)}
>
<div
className={cn(styles.toggle, toggleClassName, {
@@ -150,7 +170,7 @@ class Dropdown extends React.Component {
role="button"
aria-pressed={this.state.isOpen}
aria-haspopup="true"
tabIndex="0"
tabIndex={disabled ? '-1' : '0'}
ref={this.handleToggleRef}
>
{this.props.icon && (
@@ -206,6 +226,7 @@ class Dropdown extends React.Component {
}
Dropdown.propTypes = {
className: PropTypes.string,
containerClassName: PropTypes.string,
toggleClassName: PropTypes.string,
toggleOpenClassName: PropTypes.string,
@@ -213,6 +234,7 @@ Dropdown.propTypes = {
icon: PropTypes.string,
onChange: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
disabled: PropTypes.bool,
value: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string,
+1 -1
View File
@@ -1,7 +1,7 @@
.option {
padding: 10px;
text-transform: capitalize;
outline: none;
white-space: nowrap;
&:focus, &:hover {
background-color: #ccc;
+1
View File
@@ -12,6 +12,7 @@ export {
withSignUp,
withResendEmailConfirmation,
withSetUsername,
withEnumValues,
} from 'coral-framework/hocs';
export {
withIgnoreUser,
@@ -84,6 +84,7 @@ const handler = {
category: 'featured',
event: 'commentFeatured',
hydrate,
digestOrder: 10,
};
module.exports = {
@@ -2,5 +2,5 @@ en:
talk-plugin-notifications:
categories:
featured:
subject: "One of your comments was featured on [{0}]"
subject: "One of your comments was featured on {0}"
body: "{0}\nA member of our team has selected this comment to be featured for other readers: {1}"
@@ -90,7 +90,13 @@ const hydrate = async (ctx, category, context) => {
return [headline, replier, permalink];
};
const handler = { handle, category: 'reply', event: 'commentAdded', hydrate };
const handler = {
handle,
category: 'reply',
event: 'commentAdded',
hydrate,
digestOrder: 30,
};
module.exports = {
typeDefs: `
@@ -117,6 +117,7 @@ const handler = {
event: 'commentAdded',
hydrate,
supersedesCategories: ['reply'],
digestOrder: 20,
};
module.exports = {
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,7 @@
import translations from './translations.yml';
export default {
// The only thing necessary on the client side is to provide
// a translation for the new enum.
translations,
};
@@ -0,0 +1,4 @@
en:
talk-plugin-notifications:
digest_enum:
DAILY: In a daily digest
@@ -0,0 +1,11 @@
module.exports = {
typeDefs: `
enum DIGEST_FREQUENCY {
# DAILY will queue up the notifications and send them daily.
DAILY
}
`,
notificationDigests: {
DAILY: { cronTime: '0 0 * * *', timeZone: 'America/New_York' },
},
};
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,7 @@
import translations from './translations.yml';
export default {
// The only thing necessary on the client side is to provide
// a translation for the new enum.
translations,
};
@@ -0,0 +1,4 @@
en:
talk-plugin-notifications:
digest_enum:
HOURLY: In an hourly digest
@@ -0,0 +1,11 @@
module.exports = {
typeDefs: `
enum DIGEST_FREQUENCY {
# HOURLY will queue up the notifications and send them hourly.
HOURLY
}
`,
notificationDigests: {
HOURLY: { cronTime: '0 * * * *', timeZone: 'America/New_York' },
},
};
@@ -1,6 +1,6 @@
.root {
margin-bottom: 20px;
width: 380px;
width: 350px;
}
.innerSettings {
@@ -30,3 +30,16 @@
.disabled {
color: #e5e5e5;
}
.digest {
display: flex;
align-items: center;
}
.titleDigest {
width: 100%;
}
.digestDropDown {
flex-shrink: 0;
}
@@ -4,7 +4,11 @@ import { IfSlotIsNotEmpty } from 'plugin-api/beta/client/components';
import { Slot } from 'plugin-api/beta/client/components';
import { t } from 'plugin-api/beta/client/services';
import styles from './Settings.css';
import { BareButton } from 'plugin-api/beta/client/components/ui';
import {
BareButton,
Dropdown,
Option,
} from 'plugin-api/beta/client/components/ui';
import EmailVerificationBanner from '../containers/EmailVerificationBanner';
import cn from 'classnames';
@@ -24,16 +28,29 @@ class Settings extends React.Component {
setTurnOffInputFragment,
updateNotificationSettings,
turnOffAll,
turnOffButtonDisabled,
needEmailVerification,
email,
digestFrequencyValues,
digestFrequency,
disableDigest,
disableTurnoffButton,
onChangeDigestFrequency,
} = this.props;
const slotProps = {
queryData: { root },
setTurnOffInputFragment: setTurnOffInputFragment,
updateNotificationSettings: updateNotificationSettings,
disabled: needEmailVerification,
};
return (
<IfSlotIsNotEmpty slot="notificationSettings" queryData={{ root }}>
<IfSlotIsNotEmpty slot="notificationSettings" {...slotProps}>
<div className={styles.root}>
<h3>{t('talk-plugin-notifications.settings_title')}</h3>
{needEmailVerification && <EmailVerificationBanner email={email} />}
<div className={styles.bannerContainer}>
{needEmailVerification && <EmailVerificationBanner email={email} />}
</div>
<h4
className={cn(styles.subtitle, {
[styles.disabled]: needEmailVerification,
@@ -45,20 +62,42 @@ class Settings extends React.Component {
<Slot
className={styles.notifcationSettingsSlot}
fill="notificationSettings"
queryData={{ root }}
childFactory={this.childFactory}
setTurnOffInputFragment={setTurnOffInputFragment}
updateNotificationSettings={updateNotificationSettings}
disabled={needEmailVerification}
{...slotProps}
/>
<BareButton
className={styles.turnOffButton}
onClick={turnOffAll}
disabled={turnOffButtonDisabled}
>
{t('talk-plugin-notifications.turn_off_all')}
</BareButton>
</div>
{digestFrequencyValues.length > 1 && (
<div className={styles.digest}>
<h4
className={cn(styles.titleDigest, {
[styles.disabled]: disableDigest,
})}
>
{t('talk-plugin-notifications.digest_option')}
</h4>
<Dropdown
className={styles.digestDropDown}
value={digestFrequency}
onChange={onChangeDigestFrequency}
disabled={disableDigest}
>
{digestFrequencyValues.map(v => (
<Option
value={v}
key={v}
label={t(`talk-plugin-notifications.digest_enum.${v}`)}
/>
))}
</Dropdown>
</div>
)}
<BareButton
className={styles.turnOffButton}
onClick={turnOffAll}
disabled={disableTurnoffButton}
>
{t('talk-plugin-notifications.turn_off_all')}
</BareButton>
</div>
</IfSlotIsNotEmpty>
);
@@ -72,9 +111,13 @@ Settings.propTypes = {
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
turnOffAll: PropTypes.func.isRequired,
turnOffButtonDisabled: PropTypes.bool.isRequired,
disableTurnoffButton: PropTypes.bool.isRequired,
disableDigest: PropTypes.bool.isRequired,
needEmailVerification: PropTypes.bool.isRequired,
email: PropTypes.string,
digestFrequencyValues: PropTypes.array.isRequired,
digestFrequency: PropTypes.string.isRequired,
onChangeDigestFrequency: PropTypes.func.isRequired,
};
export default Settings;
@@ -2,7 +2,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import Settings from '../components/Settings';
import { withFragments, excludeIf } from 'plugin-api/beta/client/hocs';
import {
withFragments,
excludeIf,
withEnumValues,
} from 'plugin-api/beta/client/hocs';
import { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils';
import { withUpdateNotificationSettings } from '../mutations';
@@ -33,6 +37,10 @@ class SettingsContainer extends React.Component {
this.props.updateNotificationSettings(this.state.turnOffInput);
};
setDigestFrequency = digestFrequency => {
this.props.updateNotificationSettings({ digestFrequency });
};
getNeedEmailVerification() {
return !this.props.root.me.profiles.some(
profile => profile.provider === 'local' && profile.confirmedAt
@@ -49,9 +57,15 @@ class SettingsContainer extends React.Component {
setTurnOffInputFragment={this.setTurnOffInputFragment}
updateNotificationSettings={this.props.updateNotificationSettings}
turnOffAll={this.turnOffAll}
turnOffButtonDisabled={this.state.hasNotifications.length === 0}
needEmailVerification={this.getNeedEmailVerification()}
email={this.props.root.me.email}
digestFrequencyValues={this.props.digestFrequencyValues}
digestFrequency={
this.props.root.me.notificationSettings.digestFrequency
}
disableDigest={this.state.hasNotifications.length === 0}
disableTurnoffButton={this.state.hasNotifications.length === 0}
onChangeDigestFrequency={this.setDigestFrequency}
/>
);
}
@@ -61,6 +75,7 @@ SettingsContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
updateNotificationSettings: PropTypes.func.isRequired,
digestFrequencyValues: PropTypes.array.isRequired,
};
const enhance = compose(
@@ -70,6 +85,9 @@ const enhance = compose(
__typename
${getSlotFragmentSpreads(slots, 'root')}
me {
notificationSettings {
digestFrequency
}
email
profiles {
provider
@@ -85,7 +103,8 @@ const enhance = compose(
props =>
!props.root.me.profiles.some(profile => profile.provider === 'local')
),
withUpdateNotificationSettings
withUpdateNotificationSettings,
withEnumValues('DIGEST_FREQUENCY', 'digestFrequencyValues')
);
export default enhance(SettingsContainer);
@@ -11,13 +11,38 @@ export default {
`,
},
mutations: {
UpdateNotificationSettings: () => ({
UpdateNotificationSettings: ({
variables: { input },
state: { auth: { user: { id } } },
}) => ({
optimisticResponse: {
updateNotificationSettings: {
__typename: 'UpdateNotificationSettingsResponse',
errors: null,
},
},
update: proxy => {
if (input.digestFrequency === undefined) {
return;
}
const fragment = gql`
fragment TalkNotificationsCategoryReply_User_Fragment on User {
notificationSettings {
digestFrequency
}
}
`;
const fragmentId = `User_${id}`;
const data = {
__typename: 'User',
notificationSettings: {
__typename: 'NotificationSettings',
digestFrequency: input.digestFrequency,
},
};
proxy.writeFragment({ fragment, id: fragmentId, data });
},
}),
},
};
@@ -13,3 +13,6 @@ en:
banner_error:
title: Error
text: There has been an error sending your verification email. Please try again later.
digest_option: Send notifications
digest_enum:
NONE: Immediately
@@ -6,6 +6,7 @@
"license": "Apache-2.0",
"private": false,
"dependencies": {
"cron": "^1.3.0",
"linkifyjs": "^2.1.5"
}
}
@@ -1,10 +1,24 @@
const { get, find, groupBy, forEach, property } = require('lodash');
const debug = require('debug')('talk-plugin-notifications');
const uuid = require('uuid/v4');
const {
UNSUBSCRIBE_SUBJECT,
DISABLE_REQUIRE_EMAIL_VERIFICATIONS,
} = require('./config');
merge,
map,
get,
find,
groupBy,
forEach,
flatten,
property,
} = require('lodash');
const debug = require('debug')('talk-plugin-notifications');
const { DISABLE_REQUIRE_EMAIL_VERIFICATIONS } = require('./config');
const { CronJob } = require('cron');
const { getOrganizationName } = require('./util');
const {
processNewNotifications,
filterSuperseded,
filterVerified,
sendNotification,
} = require('./messages');
const { renderDigestMessage } = require('./digests');
// handleHandlers will call the handle method on each handler to determine if a
// notification should be sent for it.
@@ -32,97 +46,11 @@ const handleHandlers = (ctx, handlers, ...args) =>
})
);
// 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'));
};
class NotificationManager {
constructor(context) {
this.context = context;
this.registry = [];
this.digests = [];
}
/**
@@ -149,17 +77,170 @@ class NotificationManager {
.map(({ category }) => category)
.join(', ')}] handlers when the '${event}' event is emitted`
);
broker.on(event, this.handle(handlers));
broker.on(event, this.handleUserEvent(handlers));
});
}
/**
* handle will wrap a notification handler and attach it to the notification
* stream system.
* registerDigests will register the digest handlers.
*
* @param {Array<Object>} handlers digest handlers for options related to digesting
*/
registerDigests(...handlers) {
this.digests.push(...handlers);
}
/**
* startDigesting will register all the digests to run and setup the cron
* jobs.
*/
startDigesting() {
this.digests.forEach(({ frequency, config }) => {
new CronJob(
merge(config, {
start: true,
onTick: this.handleDigestEvent(frequency),
})
);
});
}
handleDigestEvent(frequency) {
return async () => {
// Create a system context to send down.
const ctx = this.context.forSystem();
try {
// Pull out some useful tools.
const {
connectors: { models: { User }, services: { I18n: { t } } },
} = ctx;
const organizationName = await getOrganizationName(ctx);
if (!organizationName) {
ctx.log.error(
'could not send the notification, organization name not in settings'
);
return;
}
const subject = t(
'talk-plugin-notifications.templates.digest.subject',
organizationName
);
// Continue to pull from the Users digest until the queue is empty.
while (true) {
// Pull notifications from a user that have notifications enabled for
// `frequency` and currently have notifications.
const user = await User.findOneAndUpdate(
{
'metadata.notifications.settings.digestFrequency': frequency,
'metadata.notifications.digests': { $exists: true, $ne: [] },
},
{ $set: { 'metadata.notifications.digests': [] } }
);
if (!user) {
// There are no more users that meet the search criteria! We're
// done!
ctx.log.info('no notifications from database');
break;
}
// Begin rendering the user's digest.
const digests = get(user, 'metadata.notifications.digests');
if (!digests) {
// We couldn't get the digest from the user (even after Mongo said
// we would get it?).
ctx.log.info(
{ userID: user.id },
'no notifications from user in database'
);
continue;
}
ctx.log.info(
{ userID: user.id, notifications: digests.length },
'generating notification digest email'
);
const flattenedDigestCategories = this.flattenDigests(ctx, digests);
console.log(JSON.stringify(flattenedDigestCategories));
// Get all the notifications together.
const allMessages = await renderDigestMessage(
ctx,
flattenedDigestCategories
);
// Send the email with the digested body.
await sendNotification(
ctx,
user.id,
subject,
flatten(allMessages),
'notification-digest'
);
}
} catch (err) {
ctx.log.error({ err }, 'could not handle digests');
}
};
}
flattenDigests(ctx, digests) {
// Digests are store in the database like:
//
// [{ notification: { userID, date, context }, category }, ...]
//
// So lets group our notifications by category, creating the
// following:
//
// {[category]: [{notification: { userID, date, context }}, ...], ...}
//
const groupedDigests = groupBy(digests, 'category');
// Lets attach the handler reference onto each of these, so we
// transform it again to the following:
//
// [{ handler, notifications: [{ userID, date, context }]}]
//
return Object.keys(groupedDigests)
.map(category => {
// Get the handler.
const handler = find(this.registry, ['category', category]);
if (!handler) {
ctx.log.info({ category }, 'notification category not found');
return;
}
// Get the notifications.
const notifications = map(get(groupedDigests, category), digests =>
get(digests, 'notification')
);
return { notifications, handler };
})
.filter(digest => digest)
.sort((a, b) => {
const aDigestOrder = get(a, 'handler.digestOrder', 0);
const bDigestOrder = get(b, 'handler.digestOrder', 0);
if (aDigestOrder < bDigestOrder) {
return -1;
}
if (aDigestOrder > bDigestOrder) {
return 1;
}
return 0;
});
}
/**
* handleUserEvent will wrap a notification handler and attach it to the
* notification stream system.
*
* @param {Object} handler a notification handler
*/
handle(handlers) {
handleUserEvent(handlers) {
return async (...args) => {
// Create a system context to send down.
const ctx = this.context.forSystem();
@@ -181,95 +262,9 @@ class NotificationManager {
}
// Send the remaining notifications.
return Promise.all(
notifications.map(
({ handler, notification: { userID, date, context } }) =>
this.send(ctx, userID, date, handler, context)
)
);
return processNewNotifications(ctx, notifications);
};
}
async send(ctx, userID, date, handler, context) {
const {
connectors: {
secrets: { jwt },
config: { JWT_ISSUER, JWT_AUDIENCE },
services: { Mailer, I18n: { t } },
},
loaders: { Settings },
} = ctx;
const { category } = handler;
try {
// Get the settings.
const { organizationName = null } = await Settings.load(
'organizationName'
);
if (organizationName === null) {
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,
});
// 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 this.getBody(ctx, handler, context);
// Send the notification to the user.
const task = await Mailer.send({
template: 'notification',
locals: { body, organizationName, unsubscribeToken },
subject,
user: userID,
});
ctx.log.info(`Sent the notification for Job.ID[${task.id}]`);
} catch (err) {
ctx.log.error(
{ err, message: err.message },
'could not send the notification, an error occurred'
);
return;
}
}
/**
* getBody will return the body for the notification payload.
*
* @param {Object} ctx the graph context
* @param {Object} handler the notification handler
* @param {Mixed} context the notification context
*/
async getBody(ctx, handler, context) {
const { connectors: { services: { I18n: { t } } } } = ctx;
const { category, hydrate = () => [] } = handler;
// Get the body replacement variables for the translation key.
const replacements = await hydrate(ctx, category, context);
// Generate the body.
return t(
`talk-plugin-notifications.categories.${category}.body`,
...replacements
);
}
}
module.exports = NotificationManager;
@@ -2,6 +2,7 @@ const debug = require('debug')('talk-plugin-notifications');
const path = require('path');
const linkify = require('linkifyjs/html');
const NotificationManager = require('./NotificationManager');
const { map, reduce } = require('lodash');
module.exports = connectors => {
const {
@@ -12,17 +13,15 @@ module.exports = connectors => {
// Setup the mailer. Other plugins registered before this one can replace the
// notification template by passing the same name + format for the template
// registration.
Mailer.templates.register(
path.join(__dirname, 'emails', 'notification.html.ejs'),
'notification',
'html'
);
Mailer.templates.register(
path.join(__dirname, 'emails', 'notification.txt.ejs'),
'notification',
'txt'
);
['notification', 'notification-digest'].forEach(name => {
['txt', 'html'].forEach(format => {
Mailer.templates.register(
path.join(__dirname, 'emails', `${name}.${format}.ejs`),
name,
format
);
});
});
// Register the mail helpers. You can register your own helpers by calling
// this function in another plugin.
Mailer.registerHelpers({ linkify });
@@ -67,6 +66,47 @@ module.exports = connectors => {
// Attach all the notification handlers.
manager.register(...notificationHandlers);
// Digest handlers should export the following to the `notificationDigests`
// plugin hook:
//
// {DAILY: { cronTime: '0 0 * * *', timeZone: 'America/New_York' }}
//
// Where `DAILY` is the key referenced in the typeDefs as a new type of
// `DIGEST_FREQUENCY`, and the value of that key is the one provided to the
// constructor for the Cron object:
//
// https://github.com/kelektiv/node-cron
//
// Which is used to trigger the digest operation for those uses setup with
// that type of digesting.
const digestHandlers = Plugins.get('server', 'notificationDigests').reduce(
(handlers, { plugin, notificationDigests }) => {
debug(
`registered the ${plugin.name} plugin for digest notifications ${map(
notificationDigests,
(config, frequency) => frequency
)}`
);
return reduce(
notificationDigests,
(handlers, config, frequency) => {
handlers.push({ config, frequency });
return handlers;
},
handlers
);
},
[]
);
// Attach all the notification digest handlers.
manager.registerDigests(...digestHandlers);
// Attach the broker to the manager so it can listen for the events.
manager.attach(broker);
// Start processing digests.
manager.startDigesting();
};
@@ -0,0 +1,60 @@
const { get, flatten } = require('lodash');
const { getNotificationBody } = require('./util');
const QUEUE_DIGEST_NOTIFICATION_QUERY = `
query CheckDigest($userID: ID!) {
user(id: $userID) {
notificationSettings {
digestFrequency
}
}
}
`;
// checkDigests will return a boolean indicating if the user has digesting
// enabled.
const checkDigests = async (ctx, userID) => {
const { data, errors } = await ctx.graphql(QUEUE_DIGEST_NOTIFICATION_QUERY, {
userID,
});
if (errors) {
ctx.log.error(
{ err: errors },
'could not check the digest status of the user, skipping notifications'
);
return;
}
return get(data, 'user.notificationSettings.digestFrequency') !== 'NONE';
};
// renderDigestMessage will render the notification body value for a digest
// message. It expects that the digestCategories are parsed into a list grouped
// by category with the handler available.
const renderDigestMessage = async (ctx, flattenedDigestCategories) => {
// Render the messages in this format:
//
// [{handler, notifications: [{ context }, ...]}, ...]
//
// To:
//
// [['body', 'body'], ['body']]
//
const notifications = await Promise.all(
flattenedDigestCategories.map(async ({ handler, notifications }) =>
Promise.all(
notifications.map(async ({ context }) =>
getNotificationBody(ctx, handler, context)
)
)
)
);
// Flatten the array of categories:
//
// [[..., ...], [..., ...], ...] -> [..., ..., ...]
//
return flatten(notifications);
};
module.exports = { renderDigestMessage, checkDigests };
@@ -0,0 +1,6 @@
<% body.forEach((message) => { %>
<p><%= linkify(message, {nl2br: true}) %></p>
<% }) %>
<br/>
<p><%= t('talk-plugin-notifications.templates.footer', organizationName) %></p>
<p><a href="<%= BASE_URL %>account/unsubscribe-notifications#<%= unsubscribeToken %>" target="_blank"><%= t('talk-plugin-notifications.templates.links.unsubscribe') %></a></p>
@@ -0,0 +1,9 @@
<% body.forEach((message) => { %>
<%= message %>
<% }) %>
<%= t('talk-plugin-notifications.templates.footer', organizationName) %>
<%= t('talk-plugin-notifications.templates.links.unsubscribe') %>
<%= BASE_URL %>account/unsubscribe-notifications#<%= unsubscribeToken %>
@@ -0,0 +1,37 @@
const { isValidJSValue } = require('graphql');
module.exports = {
NotificationSettings: {
digestFrequency: {
// This hook will swap out the digest frequency with `NONE` in the event
// that an org had a digest plugin enabled and later switched it off. This
// will force users that have previously had a digested option enabled to
// get notifications immediately until they update their frequency
// options.
post: async (settings, args, ctx, { schema }, frequency) => {
try {
// Validate that the type is correct.
const errors = isValidJSValue(
frequency,
schema.getType('DIGEST_FREQUENCY')
);
if (errors && errors.length > 0) {
ctx.log.info(
{ frequency },
'invalid frequency, swapping with `NONE`, plugin likely disabled'
);
// Fallback to 'NONE' if the digest value has an error.
frequency = 'NONE';
}
} catch (err) {
ctx.log.error({ err }, 'could not check if the type was valid');
// Fallback to 'NONE' if we couldn't validate the value.
frequency = 'NONE';
}
return frequency;
},
},
},
};
@@ -1,16 +1,19 @@
const path = require('path');
const connect = require('./connect');
const typeDefs = require('./typeDefs');
const resolvers = require('./resolvers');
const router = require('./router');
const mutators = require('./mutators');
const translations = path.join(__dirname, 'translations.yml');
const connect = require('./connect');
const hooks = require('./hooks');
const mutators = require('./mutators');
const resolvers = require('./resolvers');
const router = require('./router');
const typeDefs = require('./typeDefs');
module.exports = {
connect,
hooks,
mutators,
resolvers,
router,
translations,
typeDefs,
resolvers,
mutators,
connect,
router,
};
@@ -0,0 +1,238 @@
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;
}
console.log(notifications);
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,
};
@@ -12,6 +12,9 @@ module.exports = {
}
},
},
NotificationSettings: {
digestFrequency: settings => get(settings, 'digestFrequency', 'NONE'),
},
RootMutation: {
async updateNotificationSettings(obj, { input }, { mutators: { User } }) {
await User.updateNotificationSettings(input);
@@ -1,6 +1,8 @@
en:
talk-plugin-notifications:
templates:
digest:
subject: "Your latest comment activity at {0}"
footer: "You received this notification because you are a commenter on {0} and you opted in to receive notifications."
links:
unsubscribe: "Unsubscribe from comment notifications"
@@ -1,5 +1,13 @@
enum DIGEST_FREQUENCY {
# NONE will have the notifications send immediatly rather than bundling
# for digesting.
NONE
}
# NotificationSettings stores all the preferences related to notifications.
type NotificationSettings { }
type NotificationSettings {
digestFrequency: DIGEST_FREQUENCY
}
type User {
notificationSettings: NotificationSettings
@@ -18,7 +26,8 @@ type UpdateNotificationSettingsResponse implements Response {
}
input NotificationSettingsInput {
# digestFrequency is the frequency to send notifications.
digestFrequency: DIGEST_FREQUENCY
}
type RootMutation {
@@ -0,0 +1,32 @@
const getOrganizationName = async ctx => {
// Grab some useful tools.
const { loaders: { Settings } } = ctx;
// Get the settings.
const { organizationName = null } = await Settings.load('organizationName');
return organizationName;
};
/**
* getNotificationBody will return the body for the notification payload.
*
* @param {Object} ctx the graph context
* @param {Object} handler the notification handler
* @param {Mixed} context the notification context
*/
const getNotificationBody = async (ctx, handler, context) => {
const { connectors: { services: { I18n: { t } } } } = ctx;
const { category, hydrate = () => [] } = handler;
// Get the body replacement variables for the translation key.
const replacements = await hydrate(ctx, category, context);
// Generate the body.
return t(
`talk-plugin-notifications.categories.${category}.body`,
...replacements
);
};
module.exports = { getNotificationBody, getOrganizationName };
+16
View File
@@ -2319,6 +2319,12 @@ create-react-class@^15.5.1, create-react-class@^15.5.2, create-react-class@^15.6
loose-envify "^1.3.1"
object-assign "^4.1.1"
cron@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/cron/-/cron-1.3.0.tgz#7e459968eaf94e1a445be796ce402166c234659d"
dependencies:
moment-timezone "^0.5.x"
cross-spawn-promise@^0.10.1:
version "0.10.1"
resolved "https://registry.yarnpkg.com/cross-spawn-promise/-/cross-spawn-promise-0.10.1.tgz#db9cb4c50c60b72a15be049b78122ce382d87b10"
@@ -6760,10 +6766,20 @@ mocha@^3.1.2:
mkdirp "0.5.1"
supports-color "3.1.2"
moment-timezone@^0.5.x:
version "0.5.14"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.14.tgz#4eb38ff9538b80108ba467a458f3ed4268ccfcb1"
dependencies:
moment ">= 2.9.0"
moment@2.x.x:
version "2.19.4"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.4.tgz#17e5e2c6ead8819c8ecfad83a0acccb312e94682"
"moment@>= 2.9.0":
version "2.21.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.21.0.tgz#2a114b51d2a6ec9e6d83cf803f838a878d8a023a"
moment@^2.10.3:
version "2.19.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.1.tgz#56da1a2d1cbf01d38b7e1afc31c10bcfa1929167"