mirror of
https://github.com/wassname/talk.git
synced 2026-07-07 14:57:36 +08:00
Merge branch 'master' into refactor-graph
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.root {
|
||||
text-align: center;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
color: #3a4a52;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-top: 0px;
|
||||
font-size: 14px;
|
||||
color: #7d8285;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import styles from './BlankCommentHistory.css';
|
||||
import { Icon } from 'coral-ui';
|
||||
import cn from 'classnames';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export default () => (
|
||||
<section className={cn(styles.root, 'talk-my-profile-comment-history-blank')}>
|
||||
<Icon name="chat" className={styles.icon} />
|
||||
<h1 className={styles.title}>{t('comment_history_blank.title')}</h1>
|
||||
<p className={styles.info}>{t('comment_history_blank.info')}</p>
|
||||
</section>
|
||||
);
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Comment from '../containers/Comment';
|
||||
import LoadMore from './LoadMore';
|
||||
import BlankCommentHistory from './BlankCommentHistory';
|
||||
|
||||
class CommentHistory extends React.Component {
|
||||
state = {
|
||||
@@ -22,6 +23,9 @@ class CommentHistory extends React.Component {
|
||||
|
||||
render() {
|
||||
const { navigate, comments, data, root } = this.props;
|
||||
if (!comments.nodes.length) {
|
||||
return <BlankCommentHistory />;
|
||||
}
|
||||
return (
|
||||
<div className="talk-my-profile-comment-history">
|
||||
<div className="commentHistory__list">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
.option {
|
||||
padding: 10px;
|
||||
text-transform: capitalize;
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&:focus, &:hover {
|
||||
background-color: #ccc;
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ class Context {
|
||||
this.id = ctx.id || uuid.v4();
|
||||
|
||||
// Attach a logger or create one.
|
||||
this.log = ctx.log || createLogger('context', this.id);
|
||||
this.log = ctx.log || createLogger('graph:context', this.id);
|
||||
|
||||
// Load the current logged in user to `user`, otherwise this will be null.
|
||||
this.user = get(ctx, 'user');
|
||||
|
||||
@@ -119,7 +119,7 @@ const findOrCreateAssetByURL = async (ctx, url) => {
|
||||
// If this is a new asset, then we need to scrape it!
|
||||
if (!asset.scraped) {
|
||||
// Create the Scraper job.
|
||||
await Scraper.create(asset);
|
||||
await Scraper.create(ctx, asset.id);
|
||||
}
|
||||
|
||||
return asset;
|
||||
|
||||
@@ -63,9 +63,9 @@ const closeNow = async (ctx, id) =>
|
||||
* @param {String} id the asset's id to scrape
|
||||
*/
|
||||
const scrapeAsset = async (ctx, id) => {
|
||||
const { services: { Scraper } } = ctx;
|
||||
const { connectors: { services: { Scraper } } } = ctx;
|
||||
|
||||
return Scraper.create({ id });
|
||||
return Scraper.create(ctx, id);
|
||||
};
|
||||
|
||||
module.exports = ctx => {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const jobs = [require('./mailer')];
|
||||
const jobs = [require('./mailer'), require('./scraper')];
|
||||
|
||||
const process = () => jobs.forEach(job => job());
|
||||
|
||||
|
||||
+11
-14
@@ -1,7 +1,8 @@
|
||||
const Asset = require('../models/asset');
|
||||
const scraper = require('../services/scraper');
|
||||
const Assets = require('../services/assets');
|
||||
const debug = require('debug')('talk:jobs:scraper');
|
||||
const { createLogger } = require('../services/logging');
|
||||
const logger = createLogger('jobs:scraper');
|
||||
const metascraper = require('metascraper');
|
||||
|
||||
/**
|
||||
@@ -39,38 +40,34 @@ function update(id, meta) {
|
||||
}
|
||||
|
||||
module.exports = () => {
|
||||
debug(`Now processing ${scraper.task.name} jobs`);
|
||||
logger.info({ taskName: scraper.task.name }, 'Now processing jobs');
|
||||
|
||||
scraper.task.process(async (job, done) => {
|
||||
debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`);
|
||||
const { id, asset_id } = job.data;
|
||||
|
||||
const log = logger.child({ traceID: id, jobID: job.id, assetID: asset_id });
|
||||
log.info('Starting scrape');
|
||||
|
||||
try {
|
||||
// Find the asset, or complain that it doesn't exist.
|
||||
const asset = await Assets.findById(job.data.asset_id);
|
||||
if (!asset) {
|
||||
return done(new Error('asset not found'));
|
||||
throw new Error('asset not found');
|
||||
}
|
||||
|
||||
// Scrape the metadata from the asset.
|
||||
const meta = await scrape(asset);
|
||||
|
||||
debug(
|
||||
`Scraped ${JSON.stringify(meta)} on Job[${job.id}] for Asset[${
|
||||
job.data.asset_id
|
||||
}]`
|
||||
);
|
||||
log.info('Finished scraping');
|
||||
|
||||
// Assign the metadata retrieved for the asset to the db.
|
||||
await update(job.data.asset_id, meta);
|
||||
} catch (err) {
|
||||
debug(
|
||||
`Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`,
|
||||
err
|
||||
);
|
||||
log.error({ err }, 'Failed to scrape');
|
||||
return done(err);
|
||||
}
|
||||
|
||||
debug(`Finished on Job[${job.id}] for Asset[${job.data.asset_id}]`);
|
||||
log.info('Finished updating');
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
+4
-1
@@ -358,6 +358,9 @@ en:
|
||||
report_notif: "Thank you for reporting this comment. Our moderation team has been notified and will review it shortly."
|
||||
report_notif_remove: "Your report has been removed."
|
||||
reported: Reported
|
||||
comment_history_blank:
|
||||
title: You have not written any comments
|
||||
info: A history of your comments will appear here
|
||||
settings:
|
||||
from_settings_page: "From the Profile Page you can see your comment history."
|
||||
my_comment_history: "My comment History"
|
||||
@@ -474,4 +477,4 @@ en:
|
||||
close: "Close this Installer"
|
||||
admin_sidebar:
|
||||
view_options: "View Options"
|
||||
sort_comments: "Sort Comments"
|
||||
sort_comments: "Sort Comments"
|
||||
|
||||
@@ -12,6 +12,7 @@ export {
|
||||
withSignUp,
|
||||
withResendEmailConfirmation,
|
||||
withSetUsername,
|
||||
withEnumValues,
|
||||
} from 'coral-framework/hocs';
|
||||
export {
|
||||
withIgnoreUser,
|
||||
|
||||
@@ -20,3 +20,7 @@
|
||||
color: #D0011B;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.blank {
|
||||
color: #7d8285;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './IgnoredUserSection.css';
|
||||
import { t } from 'plugin-api/beta/client/services';
|
||||
|
||||
export default ({ ignoredUsers, stopIgnoringUser }) => (
|
||||
const IgnoreUserSection = ({ ignoredUsers, stopIgnoringUser }) => (
|
||||
<section className={'talk-plugin-ignore-user-section'}>
|
||||
<h3>{t('talk-plugin-ignore-user.section_title')}</h3>
|
||||
<p>{t('talk-plugin-ignore-user.section_info')}</p>
|
||||
<ul className={styles.list}>
|
||||
{ignoredUsers.map(({ username, id }) => (
|
||||
<li className={styles.listItem} key={id}>
|
||||
<span className={styles.username}>{username}</span>
|
||||
<button
|
||||
onClick={() => stopIgnoringUser({ id })}
|
||||
className={styles.button}
|
||||
>
|
||||
{t('talk-plugin-ignore-user.stop_ignoring')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!ignoredUsers.length && (
|
||||
<p className={styles.blank}>{t('talk-plugin-ignore-user.blank_info')}</p>
|
||||
)}
|
||||
{!!ignoredUsers.length && (
|
||||
<div>
|
||||
<p>{t('talk-plugin-ignore-user.section_info')}</p>
|
||||
<ul className={styles.list}>
|
||||
{ignoredUsers.map(({ username, id }) => (
|
||||
<li className={styles.listItem} key={id}>
|
||||
<span className={styles.username}>{username}</span>
|
||||
<button
|
||||
onClick={() => stopIgnoringUser({ id })}
|
||||
className={styles.button}
|
||||
>
|
||||
{t('talk-plugin-ignore-user.stop_ignoring')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
IgnoreUserSection.propTypes = {
|
||||
ignoredUsers: PropTypes.array.isRequired,
|
||||
stopIgnoringUser: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default IgnoreUserSection;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import IgnoredUserSection from '../components/IgnoredUserSection';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
import {
|
||||
withFragments,
|
||||
excludeIf,
|
||||
withStopIgnoringUser,
|
||||
} from 'plugin-api/beta/client/hocs';
|
||||
|
||||
@@ -18,6 +18,11 @@ class IgnoredUserSectionContainer extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
IgnoredUserSectionContainer.propTypes = {
|
||||
stopIgnoringUser: PropTypes.func.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const withIgnoredUserSectionFragments = withFragments({
|
||||
root: gql`
|
||||
fragment TalkIgnoreUser_IgnoredUserSection_root on RootQuery {
|
||||
@@ -32,10 +37,6 @@ const withIgnoredUserSectionFragments = withFragments({
|
||||
`,
|
||||
});
|
||||
|
||||
const enhance = compose(
|
||||
withIgnoredUserSectionFragments,
|
||||
withStopIgnoringUser,
|
||||
excludeIf(({ root: { me } }) => me.ignoredUsers.length === 0)
|
||||
);
|
||||
const enhance = compose(withIgnoredUserSectionFragments, withStopIgnoringUser);
|
||||
|
||||
export default enhance(IgnoredUserSectionContainer);
|
||||
|
||||
@@ -13,6 +13,7 @@ da:
|
||||
confirmation_title: Ignore {0}?
|
||||
en:
|
||||
talk-plugin-ignore-user:
|
||||
blank_info: You are currently not ignoring any users
|
||||
section_title: Ignored users
|
||||
section_info: Because you ignored the following commenters, their comments are hidden.
|
||||
stop_ignoring: Stop ignoring
|
||||
@@ -68,7 +69,7 @@ nl_NL:
|
||||
ignore_user: Gebruiker negeren
|
||||
cancel: Annuleren
|
||||
confirmation: |
|
||||
Wanneer je een gebruiker negeert, zullen al zijn/haar reacties voor jou worden verborgen. Je kunt
|
||||
Wanneer je een gebruiker negeert, zullen al zijn/haar reacties voor jou worden verborgen. Je kunt
|
||||
dit later ongedaan maken vanuit Mijn profiel.
|
||||
notify_success: |
|
||||
Je negeert nu {0}. Je kunt dit ongedaan maken vanuit Mijn profiel.
|
||||
|
||||
@@ -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,9 +28,13 @@ class Settings extends React.Component {
|
||||
setTurnOffInputFragment,
|
||||
updateNotificationSettings,
|
||||
turnOffAll,
|
||||
turnOffButtonDisabled,
|
||||
needEmailVerification,
|
||||
email,
|
||||
digestFrequencyValues,
|
||||
digestFrequency,
|
||||
disableDigest,
|
||||
disableTurnoffButton,
|
||||
onChangeDigestFrequency,
|
||||
} = this.props;
|
||||
|
||||
const slotPassthrough = {
|
||||
@@ -37,10 +45,15 @@ class Settings extends React.Component {
|
||||
};
|
||||
|
||||
return (
|
||||
<IfSlotIsNotEmpty slot="notificationSettings" passthrough={slotPassthrough}>
|
||||
<IfSlotIsNotEmpty
|
||||
slot="notificationSettings"
|
||||
passthrough={slotPassthrough}
|
||||
>
|
||||
<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,
|
||||
@@ -55,14 +68,39 @@ class Settings extends React.Component {
|
||||
childFactory={this.childFactory}
|
||||
passthrough={slotPassthrough}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
@@ -76,9 +114,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 };
|
||||
+5
-5
@@ -1,17 +1,17 @@
|
||||
const { version } = require('../package.json');
|
||||
const Logger = require('bunyan');
|
||||
const uuid = require('uuid/v1');
|
||||
const { LOGGING_LEVEL } = require('../config');
|
||||
const { LOGGING_LEVEL, REVISION_HASH } = require('../config');
|
||||
|
||||
// Create the logging instance that all logger's are branched from.
|
||||
function createLogger(name, id = uuid()) {
|
||||
function createLogger(name, traceID) {
|
||||
return new Logger({
|
||||
src: true,
|
||||
name,
|
||||
id,
|
||||
traceID,
|
||||
version,
|
||||
revision: REVISION_HASH,
|
||||
level: LOGGING_LEVEL,
|
||||
serializers: { req: Logger.stdSerializers.req },
|
||||
serializers: Logger.stdSerializers,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -1,5 +1,4 @@
|
||||
const kue = require('./kue');
|
||||
const debug = require('debug')('talk:services:scraper');
|
||||
|
||||
/**
|
||||
* Exposes a service object to allow operations to execute against the scraper.
|
||||
@@ -16,15 +15,16 @@ const scraper = {
|
||||
/**
|
||||
* Creates a new scraper job and scrapes the url when it gets processed.
|
||||
*/
|
||||
async create(asset) {
|
||||
debug(`Creating job for Asset[${asset.id}]`);
|
||||
async create(ctx, id) {
|
||||
ctx.log.info({ assetID: id }, 'Creating job');
|
||||
|
||||
const job = await scraper.task.create({
|
||||
title: `Scrape for asset ${asset.id}`,
|
||||
asset_id: asset.id,
|
||||
title: `Scrape for asset ${id}`,
|
||||
id: ctx.id,
|
||||
asset_id: id,
|
||||
});
|
||||
|
||||
debug(`Created Job[${job.id}] for Asset[${asset.id}]`);
|
||||
ctx.log.info({ jobID: job.id, assetID: id }, 'Created job');
|
||||
|
||||
return job;
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user