[CORL-498, CORL-495, CORL-539, CORL-496, CORL-494] Email Notifications Support & Framework (#2498)

* chore: renamed old templates

* feat: initial notifications support

* feat: email enhancements

* fix: linting

* feat: initial digesting beheviour

* feat: added notification configuration

* feat: added unsubscribe routes

* fix: fixed failing snapshots/tests bc random ids

* feat: adjusted the save beheviour, added tests

* feat: added tests

* feat: added staff replies

* feat: renamed E-Mail to Email

* feat: enhanced cron processing

* fix: linting + updating tests

* feat: enhanced cron context

* fix: added staff replies back in
This commit is contained in:
Wyatt Johnson
2019-09-05 07:02:26 +00:00
committed by GitHub
parent 0fad1070a6
commit efea0e8e1c
111 changed files with 3439 additions and 382 deletions
+13 -1
View File
@@ -4047,6 +4047,12 @@
"@types/node": "*"
}
},
"@types/object-diff": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/@types/object-diff/-/object-diff-0.0.0.tgz",
"integrity": "sha1-yecnRxRvOiNxTqPj29agJ9mRHaY=",
"dev": true
},
"@types/on-finished": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@types/on-finished/-/on-finished-2.3.1.tgz",
@@ -23430,6 +23436,12 @@
}
}
},
"object-diff": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/object-diff/-/object-diff-0.0.4.tgz",
"integrity": "sha1-2IOwRE/o/W4E5ZXXu2ZWgskWBH8=",
"dev": true
},
"object-inspect": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz",
@@ -24234,7 +24246,7 @@
"dependencies": {
"async": {
"version": "1.5.2",
"resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
"dev": true
}
+3
View File
@@ -170,6 +170,7 @@
"@types/cookie": "^0.3.3",
"@types/cookie-parser": "^1.4.1",
"@types/cors": "^2.8.4",
"@types/cron": "^1.7.1",
"@types/cross-spawn": "^6.0.0",
"@types/dotenv": "^4.0.3",
"@types/enzyme": "^3.1.15",
@@ -200,6 +201,7 @@
"@types/node-fetch": "^2.3.3",
"@types/nodemailer": "^4.6.2",
"@types/nunjucks": "^3.1.1",
"@types/object-diff": "0.0.0",
"@types/on-finished": "^2.3.1",
"@types/passport": "^0.4.6",
"@types/passport-facebook": "^2.1.8",
@@ -290,6 +292,7 @@
"marked": "^0.6.0",
"material-design-icons": "^3.0.1",
"mini-css-extract-plugin": "^0.6.0",
"object-diff": "0.0.4",
"postcss-advanced-variables": "^3.0.0",
"postcss-css-variables": "^0.11.0",
"postcss-flexbugs-fixes": "^4.1.0",
+4
View File
@@ -3,6 +3,7 @@ import React from "react";
import DownloadRoute from "./routes/download/Download";
import ConfirmRoute from "./routes/email/Confirm";
import UnsubscribeRoute from "./routes/notifications/Unsubscribe";
import ResetRoute from "./routes/password/Reset";
export default makeRouteConfig(
@@ -14,5 +15,8 @@ export default makeRouteConfig(
<Route path="confirm" {...ConfirmRoute.routeConfig} />
</Route>
<Route path="download" {...DownloadRoute.routeConfig} />
<Route path="notifications">
<Route path="unsubscribe" {...UnsubscribeRoute.routeConfig} />
</Route>
</Route>
);
@@ -0,0 +1,32 @@
import { Localized } from "fluent-react/compat";
import React from "react";
import { CallOut, HorizontalGutter, Typography } from "coral-ui/components";
interface Props {
reason: React.ReactNode;
}
const Sorry: React.FunctionComponent<Props> = ({ reason }) => {
return (
<HorizontalGutter size="double">
<Localized id="unsubscribe-oopsSorry">
<Typography variant="heading1">Oops Sorry!</Typography>
</Localized>
<CallOut color="error" fullWidth>
{reason ? (
reason
) : (
<Localized id="account-tokenNotFound">
<span data-testid="invalid-link">
The specified link is invalid, check to see if it was copied
correctly.
</span>
</Localized>
)}
</CallOut>
</HorizontalGutter>
);
};
export default Sorry;
@@ -0,0 +1,18 @@
import { Localized } from "fluent-react/compat";
import React from "react";
import { HorizontalGutter, Typography } from "coral-ui/components";
const Success: React.FunctionComponent = () => {
return (
<HorizontalGutter data-testid="success" size="double">
<Localized id="unsubscribe-successfullyUnsubscribed">
<Typography variant="heading1">
You are now unsubscribed from all notifications
</Typography>
</Localized>
</HorizontalGutter>
);
};
export default Success;
@@ -0,0 +1,76 @@
import { FORM_ERROR } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { useCallback } from "react";
import { Form } from "react-final-form";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useMutation } from "coral-framework/lib/relay";
import {
Button,
CallOut,
HorizontalGutter,
Typography,
} from "coral-ui/components";
import UnsubscribeNotificationsMutation from "./UnsubscribeNotificationsMutation";
interface Props {
token: string;
disabled?: boolean;
onSuccess: () => void;
}
const UnsubscribeForm: React.FunctionComponent<Props> = ({
onSuccess,
token,
}) => {
const unsubscribe = useMutation(UnsubscribeNotificationsMutation);
const onSubmit = useCallback(async () => {
try {
await unsubscribe({ token });
onSuccess();
} catch (error) {
if (error instanceof InvalidRequestError) {
return error.invalidArgs;
}
return { [FORM_ERROR]: error.message };
}
return;
}, [token]);
return (
<div data-testid="unsubscribe-form">
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitting, submitError }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter>
<Localized id="unsubscribe-clickToConfirm">
<Typography variant="heading1">
Click below to confirm that you want to unsubscribe from all
notifications.
</Typography>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Localized id="unsubscribe-confirm">
<Button
type="submit"
variant="filled"
color="primary"
disabled={submitting}
fullWidth
>
Confirm
</Button>
</Localized>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
export default UnsubscribeForm;
@@ -0,0 +1,14 @@
import { Environment } from "relay-runtime";
import { createMutation } from "coral-framework/lib/relay";
const UnsubscribeNotificationsMutation = createMutation(
"unsubscribeNotifications",
async (environment: Environment, variables: { token: string }, { rest }) =>
await rest.fetch<void>("/account/notifications/unsubscribe", {
method: "DELETE",
token: variables.token,
})
);
export default UnsubscribeNotificationsMutation;
@@ -0,0 +1,16 @@
.container {
width: 100%;
text-align: center;
}
.root {
display: inline-block;
max-width: calc(70 * var(--mini-unit));
@media (min-width: $breakpoints-xs) {
max-width: calc(42 * var(--mini-unit));
}
text-align: left;
}
@@ -0,0 +1,77 @@
import React, { useCallback, useState } from "react";
import { Environment } from "relay-runtime";
import Loading from "coral-account/components/Loading";
import { useToken } from "coral-framework/hooks";
import { createFetch } from "coral-framework/lib/relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { parseHashQuery } from "coral-framework/utils";
import Sorry from "./Sorry";
import Success from "./Success";
import UnsubscribeForm from "./UnsubscribeForm";
import styles from "./UnsubscribeRoute.css";
const fetcher = createFetch(
"unsubscribeToken",
async (environment: Environment, variables: { token: string }, { rest }) =>
await rest.fetch<void>("/account/notifications/unsubscribe", {
method: "GET",
token: variables.token,
})
);
interface Props {
token: string | undefined;
}
const UnsubscribeRoute: React.FunctionComponent<Props> = ({ token }) => {
const [finished, setFinished] = useState(false);
const onSuccess = useCallback(() => {
setFinished(true);
}, []);
const [state, error] = useToken(fetcher, token);
if (state === "UNCHECKED") {
return (
<div className={styles.container}>
<div className={styles.root}>
<Loading />
</div>
</div>
);
}
if (state !== "VALID" || error) {
return (
<div className={styles.container}>
<div className={styles.root}>
<Sorry reason={error} />
</div>
</div>
);
}
return !finished ? (
<div className={styles.container}>
<div className={styles.root}>
<UnsubscribeForm token={token!} onSuccess={onSuccess} />
</div>
</div>
) : (
<div className={styles.container}>
<div className={styles.root}>
<Success />
</div>
</div>
);
};
const enhanced = withRouteConfig<Props>({
render: ({ match, Component }) => (
<Component token={parseHashQuery(match.location.hash).unsubscribeToken} />
),
})(UnsubscribeRoute);
export default enhanced;
@@ -0,0 +1 @@
export { default, default as UnsubscribeRoute } from "./UnsubscribeRoute";
@@ -0,0 +1,96 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders form 1`] = `
<div
data-testid="main-layout"
>
<div
className="MainLayout-bar"
/>
<div
className="MainLayout-centered"
>
<div
className="UnsubscribeRoute-container"
>
<div
className="UnsubscribeRoute-root"
>
<div
data-testid="unsubscribe-form"
>
<form
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Click below to confirm that you want to unsubscribe from all notifications.
</h1>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Confirm
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
`;
exports[`renders missing confirm token 1`] = `
<div
data-testid="main-layout"
>
<div
className="MainLayout-bar"
/>
<div
className="MainLayout-centered"
>
<div
className="UnsubscribeRoute-container"
>
<div
className="UnsubscribeRoute-root"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Oops Sorry!
</h1>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
>
<div>
<span
data-testid="invalid-link"
>
The specified link is invalid, check to see if it was copied correctly.
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,134 @@
import sinon from "sinon";
import { ERROR_CODES } from "coral-common/errors";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { GQLResolver } from "coral-framework/schema";
import {
act,
createAccessToken,
CreateTestRendererParams,
replaceHistoryLocation,
waitForElement,
within,
} from "coral-framework/testHelpers";
import create from "./create";
const token = createAccessToken();
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
const { testRenderer, context } = create();
return {
context,
testRenderer,
root: testRenderer.root,
};
}
it("renders missing confirm token", async () => {
replaceHistoryLocation("http://localhost/account/notifications/unsubscribe");
const { root } = await createTestRenderer();
await waitForElement(() => within(root).getByTestID("invalid-link"));
expect(within(root).toJSON()).toMatchSnapshot();
});
it("renders form", async () => {
replaceHistoryLocation(
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
);
const { root, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "GET",
token,
})
.once();
await act(async () => {
await waitForElement(() => within(root).getByTestID("unsubscribe-form"));
});
expect(within(root).toJSON()).toMatchSnapshot();
restMock.verify();
});
it("renders error from server", async () => {
replaceHistoryLocation(
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
);
const codes = [
ERROR_CODES.RATE_LIMIT_EXCEEDED,
ERROR_CODES.INTEGRATION_DISABLED,
ERROR_CODES.USER_NOT_FOUND,
ERROR_CODES.TOKEN_INVALID,
];
for (const code of codes) {
const { root, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "GET",
token,
})
.once()
.throwsException(
new InvalidRequestError({
code,
})
);
await act(async () => {
await waitForElement(() =>
within(root).getByText(code, {
exact: false,
})
);
});
restMock.verify();
}
});
it("submits form", async () => {
replaceHistoryLocation(
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
);
const { root, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "GET",
token,
})
.once();
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "DELETE",
token,
})
.once();
await act(async () => {
await waitForElement(() => within(root).getByTestID("unsubscribe-form"));
});
const form = within(root).getByType("form");
// Submit valid form.
await act(async () => {
form.props.onSubmit();
await waitForElement(() => within(root).getByTestID("success"));
});
restMock.verify();
});
@@ -37,7 +37,7 @@ const OrganizationNameConfig: FunctionComponent<Props> = ({ disabled }) => (
id="configure-organization-emailExplanation"
strong={<strong />}
>
<Typography variant="detail">This E-Mail will be used</Typography>
<Typography variant="detail">This Email will be used</Typography>
</Localized>
<Field
name="organization.contactEmail"
@@ -0,0 +1,210 @@
import { FORM_ERROR } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form, FormSpy } from "react-final-form";
import { graphql } from "react-relay";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLDIGEST_FREQUENCY } from "coral-framework/schema";
import { NotificationSettingsContainer_viewer } from "coral-stream/__generated__/NotificationSettingsContainer_viewer.graphql";
import {
Button,
CallOut,
CheckBox,
FieldSet,
Flex,
FormField,
HorizontalGutter,
Option,
SelectField,
Typography,
} from "coral-ui/components";
import UpdateNotificationSettingsMutation from "./UpdateNotificationSettingsMutation";
interface Props {
viewer: NotificationSettingsContainer_viewer;
}
type FormProps = NotificationSettingsContainer_viewer["notifications"];
const NotificationSettingsContainer: FunctionComponent<Props> = ({
viewer: { notifications },
}) => {
const mutation = useMutation(UpdateNotificationSettingsMutation);
const onSubmit = useCallback(
async (values: FormProps) => {
try {
await mutation(values);
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
}
return {
[FORM_ERROR]: err.message,
};
}
return;
},
[mutation]
);
return (
<HorizontalGutter data-testid="profile-settings-notifications">
<Localized id="profile-settings-notifications-emailNotifications">
<Typography variant="heading3">Email Notifications</Typography>
</Localized>
<Localized id="profile-settings-notifications-receiveWhen">
<Typography variant="heading4">Receive notifications when:</Typography>
</Localized>
<HorizontalGutter>
<Form initialValues={{ ...notifications }} onSubmit={onSubmit}>
{({
handleSubmit,
submitting,
submitError,
pristine,
submitSucceeded,
}) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter>
<FieldSet>
<FormField>
<Field name="onReply" type="checkbox">
{({ input }) => (
<Localized id="profile-settings-notifications-onReply">
<CheckBox id={input.name} {...input}>
My comment receives a reply
</CheckBox>
</Localized>
)}
</Field>
</FormField>
<FormField>
<Field name="onFeatured" type="checkbox">
{({ input }) => (
<Localized id="profile-settings-notifications-onFeatured">
<CheckBox id={input.name} {...input}>
My comment is featured
</CheckBox>
</Localized>
)}
</Field>
</FormField>
<FormField>
<Field name="onStaffReplies" type="checkbox">
{({ input }) => (
<Localized id="profile-settings-notifications-onStaffReplies">
<CheckBox id={input.name} {...input}>
A staff member replies to my comment
</CheckBox>
</Localized>
)}
</Field>
</FormField>
<FormField>
<Field name="onModeration" type="checkbox">
{({ input }) => (
<Localized id="profile-settings-notifications-onModeration">
<CheckBox id={input.name} {...input}>
My pending comment has been reviewed
</CheckBox>
</Localized>
)}
</Field>
</FormField>
<FormField>
<Flex alignItems="center" itemGutter>
<Localized id="profile-settings-notifications-sendNotifications">
<Typography variant="bodyCopyBold">
Send Notifications:
</Typography>
</Localized>
<FormSpy subscription={{ values: true }}>
{({ values }) => (
<Field name="digestFrequency">
{({ input }) => (
<SelectField
id={input.name}
{...input}
disabled={
!values.onReply &&
!values.onStaffReplies &&
!values.onFeatured &&
!values.onModeration
}
>
<Localized id="profile-settings-notifications-sendNotifications-immediately">
<Option value={GQLDIGEST_FREQUENCY.NONE}>
Immediately
</Option>
</Localized>
<Localized id="profile-settings-notifications-sendNotifications-daily">
<Option value={GQLDIGEST_FREQUENCY.DAILY}>
Daily
</Option>
</Localized>
<Localized id="profile-settings-notifications-sendNotifications-hourly">
<Option value={GQLDIGEST_FREQUENCY.HOURLY}>
Hourly
</Option>
</Localized>
</SelectField>
)}
</Field>
)}
</FormSpy>
</Flex>
</FormField>
</FieldSet>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
{submitSucceeded && (
<Localized id="profile-settings-notifications-updated">
<CallOut color="success" fullWidth>
Your notification settings have been updated
</CallOut>
</Localized>
)}
<Flex justifyContent="flex-end">
<Localized id="profile-settings-notifications-button">
<Button
color="primary"
variant="filled"
type="submit"
disabled={submitting || pristine}
>
Update Notification Settings
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</HorizontalGutter>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment NotificationSettingsContainer_viewer on User {
notifications {
onReply
onFeatured
onStaffReplies
onModeration
digestFrequency
}
}
`,
})(NotificationSettingsContainer);
export default enhanced;
@@ -10,6 +10,7 @@ import ChangePasswordContainer from "./ChangePasswordContainer";
import DeleteAccountContainer from "./DeleteAccount/DeleteAccountContainer";
import DownloadCommentsContainer from "./DownloadCommentsContainer";
import IgnoreUserSettingsContainer from "./IgnoreUserSettingsContainer";
import NotificationSettingsContainer from "./NotificationSettingsContainer";
import styles from "./SettingsContainer.css";
@@ -28,6 +29,7 @@ const SettingsContainer: FunctionComponent<Props> = ({ viewer, settings }) => (
{settings.accountFeatures.deleteAccount && (
<DeleteAccountContainer viewer={viewer} settings={settings} />
)}
<NotificationSettingsContainer viewer={viewer} />
</HorizontalGutter>
);
@@ -37,6 +39,7 @@ const enhanced = withFragmentContainer<Props>({
...IgnoreUserSettingsContainer_viewer
...DownloadCommentsContainer_viewer
...DeleteAccountContainer_viewer
...NotificationSettingsContainer_viewer
}
`,
settings: graphql`
@@ -0,0 +1,45 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { UpdateNotificationSettingsMutation as MutationTypes } from "coral-stream/__generated__/UpdateNotificationSettingsMutation.graphql";
let clientMutationId = 0;
const UpdateNotificationSettingsMutation = createMutation(
"updateNotificationSettings",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateNotificationSettingsMutation(
$input: UpdateNotificationSettingsInput!
) {
updateNotificationSettings(input: $input) {
user {
id
notifications {
onReply
onFeatured
onStaffReplies
onModeration
digestFrequency
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default UpdateNotificationSettingsMutation;
+9 -1
View File
@@ -1,11 +1,12 @@
import {
GQLComment,
GQLCOMMENT_STATUS,
GQLDIGEST_FREQUENCY,
GQLMODERATION_MODE,
GQLSettings,
GQLStory,
GQLTag,
GQLTAG,
GQLTag,
GQLUser,
GQLUSER_ROLE,
GQLUSER_STATUS,
@@ -143,6 +144,13 @@ export const baseUser = createFixture<GQLUser>({
hasNextPage: false,
},
},
notifications: {
onReply: false,
onModeration: false,
onStaffReplies: false,
onFeatured: false,
digestFrequency: GQLDIGEST_FREQUENCY.NONE,
},
ignoreable: true,
profiles: [
{
@@ -495,6 +495,225 @@ all your comments from this site.
</span>
</button>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
data-testid="profile-settings-notifications"
>
<h1
className="Box-root Typography-root Typography-heading3 Typography-colorTextPrimary"
>
Email Notifications
</h1>
<h1
className="Box-root Typography-root Typography-heading4 Typography-colorTextPrimary"
>
Receive notifications when:
</h1>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<form
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<fieldset
className="FieldSet-root"
>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="CheckBox-root"
>
<input
checked={false}
className="CheckBox-input"
id="onReply"
name="onReply"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="checkbox"
value={false}
/>
<label
className="CheckBox-label"
htmlFor="onReply"
>
<span
className="CheckBox-labelSpan"
>
My comment receives a reply
</span>
</label>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="CheckBox-root"
>
<input
checked={false}
className="CheckBox-input"
id="onFeatured"
name="onFeatured"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="checkbox"
value={false}
/>
<label
className="CheckBox-label"
htmlFor="onFeatured"
>
<span
className="CheckBox-labelSpan"
>
My comment is featured
</span>
</label>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="CheckBox-root"
>
<input
checked={false}
className="CheckBox-input"
id="onStaffReplies"
name="onStaffReplies"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="checkbox"
value={false}
/>
<label
className="CheckBox-label"
htmlFor="onStaffReplies"
>
<span
className="CheckBox-labelSpan"
>
A staff member replies to my comment
</span>
</label>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="CheckBox-root"
>
<input
checked={false}
className="CheckBox-input"
id="onModeration"
name="onModeration"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="checkbox"
value={false}
/>
<label
className="CheckBox-label"
htmlFor="onModeration"
>
<span
className="CheckBox-labelSpan"
>
My pending comment has been reviewed
</span>
</label>
</div>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter Flex-alignCenter gutter"
>
<p
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary"
>
Send Notifications:
</p>
<span
className="SelectField-root"
>
<select
className="SelectField-select"
disabled={true}
id="digestFrequency"
name="digestFrequency"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value="NONE"
>
<option
value="NONE"
>
Immediately
</option>
<option
value="DAILY"
>
Daily
</option>
<option
value="HOURLY"
>
Hourly
</option>
</select>
<span
aria-hidden={true}
className="SelectField-afterWrapper SelectField-afterWrapperDisabled"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
expand_more
</i>
</span>
</span>
</div>
</div>
</fieldset>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-disabled"
data-color="primary"
data-variant="filled"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Update Notification Settings
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
</div>
@@ -184,3 +184,100 @@ it("render ignored users list", async () => {
);
within(ignoredCommenters).getByText(commenters[1].username!);
});
it("render notifications form", async () => {
const updateNotificationSettings = sinon
.stub()
.callsFake((_: any, { input: { clientMutationId, ...notifications } }) => {
expectAndFail(notifications).toMatchObject({
onReply: true,
onFeatured: true,
onStaffReplies: true,
onModeration: true,
digestFrequency: "HOURLY",
});
return {
user: pureMerge<typeof viewer>(viewer, {
notifications,
}),
clientMutationId,
};
});
const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Mutation: {
updateNotificationSettings,
},
}),
});
const container = await waitForElement(() =>
within(testRenderer.root).getByTestID("profile-settings-notifications")
);
const form = within(container).getByType("form");
// Get the form fields.
const onReply = await waitForElement(() =>
within(form).getByID("onReply", { exact: false })
);
const onStaffReplies = await waitForElement(() =>
within(form).getByID("onStaffReplies", { exact: false })
);
const onModeration = await waitForElement(() =>
within(form).getByID("onModeration", { exact: false })
);
const onFeatured = await waitForElement(() =>
within(form).getByID("onFeatured", { exact: false })
);
const digestFrequency = await waitForElement(() =>
within(form).getByID("digestFrequency", { exact: false })
);
const save = await waitForElement(() => within(form).getByType("button"));
// The save button should be disabled for unchanged fields.
expect(save.props.disabled).toEqual(true);
// The digest frequency select should be disabled with no options enabled.
expect(digestFrequency.props.disabled).toEqual(true);
// Enable the options.
act(() => {
onReply.props.onChange(true);
onStaffReplies.props.onChange(true);
onModeration.props.onChange(true);
onFeatured.props.onChange(true);
});
// The digest frequency select should now be enabled.
expect(digestFrequency.props.disabled).toEqual(false);
// Change the digest frequency.
act(() => {
digestFrequency.props.onChange("HOURLY");
});
// Submit the form.
await act(async () => {
await form.props.onSubmit();
});
// Ensure that the mutation was called and that the save button is now
// disabled.
expect(updateNotificationSettings.calledOnce).toEqual(true);
expect(save.props.disabled).toEqual(true);
// Change a notification option.
act(() => {
onReply.props.onChange(false);
});
// The save button should now be enabled.
expect(save.props.disabled).toEqual(false);
// Change a notification back (making it pristine).
act(() => {
onReply.props.onChange(true);
});
// The save button should now be disabled.
expect(save.props.disabled).toEqual(true);
});
@@ -16,7 +16,7 @@ import { Table, TableBody, TableHead, TableRow, TableCell } from "./";
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>E-Mail Address</TableCell>
<TableCell>Email Address</TableCell>
<TableCell>Member Since</TableCell>
</TableRow>
</TableHead>
@@ -10,7 +10,7 @@ it("renders correctly", () => {
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>E-Mail Address</TableCell>
<TableCell>Email Address</TableCell>
<TableCell>Member Since</TableCell>
</TableRow>
</TableHead>
@@ -16,7 +16,7 @@ exports[`renders correctly 1`] = `
Username
</withPropsOnChange(TableCell)>
<withPropsOnChange(TableCell)>
E-Mail Address
Email Address
</withPropsOnChange(TableCell)>
<withPropsOnChange(TableCell)>
Member Since
@@ -1,3 +1,4 @@
export * from "./confirm";
export * from "./invite";
export * from "./download";
export * from "./notifications";
@@ -0,0 +1,142 @@
import { AppOptions } from "coral-server/app";
import { RequestLimiter } from "coral-server/app/request/limiter";
import { updateUserNotificationSettings } from "coral-server/models/user";
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
import { verifyUnsubscribeTokenString } from "coral-server/services/notifications/categories/unsubscribe";
import { RequestHandler } from "coral-server/types/express";
export type UnsubscribeCheckOptions = Pick<
AppOptions,
"mongo" | "signingConfig" | "redis" | "config"
>;
export const unsubscribeCheckHandler = ({
redis,
mongo,
signingConfig,
config,
}: UnsubscribeCheckOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
max: 10,
prefix: "ip",
config,
});
const subLimiter = new RequestLimiter({
redis,
ttl: "5m",
max: 10,
prefix: "sub",
config,
});
return async (req, res, next) => {
try {
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
// TODO: evaluate verifying if the Tenant allows verifications to short circuit.
// Grab the token from the request.
const tokenString = extractTokenFromRequest(req, true);
if (!tokenString) {
return res.sendStatus(400);
}
// Decode the token so we can rate limit based on the user's ID.
const { sub } = decodeJWT(tokenString);
if (sub) {
await subLimiter.test(req, sub);
}
// Verify the token.
await verifyUnsubscribeTokenString(
mongo,
tenant,
signingConfig,
tokenString,
coral.now
);
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
export type UnsubscribeOptions = Pick<
AppOptions,
"mongo" | "signingConfig" | "redis" | "config"
>;
export const unsubscribeHandler = ({
redis,
mongo,
signingConfig,
config,
}: UnsubscribeOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
redis,
ttl: "10m",
max: 10,
prefix: "ip",
config,
});
const subLimiter = new RequestLimiter({
redis,
ttl: "5m",
max: 10,
prefix: "sub",
config,
});
return async (req, res, next) => {
try {
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const coral = req.coral!;
const tenant = coral.tenant!;
// Grab the token from the request.
const tokenString = extractTokenFromRequest(req, true);
if (!tokenString) {
return res.sendStatus(400);
}
// Decode the token so we can rate limit based on the user's ID.
const { sub } = decodeJWT(tokenString);
if (sub) {
await subLimiter.test(req, sub);
}
// Verify the token.
const { user } = await verifyUnsubscribeTokenString(
mongo,
tenant,
signingConfig,
tokenString,
coral.now
);
// Unsubscribe the user from all notification types.
await updateUserNotificationSettings(mongo, tenant.id, user.id, {
onFeatured: false,
onModeration: false,
onReply: false,
onStaffReplies: false,
});
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
@@ -107,7 +107,7 @@ export const forgotHandler = ({
// Add the email to the processing queue.
await mailerQueue.add({
template: {
name: "forgot-password",
name: "account-notification/forgot-password",
context: {
resetURL,
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
@@ -19,6 +19,7 @@ export type GraphMiddlewareOptions = Pick<
| "pubsub"
| "tenantCache"
| "metrics"
| "notifierQueue"
>;
export const graphQLHandler = ({
+3 -1
View File
@@ -14,6 +14,7 @@ import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import { createPassport } from "coral-server/app/middleware/passport";
import { Config } from "coral-server/config";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { I18n } from "coral-server/services/i18n";
import { JWTSigningConfig } from "coral-server/services/jwt";
@@ -35,9 +36,10 @@ export interface AppOptions {
mailerQueue: MailerQueue;
metrics?: Metrics;
mongo: Db;
notifierQueue: NotifierQueue;
parent: Express;
persistedQueryCache: PersistedQueryCache;
persistedQueriesRequired: boolean;
persistedQueryCache: PersistedQueryCache;
pubsub: RedisPubSub;
redis: AugmentedRedis;
schema: GraphQLSchema;
@@ -10,6 +10,8 @@ import {
confirmRequestHandler,
inviteCheckHandler,
inviteHandler,
unsubscribeCheckHandler,
unsubscribeHandler,
} from "coral-server/app/handlers";
import { jsonMiddleware } from "coral-server/app/middleware/json";
import { authenticate } from "coral-server/app/middleware/passport";
@@ -33,6 +35,9 @@ export function createNewAccountRouter(
router.get("/invite", inviteCheckHandler(app));
router.put("/invite", jsonMiddleware, inviteHandler(app));
router.get("/notifications/unsubscribe", unsubscribeCheckHandler(app));
router.delete("/notifications/unsubscribe", unsubscribeHandler(app));
router.get("/download", accountDownloadCheckHandler(app));
router.post(
"/download",
+104 -86
View File
@@ -1,19 +1,23 @@
import { CronCommand, CronJob } from "cron";
import { DateTime } from "luxon";
import { Collection, Db } from "mongodb";
import logger from "coral-server/logger";
import { CommentAction } from "coral-server/models/action/comment";
import { createCollection } from "coral-server/models/helpers";
import { Story } from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import TenantCache from "coral-server/services/tenant/cache";
import {
ScheduledJob,
ScheduledJobCommand,
ScheduledJobGroup,
} from "./scheduled";
const BATCH_SIZE = 500;
// TODO: extract this out to a separate file so it
// can be re-used elsewhere
// TODO: extract this out to a separate file so it can be re-used elsewhere
const collections = {
users: createCollection<User>("users"),
comments: createCollection<Comment>("comments"),
@@ -22,80 +26,75 @@ const collections = {
commentActions: createCollection<CommentAction>("commentActions"),
};
interface Options {
mongo: Db;
mailerQueue: MailerQueue;
tenantCache: TenantCache;
}
export const NAME = "Account Deletion";
export function registerAccountDeletion(
mongo: Db,
mailer: MailerQueue
): CronJob {
const job = new CronJob({
options: Options
): ScheduledJobGroup<Options> {
const job = new ScheduledJob(options, {
name: `Twice Hourly ${NAME}`,
cronTime: "0,30 * * * *",
timeZone: "America/New_York",
start: true,
runOnInit: false,
onTick: deleteScheduledAccounts(mongo, mailer),
command: deleteScheduledAccounts,
});
if (job.running) {
logger.info("account deletion scheduler now running");
}
return job;
return { name: NAME, schedulers: [job] };
}
function deleteScheduledAccounts(mongo: Db, mailer: MailerQueue): CronCommand {
return async () => {
try {
logger.info("checking for accounts that require deletion");
const deleteScheduledAccounts: ScheduledJobCommand<Options> = async ({
log,
mongo,
mailerQueue,
tenantCache,
}) => {
// For each of the tenant's, process their users notifications.
for await (const tenant of tenantCache) {
log = log.child({ tenantID: tenant.id });
// TODO: iterate over tenants in tenant cache
while (true) {
const now = new Date();
const rescheduledDeletionDate = DateTime.fromJSDate(now)
.plus({ hours: 1 })
.toJSDate();
while (true) {
const now = new Date();
const rescheduledDeletionDate = DateTime.fromJSDate(now)
.plus({ hours: 1 })
.toJSDate();
const userResult = await collections.users(mongo).findOneAndUpdate(
{
scheduledDeletionDate: { $lte: now },
const { value: user } = await collections.users(mongo).findOneAndUpdate(
{
tenantID: tenant.id,
scheduledDeletionDate: { $lte: now },
},
{
$set: {
scheduledDeletionDate: rescheduledDeletionDate,
},
{
$set: {
scheduledDeletionDate: rescheduledDeletionDate,
},
},
{
// We want to get back the user with
// modified scheduledDeletionDate
returnOriginal: false,
}
);
if (!userResult.value) {
logger.info("no more users were scheduled for deletion");
break;
},
{
// We want to get back the user with
// modified scheduledDeletionDate
returnOriginal: false,
}
const userToDelete = userResult.value;
logger.info(
{ userID: userToDelete.id, tenantID: userToDelete.tenantID },
`deleting user`
);
deleteUser(mongo, mailer, userToDelete.id, userToDelete.tenantID, now);
}
} catch (error) {
logger.error(
{ error },
"an error occurred trying to perform scheduled account deletions"
);
if (!user) {
log.debug("no more users were scheduled for deletion");
break;
}
log.info({ userID: user.id }, "deleting user");
await deleteUser(mongo, mailerQueue, user.id, user.tenantID, now);
}
};
}
}
};
async function executeBulkOperations<T>(
collection: Collection<T>,
operations: any[]
) {
// TODO: (wyattjoh) fix types here to support actual types when upstream changes applied
const bulk: any = collection.initializeUnorderedBulkOp();
for (const operation of operations) {
@@ -110,7 +109,11 @@ interface Batch {
stories: any[];
}
async function deleteUserActionCounts(db: Db, userID: string) {
async function deleteUserActionCounts(
mongo: Db,
userID: string,
tenantID: string
) {
const batch: Batch = {
comments: [],
stories: [],
@@ -118,24 +121,30 @@ async function deleteUserActionCounts(db: Db, userID: string) {
async function processBatch() {
await executeBulkOperations<Comment>(
collections.comments(db),
collections.comments(mongo),
batch.comments
);
batch.comments = [];
await executeBulkOperations<Story>(collections.stories(db), batch.stories);
await executeBulkOperations<Story>(
collections.stories(mongo),
batch.stories
);
batch.stories = [];
}
const cursor = db
.collection("commentActions")
.find({ userID, actionType: "REACTION" });
const cursor = collections
.commentActions(mongo)
.find({ tenantID, userID, actionType: "REACTION" });
while (await cursor.hasNext()) {
const action = await cursor.next();
if (!action) {
continue;
}
batch.comments.push({
updateOne: {
filter: { id: action.commentID },
filter: { tenantID, id: action.commentID },
update: {
$inc: {
"revisions.$[revisions].actionCounts.REACTION": -1,
@@ -148,7 +157,7 @@ async function deleteUserActionCounts(db: Db, userID: string) {
batch.stories.push({
updateOne: {
filter: { id: action.storyID },
filter: { tenantID, id: action.storyID },
update: {
$inc: {
"commentCounts.action.REACTION": -1,
@@ -169,15 +178,20 @@ async function deleteUserActionCounts(db: Db, userID: string) {
await processBatch();
}
await collections.commentActions(db).deleteMany({
await collections.commentActions(mongo).deleteMany({
tenantID,
userID,
actionType: "REACTION",
});
}
async function deleteUserComments(db: Db, authorID: string) {
await collections.comments(db).updateMany(
{ authorID },
async function deleteUserComments(
mongo: Db,
authorID: string,
tenantID: string
) {
await collections.comments(mongo).updateMany(
{ tenantID, authorID },
{
$set: {
authorID: null,
@@ -190,29 +204,31 @@ async function deleteUserComments(db: Db, authorID: string) {
}
async function deleteUser(
db: Db,
mongo: Db,
mailer: MailerQueue,
userID: string,
tenantID: string,
now: Date
) {
const user = await collections.users(db).findOne({ id: userID, tenantID });
const user = await collections.users(mongo).findOne({ id: userID, tenantID });
if (!user) {
logger.warn({ userID, tenantID }, `could not find user`);
return;
throw new Error("could not find user by ID");
}
const tenant = await collections.tenants(db).findOne({ id: tenantID });
const tenant = await collections.tenants(mongo).findOne({ id: tenantID });
if (!tenant) {
logger.warn({ userID, tenantID }, `could not find tenant`);
return;
throw new Error("could not find tenant by ID");
}
await deleteUserActionCounts(db, userID);
await deleteUserComments(db, userID);
// Delete the user's action counts.
await deleteUserActionCounts(mongo, userID, tenantID);
collections.users(db).updateOne(
{ id: userID },
// Delete the user's comments.
await deleteUserComments(mongo, userID, tenantID);
// Mark the user as deleted.
await collections.users(mongo).updateOne(
{ tenantID, id: userID },
{
$set: {
profiles: [],
@@ -224,14 +240,16 @@ async function deleteUser(
}
);
// If the user has an email, then send them a confirmation that their account
// was deleted.
if (user.email) {
await mailer.add({
tenantID: tenant.id,
tenantID,
message: {
to: user.email,
},
template: {
name: "delete-request-completed",
name: "account-notification/delete-request-completed",
context: {
organizationContactEmail: tenant.organization.contactEmail,
organizationName: tenant.organization.name,
+27 -14
View File
@@ -1,27 +1,40 @@
import { CronJob } from "cron";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig } from "coral-server/services/jwt";
import TenantCache from "coral-server/services/tenant/cache";
import { registerAccountDeletion } from "./accountDeletion";
import { registerNotificationDigesting } from "./notificationDigesting";
export interface ScheduledTasks {
accountDeletion: ScheduledTask;
export interface ScheduledJobGroups {
accountDeletion: ReturnType<typeof registerAccountDeletion>;
notificationDigesting: ReturnType<typeof registerNotificationDigesting>;
}
export interface ScheduledTask {
name: string;
task: CronJob;
interface Options {
mongo: Db;
config: Config;
mailerQueue: MailerQueue;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
}
export default function startScheduledTasks(
mongo: Db,
mailer: MailerQueue
): ScheduledTasks {
return {
accountDeletion: {
name: "Account Deletion",
task: registerAccountDeletion(mongo, mailer),
},
options: Options
): ScheduledJobGroups {
const tasks: ScheduledJobGroups = {
accountDeletion: registerAccountDeletion(options),
notificationDigesting: registerNotificationDigesting(options),
};
for (const { name, schedulers } of Object.values(tasks)) {
for (const scheduler of schedulers) {
scheduler.job.start();
scheduler.log.debug({ jobGroupName: name }, "now started job scheduling");
}
}
return tasks;
}
@@ -0,0 +1,131 @@
import { Db } from "mongodb";
import path from "path";
import { Config } from "coral-server/config";
import { GQLDIGEST_FREQUENCY } from "coral-server/graph/tenant/schema/__generated__/types";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates";
import { JWTSigningConfig } from "coral-server/services/jwt";
import NotificationContext from "coral-server/services/notifications/context";
import TenantCache from "coral-server/services/tenant/cache";
import {
ScheduledJob,
ScheduledJobCommand,
ScheduledJobGroup,
} from "./scheduled";
interface Options {
mongo: Db;
config: Config;
mailerQueue: MailerQueue;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
}
export const NAME = "Notification Digesting";
export function registerNotificationDigesting(
options: Options
): ScheduledJobGroup<Options> {
const hourly = new ScheduledJob(options, {
name: `Hourly ${NAME}`,
cronTime: "0 * * * *",
command: processNotificationDigesting(GQLDIGEST_FREQUENCY.HOURLY),
});
const daily = new ScheduledJob(options, {
name: `Daily ${NAME}`,
cronTime: "0 0 * * *",
command: processNotificationDigesting(GQLDIGEST_FREQUENCY.DAILY),
});
return {
name: NAME,
schedulers: [hourly, daily],
};
}
/**
* DigestElement represents each element that is used for the digesting
* operations.
*/
interface DigestElement {
template: string;
partial: string;
contexts: Array<DigestibleTemplate["context"]>;
}
const processNotificationDigesting = (
frequency: GQLDIGEST_FREQUENCY
): ScheduledJobCommand<Options> => async ({
log,
mongo,
config,
signingConfig,
tenantCache,
mailerQueue,
}) => {
// For each of the tenant's, process their users notifications.
for await (const tenant of tenantCache) {
// Create a notification context to handle processing notifications. Note
// that this will share the current date for all users processed for this
// Tenant, but this is OK, because we're not using this Date for the
// digesting operations.
const ctx = new NotificationContext({
mongo,
config,
signingConfig,
tenant,
log,
});
ctx.log.debug("starting digesting for tenant");
// Process all the notifications for this Tenant.
for await (const user of ctx.digest(frequency)) {
ctx.log.debug(
{ userID: user.id, digests: user.digests.length },
"now processing digests for user"
);
// Group the digests.
const digests = user.digests.reduce(
(acc, entry) => {
const digest = acc.find(d => d.template === entry.template.name);
if (digest) {
digest.contexts.push(entry.template.context);
} else {
acc.push({
template: entry.template.name,
partial: path.basename(entry.template.name),
contexts: [entry.template.context],
});
}
return acc;
},
[] as DigestElement[]
);
// TODO: sort the digest template elements by the digest order.
// Add the email containing the digest information.
mailerQueue.add({
tenantID: tenant.id,
message: {
to: user.email!,
},
template: {
name: "notification/digest",
context: {
digests,
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
unsubscribeURL: await ctx.generateUnsubscribeURL(user),
},
},
});
}
}
};
+6
View File
@@ -0,0 +1,6 @@
import { ScheduledJob } from "./job";
export interface ScheduledJobGroup<T> {
name: string;
schedulers: Array<ScheduledJob<T>>;
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./group";
export * from "./job";
+55
View File
@@ -0,0 +1,55 @@
import { CronCommand, CronJob } from "cron";
import now from "performance-now";
import uuid from "uuid";
import logger, { Logger } from "coral-server/logger";
export type ScheduledJobCommand<T extends {}> = (
ctx: T & { log: Logger }
) => Promise<void>;
interface Options<T extends {}> {
name: string;
cronTime: string;
command: ScheduledJobCommand<T>;
}
export class ScheduledJob<T extends {} = {}> {
public readonly job: CronJob;
public readonly log: Logger;
public readonly context: T;
constructor(context: T, opts: Options<T>) {
this.context = context;
this.log = logger.child({
jobName: opts.name,
jobFrequency: opts.cronTime,
});
this.job = new CronJob({
cronTime: opts.cronTime,
onTick: this.command(opts.command),
timeZone: "America/New_York",
start: false,
runOnInit: false,
});
}
private command(command: ScheduledJobCommand<T>): CronCommand {
return async () => {
const log = this.log.child({ scheduledExecutionID: uuid.v1() });
log.debug("now starting scheduled job");
const start = now();
try {
await command({
...this.context,
log,
});
const processingTime = Math.floor(now() - start);
log.debug({ processingTime }, "now finished scheduled job");
} catch (err) {
const processingTime = Math.floor(now() - start);
log.error({ err, processingTime }, "failed to run scheduled job");
}
};
}
}
+4
View File
@@ -9,6 +9,7 @@ import logger from "coral-server/logger";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { JWTSigningConfig } from "coral-server/services/jwt";
import TenantCache from "coral-server/services/tenant/cache";
@@ -20,6 +21,7 @@ export interface TenantContextOptions extends CommonContextOptions {
tenant: Tenant;
tenantCache: TenantCache;
mailerQueue: MailerQueue;
notifierQueue: NotifierQueue;
scraperQueue: ScraperQueue;
signingConfig?: JWTSigningConfig;
clientID?: string;
@@ -41,6 +43,7 @@ export default class TenantContext extends CommonContext {
constructor({
tenant,
logger: log = logger,
notifierQueue,
...options
}: TenantContextOptions) {
super({
@@ -57,6 +60,7 @@ export default class TenantContext extends CommonContext {
this.clientID = options.clientID;
this.publisher = createPublisher(
this.pubsub,
notifierQueue,
this.tenant.id,
this.clientID
);
@@ -21,6 +21,7 @@ import {
updateAvatar,
updateEmail,
updateEmailByID,
updateNotificationSettings,
updatePassword,
updateRole,
updateUsername,
@@ -46,6 +47,7 @@ import {
GQLSetUsernameInput,
GQLSuspendUserInput,
GQLUpdateEmailInput,
GQLUpdateNotificationSettingsInput,
GQLUpdatePasswordInput,
GQLUpdateUserAvatarInput,
GQLUpdateUserEmailInput,
@@ -53,6 +55,7 @@ import {
GQLUpdateUserRoleInput,
GQLUpdateUserUsernameInput,
} from "../schema/__generated__/types";
import { WithoutMutationID } from "./util";
export const Users = (ctx: TenantContext) => ({
invite: async ({ role, emails }: GQLInviteUsersInput) =>
@@ -178,6 +181,9 @@ export const Users = (ctx: TenantContext) => ({
input.email,
input.password
),
updateNotificationSettings: async (
input: WithoutMutationID<GQLUpdateNotificationSettingsInput>
) => updateNotificationSettings(ctx.mongo, ctx.tenant, ctx.user!, input),
updateUserAvatar: async (input: GQLUpdateUserAvatarInput) =>
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
@@ -17,10 +17,10 @@ import {
hasPublishedStatus,
} from "coral-server/models/comment/helpers";
import { createConnection } from "coral-server/models/helpers";
import { getURLWithCommentID } from "coral-server/models/story";
import { getCommentEditableUntilDate } from "coral-server/services/comments";
import TenantContext from "../context";
import { getURLWithCommentID } from "./util";
export const maybeLoadOnlyID = (
ctx: TenantContext,
@@ -25,6 +25,14 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
},
clientMutationId: input.clientMutationId,
}),
updateNotificationSettings: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
user: await ctx.mutators.Users.updateNotificationSettings(input),
clientMutationId,
}),
updateSettings: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.update(input),
clientMutationId: input.clientMutationId,
@@ -1,13 +1,22 @@
import { SubscriptionToCommentCreatedResolver } from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentCreatedInput extends SubscriptionPayload {
storyID: string;
commentID: string;
}
export type CommentCreatedSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_CREATED,
CommentCreatedInput
>;
export const commentCreated: SubscriptionToCommentCreatedResolver<
CommentCreatedInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_CREATED, {
@@ -4,7 +4,11 @@ import {
} from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentEnteredModerationQueueInput
extends SubscriptionPayload {
@@ -13,6 +17,11 @@ export interface CommentEnteredModerationQueueInput
storyID: string;
}
export type CommentEnteredModerationQueueSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
CommentEnteredModerationQueueInput
>;
export const commentEnteredModerationQueue: SubscriptionToCommentEnteredModerationQueueResolver<
CommentEnteredModerationQueueInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, {
@@ -4,7 +4,11 @@ import {
} from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentLeftModerationQueueInput extends SubscriptionPayload {
queue: GQLMODERATION_QUEUE;
@@ -12,6 +16,11 @@ export interface CommentLeftModerationQueueInput extends SubscriptionPayload {
storyID: string;
}
export type CommentLeftModerationQueueSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
CommentLeftModerationQueueInput
>;
export const commentLeftModerationQueue: SubscriptionToCommentLeftModerationQueueResolver<
CommentLeftModerationQueueInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, {
@@ -1,13 +1,22 @@
import { SubscriptionToCommentReplyCreatedResolver } from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentReplyCreatedInput extends SubscriptionPayload {
ancestorIDs: string[];
commentID: string;
}
export type CommentReplyCreatedSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
CommentReplyCreatedInput
>;
export const commentReplyCreated: SubscriptionToCommentReplyCreatedResolver<
CommentReplyCreatedInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, {
@@ -4,7 +4,11 @@ import {
} from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentStatusUpdatedInput extends SubscriptionPayload {
newStatus: GQLCOMMENT_STATUS;
@@ -13,6 +17,11 @@ export interface CommentStatusUpdatedInput extends SubscriptionPayload {
commentID: string;
}
export type CommentStatusUpdatedSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
CommentStatusUpdatedInput
>;
export const commentStatusUpdated: SubscriptionToCommentStatusUpdatedResolver<
CommentStatusUpdatedInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, {
@@ -1,8 +1,8 @@
import { CommentCreatedInput } from "./commentCreated";
import { CommentEnteredModerationQueueInput } from "./commentEnteredModerationQueue";
import { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue";
import { CommentReplyCreatedInput } from "./commentReplyCreated";
import { CommentStatusUpdatedInput } from "./commentStatusUpdated";
import { CommentCreatedSubscription } from "./commentCreated";
import { CommentEnteredModerationQueueSubscription } from "./commentEnteredModerationQueue";
import { CommentLeftModerationQueueSubscription } from "./commentLeftModerationQueue";
import { CommentReplyCreatedSubscription } from "./commentReplyCreated";
import { CommentStatusUpdatedSubscription } from "./commentStatusUpdated";
export enum SUBSCRIPTION_CHANNELS {
COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE",
@@ -25,23 +25,8 @@ export interface SubscriptionType<
}
export type SUBSCRIPTION_INPUT =
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
CommentEnteredModerationQueueInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
CommentLeftModerationQueueInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
CommentStatusUpdatedInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
CommentReplyCreatedInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_CREATED,
CommentCreatedInput
>;
| CommentEnteredModerationQueueSubscription
| CommentLeftModerationQueueSubscription
| CommentStatusUpdatedSubscription
| CommentReplyCreatedSubscription
| CommentCreatedSubscription;
@@ -1,9 +1,7 @@
import { GraphQLResolveInfo } from "graphql";
import graphqlFields from "graphql-fields";
import { pull } from "lodash";
import { URL } from "url";
import { parseQuery, stringifyQuery } from "coral-common/utils";
import { constructTenantURL, reconstructURL } from "coral-server/app/url";
import TenantContext from "../context";
@@ -17,20 +15,6 @@ export function getRequestedFields<T>(info: GraphQLResolveInfo) {
return pull(Object.keys(graphqlFields<T>(info)), "__typename");
}
/**
* getURLWithCommentID returns the url with the comment id.
*
* @param storyURL url of the story
* @param commentID id of the comment
*/
export function getURLWithCommentID(storyURL: string, commentID?: string) {
const url = new URL(storyURL);
const query = parseQuery(url.search);
url.search = stringifyQuery({ ...query, commentID });
return url.toString();
}
export function reconstructTenantURLResolver<T = any>(path: string) {
return (parent: T, args: {}, ctx: TenantContext) => {
// If the request is available, then prefer it over building from the tenant
@@ -1088,11 +1088,11 @@ type CommenterAccountFeatures {
"""
changeUsername: Boolean!
"""
downloadComments when true, user may download their comment history
downloadComments when true, user may download their comment history
"""
downloadComments: Boolean!
"""
deleteAccount when true, non-sso user may permanently delete their account
deleteAccount when true, non-sso user may permanently delete their account
"""
deleteAccount: Boolean!
}
@@ -1535,6 +1535,60 @@ enum USER_STATUS {
SUSPENDED
}
enum DIGEST_FREQUENCY {
"""
NONE will have the notifications send immediatly rather than bundling for digesting.
"""
NONE
"""
DAILY will queue up the notifications and send them daily.
"""
DAILY
"""
HOURLY will queue up the notifications and send them hourly.
"""
HOURLY
}
"""
UserNotificationSettings stores the notification settings for a given User.
"""
type UserNotificationSettings {
"""
onReply, when true, will enable notifications to be sent to users that have
replies to their comments.
"""
onReply: Boolean!
"""
onFeatured, when true, will enable notifications to be sent to users that have
their comment's featured.
"""
onFeatured: Boolean!
"""
onStaffReplies when true, will enable notifications to be sent to users that
have a staff member reply to their comments. These notifications will
supercede notifications that would have been sent for a basic reply
notification.
"""
onStaffReplies: Boolean!
"""
onModeration when true, will enable notifications to be sent to users when a
comment that they wrote that was previously unpublished, becomes published due
to a moderator action.
"""
onModeration: Boolean!
"""
digestFrequency is the frequency to send notifications.
"""
digestFrequency: DIGEST_FREQUENCY!
}
"""
User is someone that leaves Comments, and logs in.
"""
@@ -1655,7 +1709,11 @@ type User {
tokens lists the access tokens associated with the account.
"""
tokens: [Token!]!
@auth(roles: [ADMIN], userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(
roles: [ADMIN]
userIDField: "id"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
ignoredUsers will return the list of ignored users.
@@ -1667,6 +1725,12 @@ type User {
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
notifications stores the notification settings for the given User.
"""
notifications: UserNotificationSettings!
@auth(userIDField: "id", permit: [SUSPENDED, BANNED])
"""
createdAt is the time that the User was created at.
"""
@@ -2503,6 +2567,61 @@ type Query {
## Mutations
################################################################################
##################
## updateNotificationSettings
##################
input UpdateNotificationSettingsInput {
"""
onReply, when true, will enable notifications to be sent to users that have
replies to their comments.
"""
onReply: Boolean
"""
onFeatured, when true, will enable notifications to be sent to users that have
their comment's featured.
"""
onFeatured: Boolean
"""
onStaffReplies when true, will enable notifications to be sent to users that
have a staff member reply to their comments. These notifications will
supercede notifications that would have been sent for a basic reply
notification.
"""
onStaffReplies: Boolean
"""
onModeration when true, will enable notifications to be sent to users when a
comment that they wrote that was previously unpublished, becomes published due
to a moderator action.
"""
onModeration: Boolean
"""
digestFrequency is the frequency to send notifications.
"""
digestFrequency: DIGEST_FREQUENCY
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateNotificationSettingsPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## createComment
##################
@@ -3140,11 +3259,11 @@ input CommenterAccountFeaturesInput {
"""
changeUsername: Boolean
"""
downloadComments when true, user may download their comment history
downloadComments when true, user may download their comment history
"""
downloadComments: Boolean
"""
deleteAccount when true, non-sso user may permanently delete their account
deleteAccount when true, non-sso user may permanently delete their account
"""
deleteAccount: Boolean
}
@@ -4887,7 +5006,9 @@ type Mutation {
before. This mutation will fail if the username is already set.
"""
setUsername(input: SetUsernameInput!): SetUsernamePayload!
@auth(permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION])
@auth(
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
)
"""
updateUsername will set the username on the current User if they have not set one
@@ -4959,6 +5080,14 @@ type Mutation {
"""
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload! @auth
"""
updateNotificationSettings can be used to update the notification settings for
the current logged in user.
"""
updateNotificationSettings(
input: UpdateNotificationSettingsInput!
): UpdateNotificationSettingsPayload! @auth
"""
updateUserEmail allows administrators to update a given User's email address
to the one provided.
@@ -5023,7 +5152,8 @@ type Mutation {
"""
requestCommentsDownload(
input: RequestCommentsDownloadInput!
): RequestCommentsDownloadPayload! @auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
): RequestCommentsDownloadPayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
"""
requestUserCommentsDownload allows a user to request to download their comments.
@@ -3,12 +3,13 @@ import { RedisPubSub } from "graphql-redis-subscriptions";
import { createSubscriptionChannelName } from "coral-server/graph/tenant/resolvers/Subscription/helpers";
import { SUBSCRIPTION_INPUT } from "coral-server/graph/tenant/resolvers/Subscription/types";
import logger from "coral-server/logger";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
/**
* createPublisher will create a new Publisher that can be used to send events
* over the pubsub broker to facilitate live updates.
* over the pubsub broker to facilitate live updates and notifications.
*
* @param pubsub the pubsub broker to be used to facilitate the publish action
* @param tenantID the ID of the Tenant where the event will be published with
@@ -16,13 +17,23 @@ export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
*/
export const createPublisher = (
pubsub: RedisPubSub,
notifier: NotifierQueue,
tenantID: string,
clientID?: string
): Publisher => async ({ channel, payload }) => {
): Publisher => async input => {
const { channel, payload } = input;
logger.trace({ channel, tenantID, clientID }, "publishing event");
return pubsub.publish(createSubscriptionChannelName(tenantID, channel), {
...payload,
clientID,
});
// Start the publishing operation out to all affected subscribers.
await Promise.all([
// Publish to the underlying pubsub system for subscriptions.
pubsub.publish(createSubscriptionChannelName(tenantID, channel), {
...payload,
clientID,
}),
// Notify the notifications queue so we can offload notification processing
// to it.
notifier.add({ tenantID, input }),
]);
};
@@ -38,7 +38,7 @@ import { getOperationMetadata } from "coral-server/graph/common/extensions/helpe
import { getPersistedQuery } from "coral-server/graph/tenant/persisted";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import { userIsStaff } from "coral-server/models/user/helpers";
import { hasStaffRole } from "coral-server/models/user/helpers";
import { extractTokenFromRequest } from "coral-server/services/jwt";
import TenantContext, { TenantContextOptions } from "../context";
@@ -133,7 +133,7 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
// TODO: (wyattjoh) if the story settings can only disable, and not
// enable live updates (as it takes precedence over global settings)
// then we can add a check for `!tenant.live.enabled` here too.
if (!opts.user || !userIsStaff(opts.user)) {
if (!opts.user || !hasStaffRole(opts.user)) {
throw new LiveUpdatesDisabled();
}
}
+26 -9
View File
@@ -15,14 +15,17 @@ import { JSONErrorHandler } from "coral-server/app/middleware/error";
import { accessLogger, errorLogger } from "coral-server/app/middleware/logging";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import config, { Config } from "coral-server/config";
import startScheduledTasks, { ScheduledTasks } from "coral-server/cron";
import startScheduledTasks, { ScheduledJobGroups } from "coral-server/cron";
import { createPubSubClient } from "coral-server/graph/common/subscriptions/pubsub";
import getTenantSchema from "coral-server/graph/tenant/schema";
import { createSubscriptionServer } from "coral-server/graph/tenant/subscriptions/server";
import logger from "coral-server/logger";
import { createQueue, TaskQueue } from "coral-server/queue";
import { I18n } from "coral-server/services/i18n";
import { createJWTSigningConfig } from "coral-server/services/jwt";
import {
createJWTSigningConfig,
JWTSigningConfig,
} from "coral-server/services/jwt";
import { createMetrics } from "coral-server/services/metrics";
import { createMongoDB } from "coral-server/services/mongodb";
import { ensureIndexes } from "coral-server/services/mongodb/indexes";
@@ -66,7 +69,8 @@ class Server {
// bind to the requested port to serve websocket traffic.
public subscriptionServer: SubscriptionServer;
public scheduledTasks: ScheduledTasks;
// scheduledTasks are tasks managed by cron.
public scheduledTasks: ScheduledJobGroups;
// tasks stores a reference to the queues that can process operations.
private tasks: TaskQueue;
@@ -92,6 +96,9 @@ class Server {
// i18n is the server reference to the i18n framework.
private i18n: I18n;
// signingConfig is the server reference to the signing configuration.
private signingConfig: JWTSigningConfig;
constructor(options: ServerOptions) {
this.parentApp = express();
@@ -110,6 +117,9 @@ class Server {
// Setup the translation framework.
this.i18n = new I18n(defaultLocale);
// Create the signing config.
this.signingConfig = createJWTSigningConfig(this.config);
}
/**
@@ -148,6 +158,7 @@ class Server {
mongo: this.mongo,
tenantCache: this.tenantCache,
i18n: this.i18n,
signingConfig: this.signingConfig,
});
// Create the pubsub client.
@@ -182,6 +193,16 @@ class Server {
// Launch all of the job processors.
this.tasks.mailer.process();
this.tasks.scraper.process();
this.tasks.notifier.process();
// Start up the cron job processors.
this.scheduledTasks = startScheduledTasks({
mongo: this.mongo,
config: this.config,
mailerQueue: this.tasks.mailer,
tenantCache: this.tenantCache,
signingConfig: this.signingConfig,
});
// If we are running in concurrency mode, and we are the master, we should
// setup the aggregator for the cluster metrics.
@@ -257,9 +278,6 @@ class Server {
// Ensure we have an app to bind to.
parent = parent ? parent : this.parentApp;
// Create the signing config.
const signingConfig = createJWTSigningConfig(this.config);
// Disables the client routes to serve bundles etc. Useful for developing with
// Webpack Dev Server.
const disableClientRoutes = this.config.get("disable_client_routes");
@@ -275,13 +293,14 @@ class Server {
pubsub: this.pubsub,
mongo: this.mongo,
redis: this.redis,
signingConfig,
signingConfig: this.signingConfig,
tenantCache: this.tenantCache,
config: this.config,
schema: this.schema,
i18n: this.i18n,
mailerQueue: this.tasks.mailer,
scraperQueue: this.tasks.scraper,
notifierQueue: this.tasks.notifier,
disableClientRoutes,
persistedQueryCache,
persistedQueriesRequired:
@@ -310,8 +329,6 @@ class Server {
);
logger.info({ port }, "now listening");
this.scheduledTasks = startScheduledTasks(this.mongo, this.tasks.mailer);
}
}
+47 -30
View File
@@ -1,66 +1,61 @@
email-notification-footer =
# Account Notifications
email-footer-accountNotification =
Sent by <a data-l10n-name="organizationLink">{ $organizationName }</a>
email-notification-template-forgotPassword =
email-subject-accountNotificationForgotPassword = Password Reset Request
email-template-accountNotificationForgotPassword =
Hello { $username },<br/><br/>
We received a request to reset your password on <a data-l10n-name="organizationName">{ $organizationName }</a>.<br/><br/>
Please follow this link reset your password: <a data-l10n-name="resetYourPassword">Click here to reset your password</a><br/><br/>
<i>If you did not request this, you can ignore this email.</i><br/>
email-subject-forgotPassword = Password Reset Request
email-notification-template-ban =
email-subject-accountNotificationBan = Your account has been banned
email-template-accountNotificationBan =
{ $customMessage }<br /><br />
If you think this has been done in error, please contact our community team
at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-ban = Your account has been banned
email-notification-template-passwordChange =
email-subject-accountNotificationPasswordChange = Your password has been changed
email-template-accountNotificationPasswordChange =
Hello { $username },<br/><br/>
The password on your account has been changed.<br/><br/>
If you did not request this change,
please contact our community team at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-passwordChange = Your password has been changed
email-subject-updateUsername = Your username has been changed
email-notification-template-updateUsername =
email-subject-accountNotificationUpdateUsername = Your username has been changed
email-template-accountNotificationUpdateUsername =
Hello { $username },<br/><br/>
Thank you for updating your { $organizationName } commenter account information. The changes you made are effective immediately. <br /><br />
If you did not make this change please reach out to our community team at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-notification-template-suspend =
email-subject-accountNotificationSuspend = Your account has been suspended
email-template-accountNotificationSuspend =
{ $customMessage }<br/><br/>
If you think this has been done in error, please contact our community team
at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-suspend = Your account has been suspended
email-notification-template-confirmEmail =
email-subject-accountNotificationConfirmEmail = Confirm Email
email-template-accountNotificationConfirmEmail =
Hello { $username },<br/><br/>
To confirm your email address for use with your commenting account at { $organizationName },
please follow this link: <a data-l10n-name="confirmYourEmail">Click here to confirm your email</a><br/><br/>
If you did not recently create a commenting account with
{ $organizationName }, you can safely ignore this email.
email-subject-confirmEmail = Confirm Email
email-subject-invite = Coral Team invite
email-notification-template-invite =
email-subject-accountNotificationInvite = Coral Team invite
email-template-accountNotificationInvite =
You have been invited to join the { $organizationName } team on Coral. Finish
setting up your account <a data-l10n-name="invite">here</a>.
email-subject-downloadComments = Your comments are ready for download
email-notification-template-downloadComments =
email-subject-accountNotificationDownloadComments = Your comments are ready for download
email-template-accountNotificationDownloadComments =
Your comments from { $organizationName } as of { $date } are now available for download.<br /><br />
<a data-l10n-name="downloadUrl">Download my comment archive</a>
email-subject-deleteRequestConfirmation =
email-subject-accountNotificationDeleteRequestConfirmation =
Your commenter account is scheduled to be deleted
email-notification-template-deleteRequestConfirmation =
email-template-accountNotificationDeleteRequestConfirmation =
A request to delete your commenter account was received.
Your account is scheduled for deletion on { $requestDate }.<br /><br />
After that time all of your comments will be removed from the site,
@@ -69,15 +64,15 @@ email-notification-template-deleteRequestConfirmation =
If you change your mind you can sign into your account and cancel the
request before your scheduled account deletion time.
email-subject-deleteRequestCancel =
email-subject-accountNotificationDeleteRequestCancel =
Your account deletion request has been cancelled
email-notification-template-deleteRequestCancel =
email-template-accountNotificationDeleteRequestCancel =
You have cancelled your account deletion request for { $organizationName }.
Your account is now reactivated.
email-subject-deleteRequestCompleted =
email-subject-accountNotificationDeleteRequestCompleted =
Your account has been deleted
email-notification-template-deleteRequestCompleted =
email-template-accountNotificationDeleteRequestCompleted =
Your commenter account for { $organizationName } is now deleted. We're sorry to
see you go!<br /><br />
If you'd like to re-join the discussion in the future, you can sign up for
@@ -86,3 +81,25 @@ email-notification-template-deleteRequestCompleted =
the commenting experience better, please email us at
{ $organizationContactEmail }.
# Notification
email-footer-notification =
Sent by <a data-l10n-name="organizationLink">{ $organizationName }</a> - <a data-l10n-name="unsubscribeLink">Unsubscribe from these notifications</a>
## On Reply
email-subject-notificationOnReply = Someone has replied to your comment on { $organizationName }
email-template-notificationOnReply =
{ $organizationName } - <a data-l10n-name="storyLink">{ $storyTitle }</a><br /><br />
{ $authorUsername } has replied to your comment: <a data-l10n-name="commentPermalink">View comment</a>
## On Staff Reply
email-subject-notificationOnStaffReply = Someone at { $organizationName } has replied to your comment
email-template-notificationOnStaffReply =
{ $organizationName } - <a data-l10n-name="storyLink">{ $storyTitle }</a><br /><br/>
{ $authorUsername } works for { $organizationName } and has replied to your comment: <a data-l10n-name="commentPermalink">View comment</a>
# Notification Digest
email-subject-notificationDigest = Your latest comment activity at { $organizationName }
+14 -16
View File
@@ -1,47 +1,45 @@
email-notification-footer =
# Account Notifications
email-footer-accountNotification =
Enviado por <a data-l10n-name="organizationLink">{ $organizationName }</a>
email-notification-template-forgotPassword =
email-subject-accountNotificationForgotPassword = Pedido de Redefinição de Senha
email-template-accountNotificationForgotPassword =
Olá { $username },<br/><br/>
Recebemos uma solicitação para redefinir sua senha em <a data-l10n-name="organizationName">{ $organizationName }</a>.<br/><br/>
Por favor, use este link redefinir sua senha: <a data-l10n-name="resetYourPassword">Clique aqui para redefinir sua senha</a><br/><br/>
<i>Se você não solicitou isso, ignore este e-mail.</i><br/>
email-subject-forgotPassword = Pedido de Redefinição de Senha
email-notification-template-ban =
email-subject-accountNotificationBan = Sua conta foi banida
email-template-accountNotificationBan =
Olá { $username },<br/><br/>
Alguém com acesso à sua conta violou nossas diretrizes da comunidade.
Como resultado, sua conta foi banida. Você não será mais capaz de
comentar, reagir ou reportar comentários. se você acha que isso foi feito por engano,
entre em contato com nossa equipe da comunidade em <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-ban = Sua conta foi banida
email-notification-template-passwordChange =
email-subject-accountNotificationPasswordChange = Sua senha foi alterada
email-template-accountNotificationPasswordChange =
Olá { $username },<br/><br/>
A senha da sua conta foi alterada.<br/><br/>
Se você não solicitou essa alteração,
entre em contato com nossa equipe da comunidade em <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-passwordChange = Sua senha foi alterada
email-notification-template-suspend =
email-subject-accountNotificationSuspend = A sua conta foi suspensa
email-template-accountNotificationSuspend =
Olá { $username },<br/><br/>
Em concordância com as diretrizes da comunidade { $organizationName }, sua
  conta foi temporariamente suspensa. Durante a suspensão, você será
  incapaz de comentar, interagir ou se envolver com outros comentários. Por favor, junte-se a
  incapaz de comentar, interagir ou se envolver com outros comentários. Por favor, junte-se a
nós em { $until }.<br/><br/>
Se você acha que isso foi feito por engano, entre em contato com nossa equipe em
<a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-suspend = A sua conta foi suspensa
email-notification-template-confirmEmail =
email-subject-accountNotificationConfirmEmail = Confirmar e-mail
email-template-accountNotificationConfirmEmail =
Olá { $username },<br/><br/>
Para confirmar seu endereço de e-mail para usar sua conta nos comentários em { $organizationName }
<a data-l10n-name="confirmYourEmail">Clique aqui</a><br/><br/>
Se você não criou recentemente uma conta de comentários em
{ $organizationName }, você pode ignorar este email.
email-subject-confirmEmail = Confirmar e-mail
+17
View File
@@ -0,0 +1,17 @@
import { URL } from "url";
import { parseQuery, stringifyQuery } from "coral-common/utils";
/**
* getURLWithCommentID returns the url with the comment id.
*
* @param storyURL url of the story
* @param commentID id of the comment
*/
export function getURLWithCommentID(storyURL: string, commentID?: string) {
const url = new URL(storyURL);
const query = parseQuery(url.search);
url.search = stringifyQuery({ ...query, commentID });
return url.toString();
}
+1 -1
View File
@@ -26,8 +26,8 @@ import {
StoryCommentCounts,
} from "./counts";
// Export everything under counts.
export * from "./counts";
export * from "./helpers";
const collection = createCollection<Story>("stories");
+1 -1
View File
@@ -14,7 +14,7 @@ export function roleIsStaff(role: GQLUSER_ROLE) {
return false;
}
export function userIsStaff(user: Pick<User, "role">) {
export function hasStaffRole(user: Pick<User, "role">) {
return roleIsStaff(user.role);
}
+161
View File
@@ -19,10 +19,12 @@ import {
} from "coral-server/errors";
import {
GQLBanStatus,
GQLDIGEST_FREQUENCY,
GQLSuspensionStatus,
GQLTimeRange,
GQLUSER_ROLE,
GQLUsernameStatus,
GQLUserNotificationSettings,
} from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import {
@@ -35,6 +37,7 @@ import {
resolveConnection,
} from "coral-server/models/helpers";
import { TenantResource } from "coral-server/models/tenant";
import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates";
import { getLocalProfile, hasLocalProfile } from "./helpers";
@@ -268,6 +271,24 @@ export interface IgnoredUser {
createdAt: Date;
}
/**
* Digest is the actual digest entry that is created every time a digest is
* queued for aUser.
*/
export interface Digest {
/**
* template is a given digestable template that was generated during the
* notification processing phase. This contains the context typically provided
* to the individual notification emails that are sent.
*/
template: DigestibleTemplate;
/**
* createdAt is the date that the digest entry was created at.
*/
createdAt: Date;
}
/**
* User is someone that leaves Comments, and logs in.
*/
@@ -324,6 +345,17 @@ export interface User extends TenantResource {
*/
role: GQLUSER_ROLE;
/**
* notifications stores the notification settings for the given User.
*/
notifications: GQLUserNotificationSettings;
/**
* digests stores all the notification digests on the User that are scheduled
* to be sent out based on the User's notification preferences.
*/
digests: Digest[];
/**
* status stores the user status information regarding moderation state.
*/
@@ -442,6 +474,8 @@ export type InsertUserInput = Omit<
| "tokens"
| "status"
| "ignoredUsers"
| "notifications"
| "digests"
| "emailVerificationID"
| "createdAt"
> &
@@ -466,6 +500,14 @@ export async function insertUser(
suspension: { history: [] },
ban: { active: false, history: [] },
},
notifications: {
onReply: false,
onFeatured: false,
onModeration: false,
onStaffReplies: false,
digestFrequency: GQLDIGEST_FREQUENCY.NONE,
},
digests: [],
createdAt: now,
};
@@ -1975,3 +2017,122 @@ export async function setUserLastDownloadedAt(
return result.value;
}
export type NotificationSettingsInput = Partial<GQLUserNotificationSettings>;
export async function updateUserNotificationSettings(
mongo: Db,
tenantID: string,
id: string,
settings: NotificationSettingsInput
) {
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
},
{
$set: dotize({
notifications: settings,
}),
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the update operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* insertUserNotificationDigests will push the notification contexts onto the
* User so that notifications can now be queued.
*
* @param mongo the database to put the notification digests into
* @param tenantID the ID of the Tenant that this User exists on
* @param id the ID of the User to insert the digests onto
* @param templates the templates that represent the digests to be inserted
* @param now the current time
*/
export async function insertUserNotificationDigests(
mongo: Db,
tenantID: string,
id: string,
templates: DigestibleTemplate[],
now: Date
) {
// Form the templates into digests to be sent.
const digests: Digest[] = templates.map(template => ({
template,
createdAt: now,
}));
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
},
{
$push: {
digests: { $each: digests },
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the update operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* pullUserNotificationDigests will pull notification digests for a given User
* so it can be added to the mailer queue.
*
* @param mongo the database to pull digests from
* @param tenantID the tenant ID to pull digests for
* @param frequency the frequency that we're scanning for to limit the digest
* operation
*/
export async function pullUserNotificationDigests(
mongo: Db,
tenantID: string,
frequency: GQLDIGEST_FREQUENCY
) {
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
"notifications.digestFrequency": frequency,
digests: { $ne: [] },
},
{ $set: { digests: [] } },
{
// True to return the original document instead of the updated document.
returnOriginal: true,
}
);
return result.value || null;
}
+12 -5
View File
@@ -2,15 +2,15 @@ import Queue from "bull";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { createMailerTask, MailerQueue } from "coral-server/queue/tasks/mailer";
import {
createScraperTask,
ScraperQueue,
} from "coral-server/queue/tasks/scraper";
import { I18n } from "coral-server/services/i18n";
import { JWTSigningConfig } from "coral-server/services/jwt";
import { createRedisClient } from "coral-server/services/redis";
import TenantCache from "coral-server/services/tenant/cache";
import { createMailerTask, MailerQueue } from "./tasks/mailer";
import { createNotifierTask, NotifierQueue } from "./tasks/notifier";
import { createScraperTask, ScraperQueue } from "./tasks/scraper";
const createQueueOptions = async (
config: Config
): Promise<Queue.QueueOptions> => {
@@ -46,11 +46,13 @@ export interface QueueOptions {
config: Config;
tenantCache: TenantCache;
i18n: I18n;
signingConfig: JWTSigningConfig;
}
export interface TaskQueue {
mailer: MailerQueue;
scraper: ScraperQueue;
notifier: NotifierQueue;
}
export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
@@ -61,10 +63,15 @@ export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
// Attach process functions to the various tasks in the queue.
const mailer = createMailerTask(queueOptions, options);
const scraper = createScraperTask(queueOptions, options);
const notifier = createNotifierTask(queueOptions, {
mailerQueue: mailer,
...options,
});
// Return the tasks + client.
return {
mailer,
scraper,
notifier,
};
}
+14 -7
View File
@@ -46,13 +46,20 @@ export default class Task<T, U = any> {
"processing job from queue"
);
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
logger.trace(
{ jobID: job.id, jobName: this.options.jobName },
"processing completed"
);
return promise;
try {
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
logger.trace(
{ jobID: job.id, jobName: this.options.jobName },
"processing completed"
);
return promise;
} catch (err) {
logger.error({ err }, "failed to process job from queue");
throw err;
}
});
logger.trace(
@@ -0,0 +1,37 @@
# mailer
The mailer is responsible for rendering and translating all email messages sent
by Coral.
## Adding a new email
The first step to defining your new email is to add a new email template to
`src/core/server/queue/tasks/mailer/templates/index.ts`. There you can see how
other templates define their requirements, and how you ensure that you get the
required context.
### Templates
Depending on the type of email you're adding, you're likely adding a:
- _Account Notification_ - an email sent as a result of an account action by the
system or an administrator.
- _Notification_ - an email sent to a user based on a notification preference
they've enabled.
There are folders under `templates` for each of these. Use any of the templates
in those folders as an example to craft your own template.
### Translations
Once you've added a template, you need to add a translation into the `src/core/server/locales/${locale}/email.ftl`
file. There are two translation lines you need to add to support a new email:
- _Subject_ - the subject of the email to be sent. Keys for these translations
follow the form `email-subject-${camelCase(templateName)}`.
- For example, for the template name `account-notification/confirm-email`
the subject translation key would become `email-subject-accountNotificationConfirmEmail`.
- _Template_ - the translated body of the email to be sent. Keys for these
translations follow the form: `email-template-${camelCase(templateName)}`.
- For example, for the template name `account-notification/confirm-email`
the subject translation key would become `email-template-accountNotificationConfirmEmail`.
@@ -12,3 +12,7 @@ table {
padding: 10px;
background-color: whitesmoke;
}
.footer {
text-align: center;
}
@@ -7,7 +7,7 @@ import TenantCache from "coral-server/services/tenant/cache";
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
import { Tenant } from "coral-server/models/tenant";
import { Template } from "./templates";
import { EmailTemplate } from "./templates";
// templateDirectory is the directory containing the email templates.
const templateDirectory = path.join(__dirname, "templates");
@@ -23,11 +23,11 @@ export interface MailerContentOptions {
* @param env the nunjucks rendering environment
* @param template the template to render
*/
function render(env: Environment, { name, context }: Template) {
function render(env: Environment, { name, context }: EmailTemplate) {
return new Promise<string>((resolve, reject) =>
env.render(
name + ".html",
{ context, name: camelCase(name) },
{ context, name: camelCase(name), baseName: path.basename(name) },
(err, html) => {
if (err) {
return reject(err);
@@ -90,7 +90,7 @@ export default class MailerContent {
*/
public async generateHTML(
tenant: Tenant,
template: Template
template: EmailTemplate
): Promise<string> {
// Get the environment to render with.
const env = this.getEnvironment(tenant);
+2 -2
View File
@@ -12,13 +12,13 @@ import {
MailerData,
MailProcessorOptions,
} from "./processor";
import { Template } from "./templates";
import { EmailTemplate } from "./templates";
export interface MailerInput {
message: {
to: string;
};
template: Template;
template: EmailTemplate;
tenantID: string;
}
@@ -1,4 +1,5 @@
import { Job } from "bull";
import createDOMPurify from "dompurify";
import { DOMLocalization } from "fluent-dom/compat";
import { FluentBundle } from "fluent/compat";
import { minify } from "html-minifier";
@@ -16,6 +17,7 @@ import { LanguageCode } from "coral-common/helpers/i18n/locales";
import { Config } from "coral-server/config";
import { InternalError } from "coral-server/errors";
import logger from "coral-server/logger";
import { Tenant } from "coral-server/models/tenant";
import { I18n, translate } from "coral-server/services/i18n";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
@@ -98,6 +100,7 @@ function createMessageTranslator(i18n: I18n) {
* @param data data used to send the message
*/
return async (
tenant: Tenant,
templateName: string,
locale: LanguageCode,
fromAddress: string,
@@ -121,22 +124,35 @@ function createMessageTranslator(i18n: I18n) {
// Translate the bundle.
await loc.translateFragment(dom.window.document);
// TODO: (wyattjoh) strip the i18n attributes from the source.
// Grab the rendered HTML from the dom, and juice them.
if (!dom.window.document.documentElement) {
throw new Error("dom did not have a document element");
}
const translatedHTML = dom.window.document.documentElement.outerHTML;
// Configure the purification.
const purify = createDOMPurify<false>(dom.window);
// Strip the l10n attributes from the email HTML.
purify.sanitize(dom.window.document.documentElement, {
ALLOW_DATA_ATTR: false,
WHOLE_DOCUMENT: true,
SANITIZE_DOM: false,
RETURN_DOM: false,
ADD_TAGS: ["link"],
FORBID_TAGS: [],
FORBID_ATTR: [],
IN_PLACE: true,
});
// Juice the HTML to inline resources.
const html = await juiceHTML(translatedHTML);
const html = await juiceHTML(dom.serialize());
// Get the translated subject.
const subject = translate(
bundle,
templateName,
`email-subject-${camelCase(templateName)}`
`email-subject-${camelCase(templateName)}`,
{ organizationName: tenant.organization.name }
);
// Generate the text content of the message from the HTML.
@@ -231,6 +247,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => {
let message: Message;
try {
message = await translateMessage(
tenant,
data.templateName,
tenant.locale,
fromAddress,
@@ -0,0 +1,11 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationBan" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br/><br/>
Someone with access to your account has violated our community guidelines.
As a result, your account has been banned. You will no longer be able to
comment, react or report comments. If you think this has been done in error,
please contact our community team at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationConfirmEmail" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br/><br/>
To confirm your email address for use with your commenting account at {{ context.organizationName }},
please follow this link: <a data-l10n-name="confirmYourEmail" href="{{ context.confirmURL }}">Click here to confirm your email</a><br/><br/>
If you did not recently create a commenting account with
{{ context.organizationName }}, you can safely ignore this email.
</div>
{% endblock %}
@@ -1,4 +1,4 @@
{% extends "layouts/user-notification.html" %}
{% extends "layouts/account-notification.html" %}
{% block content %}
You have cancelled your account deletion request for {{ context.organizationName }}. Your account is now reactivated.
@@ -1,4 +1,4 @@
{% extends "layouts/user-notification.html" %}
{% extends "layouts/account-notification.html" %}
{% block content %}
Your commenter account for {{ context.organizationName }} is now deleted. We're sorry to see you go!
@@ -1,4 +1,4 @@
{% extends "layouts/user-notification.html" %}
{% extends "layouts/account-notification.html" %}
{% block content %}
A request to delete your commenter account was received. Your account is scheduled for deletion on {{ context.requestDate }}.
@@ -0,0 +1,9 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationDownloadComments" data-l10n-args="{{ context | dump }}">
Your comments from {{ context.organizationName }} as of {{ context.date }} are
now available for download.<br /><br />
<a data-l10n-name="downloadUrl" href="{{ context.downloadUrl }}">Download my comment archive</a>
</div>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationForgotPassword" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br/><br/>
We received a request to reset your password on <a data-l10n-name="organizationName" href="{{ context.organizationURL }}"></a>.<br/><br/>
Please follow this link reset your password: <a data-l10n-name="resetYourPassword" href="{{ context.resetURL }}">Click here to reset your password</a><br/><br/>
<i>If you did not request this, you can ignore this email.</i><br/>
</div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationInvite" data-l10n-args="{{ context | dump }}">
You have been invited to join the {{ context.organizationName }} team on Coral. Finish
setting up your account <a data-l10n-name="invite" href="{{ context.inviteURL }}">here</a>.
</div>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationPasswordChange" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br/><br/>
The password on your account has been changed.<br/><br/>
If you did not request this change,
please contact please contact our community team at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
</div>
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationSuspend" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br/><br/>
In accordance with {{ context.organizationName }}'s community guidelines, your
account has been temporarily suspended. During the suspension, you will be
unable to comment, flag or engage with fellow commenters. Please rejoin the
conversation {{ context.until }}.<br/><br/>
If you think this has been done in error, please contact our community team at
<a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "layouts/account-notification.html" %}
{% block content %}
<div data-l10n-id="email-template-accountNotificationUpdateUsername" data-l10n-args="{{ context | dump }}">
Hello {{ context.username }},<br /><br />
Thank you for updating your {{ context.organizationName }} commenter account
information. The changes you made are effective immediately. If you did not make
this change please reach out to <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
</div>
{% endblock %}
@@ -1,9 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
Someone with access to your account has violated our community guidelines.
As a result, your account has been banned. You will no longer be able to
comment, react or report comments. If you think this has been done in error,
please contact our community team at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -1,9 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
To confirm your email address for use with your commenting account at {{ context.organizationName }},
please follow this link: <a data-l10n-name="confirmYourEmail" href="{{ context.confirmURL }}">Click here to confirm your email</a><br/><br/>
If you did not recently create a commenting account with
{{ context.organizationName }}, you can safely ignore this email.
{% endblock %}
@@ -1,7 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Your comments from {{ context.organizationName }} as of {{ context.date }} are
now available for download.<br /><br />
<a data-l10n-name="downloadUrl" href="{{ context.downloadUrl }}">Download my comment archive</a>
{% endblock %}
@@ -1,8 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
We received a request to reset your password on <a data-l10n-name="organizationName" href="{{ context.organizationURL }}"></a>.<br/><br/>
Please follow this link reset your password: <a data-l10n-name="resetYourPassword" href="{{ context.resetURL }}">Click here to reset your password</a><br/><br/>
<i>If you did not request this, you can ignore this email.</i><br/>
{% endblock %}
@@ -1,9 +1,67 @@
interface Template<T extends string, U extends {}> {
interface EmailTemplate<T extends string, U extends {}> {
name: T;
context: U;
}
type UserNotificationContext<T extends string, U extends {}> = Template<
/**
* NotificationContext
*/
type NotificationContext<T extends string, U extends {}> = EmailTemplate<
T,
U & {
organizationURL: string;
organizationName: string;
unsubscribeURL: string;
}
>;
export type OnReplyTemplate = NotificationContext<
"notification/on-reply",
{
storyTitle: string;
storyURL: string;
authorUsername: string;
commentPermalink: string;
}
>;
export type OnStaffReplyTemplate = NotificationContext<
"notification/on-staff-reply",
{
storyTitle: string;
storyURL: string;
authorUsername: string;
commentPermalink: string;
}
>;
export type DigestibleTemplate = OnReplyTemplate | OnStaffReplyTemplate;
type DigestTemplate = NotificationContext<
"notification/digest",
{
digests: Array<{
/**
* partial stores the part of the filename that can be used to tie the
* given notification into a specific template.
*/
partial: string;
/**
* contexts is the array of all the contexts under this partial that
* should be used to create the digest context.
*/
contexts: Array<DigestibleTemplate["context"]>;
}>;
}
>;
/**
* AccountNotificationContext
*/
type AccountNotificationContext<T extends string, U extends {}> = EmailTemplate<
T,
U & {
organizationURL: string;
@@ -11,16 +69,16 @@ type UserNotificationContext<T extends string, U extends {}> = Template<
}
>;
export type ForgotPasswordTemplate = UserNotificationContext<
"forgot-password",
export type ForgotPasswordTemplate = AccountNotificationContext<
"account-notification/forgot-password",
{
username: string;
resetURL: string;
}
>;
export type BanTemplate = UserNotificationContext<
"ban",
export type BanTemplate = AccountNotificationContext<
"account-notification/ban",
{
username: string;
organizationContactEmail: string;
@@ -28,8 +86,8 @@ export type BanTemplate = UserNotificationContext<
}
>;
export type SuspendTemplate = UserNotificationContext<
"suspend",
export type SuspendTemplate = AccountNotificationContext<
"account-notification/suspend",
{
username: string;
until: string;
@@ -38,16 +96,16 @@ export type SuspendTemplate = UserNotificationContext<
}
>;
export type PasswordChangeTemplate = UserNotificationContext<
"password-change",
export type PasswordChangeTemplate = AccountNotificationContext<
"account-notification/password-change",
{
username: string;
organizationContactEmail: string;
}
>;
export type ConfirmEmailTemplate = UserNotificationContext<
"confirm-email",
export type ConfirmEmailTemplate = AccountNotificationContext<
"account-notification/confirm-email",
{
username: string;
confirmURL: string;
@@ -55,15 +113,15 @@ export type ConfirmEmailTemplate = UserNotificationContext<
}
>;
export type InviteEmailTemplate = UserNotificationContext<
"invite",
export type InviteEmailTemplate = AccountNotificationContext<
"account-notification/invite",
{
inviteURL: string;
}
>;
export type DownloadCommentsTemplate = UserNotificationContext<
"download-comments",
export type DownloadCommentsTemplate = AccountNotificationContext<
"account-notification/download-comments",
{
username: string;
date: string;
@@ -71,33 +129,37 @@ export type DownloadCommentsTemplate = UserNotificationContext<
}
>;
export type UpdateUsernameTemplate = UserNotificationContext<
"update-username",
export type UpdateUsernameTemplate = AccountNotificationContext<
"account-notification/update-username",
{
username: string;
organizationContactEmail: string;
}
>;
export type AccountDeletionConfirmation = UserNotificationContext<
"delete-request-confirmation",
export type AccountDeletionConfirmation = AccountNotificationContext<
"account-notification/delete-request-confirmation",
{
requestDate: string;
}
>;
export type AccountDeletionCancellation = UserNotificationContext<
"delete-request-cancel",
export type AccountDeletionCancellation = AccountNotificationContext<
"account-notification/delete-request-cancel",
{}
>;
export type AccountDeletionCompleted = UserNotificationContext<
"delete-request-completed",
export type AccountDeletionCompleted = AccountNotificationContext<
"account-notification/delete-request-completed",
{
organizationContactEmail: string;
}
>;
/**
* Templates
*/
type Templates =
| BanTemplate
| ConfirmEmailTemplate
@@ -109,6 +171,9 @@ type Templates =
| UpdateUsernameTemplate
| AccountDeletionConfirmation
| AccountDeletionCancellation
| AccountDeletionCompleted;
| AccountDeletionCompleted
| OnReplyTemplate
| OnStaffReplyTemplate
| DigestTemplate;
export { Templates as Template };
export { Templates as EmailTemplate };
@@ -1,6 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
You have been invited to join the {{ context.organizationName }} team on Coral. Finish
setting up your account <a data-l10n-name="invite" href="{{ context.inviteURL }}">here</a>.
{% endblock %}
@@ -0,0 +1,7 @@
{% extends "layouts/base.html" %}
{% block footer %}
<div data-l10n-id="email-footer-accountNotification" data-l10n-args="{{ context | dump }}">
Sent by <a data-l10n-name="organizationLink" href="{{ context.organizationURL }}">{{ context.organizationName }}</a>
</div>
{% endblock %}
@@ -9,6 +9,17 @@
<link href="assets/main.css" rel="stylesheet">
</head>
<body>
{% block body %}{% endblock %}
<table>
<tr>
<td class="content">
{% block content %}{% endblock %}
</td>
</tr>
<tr>
<td class="footer">
{% block footer %}{% endblock %}
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,11 @@
{% extends "layouts/base.html" %}
{% block content %}
{% include "notification/partials/" + baseName + ".html" %}
{% endblock %}
{% block footer %}
<div data-l10n-id="email-footer-notification" data-l10n-args="{{ context | dump }}">
Sent by <a data-l10n-name="organizationLink" href="{{ context.organizationURL }}">{{ context.organizationName }}</a> - <a data-l10n-name="unsubscribeLink" href="{{ context.unsubscribeURL }}">Unsubscribe from these notifications</a>
</div>
{% endblock %}
@@ -1,16 +0,0 @@
{% extends "layouts/base.html" %}
{% block body %}
<table>
<tr>
<td class="content" data-l10n-id="email-notification-template-{{ name }}" data-l10n-args="{{ context | dump }}">
{% block content %}{% endblock %}
</td>
</tr>
<tr>
<td align="center" data-l10n-id="email-notification-footer" data-l10n-args="{{ context | dump }}">
Sent by <a data-l10n-name="organizationLink" href="{{ context.organizationURL }}">{{ context.organizationName }}</a>
</td>
</tr>
</table>
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "layouts/notification.html" %}
{% block content %}
{% for digest in context.digests %}
{% for context in digest.contexts %}
{% include "notification/partials/" + digest.partial + ".html" %}
{% endfor %}
{% endfor %}
{% endblock %}
@@ -0,0 +1 @@
{% extends "layouts/notification.html" %}
@@ -0,0 +1 @@
{% extends "layouts/notification.html" %}
@@ -0,0 +1,4 @@
<div data-l10n-id="email-template-notificationOnReply" data-l10n-args="{{ context | dump }}">
{{ context.organizationName }} - <a data-l10n-name="storyLink" href="{{ context.storyURL }}">{{ context.storyTitle }}</a><br /><br/>
{{ context.authorUsername }} has replied to your comment: <a data-l10n-name="commentPermalink" href="{{ context.commentPermalink }}">View comment</a>
</div>
@@ -0,0 +1,4 @@
<div data-l10n-id="email-template-notificationOnStaffReply" data-l10n-args="{{ context | dump }}">
{{ context.organizationName }} - <a data-l10n-name="storyLink" href="{{ context.storyURL }}">{{ context.storyTitle }}</a><br /><br/>
{{ context.authorUsername }} works for {{ context.organizationName }} and has replied to your comment: <a data-l10n-name="commentPermalink" href="{{ context.commentPermalink }}">View comment</a>
</div>
@@ -1,8 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
The password on your account has been changed.<br/><br/>
If you did not request this change,
please contact please contact our community team at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -1,13 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
In accordance with {{ context.organizationName }}'s community guidelines, your
account has been temporarily suspended. During the suspension, you will be
unable to comment, flag or engage with fellow commenters. Please rejoin the
conversation {{ context.until }}.<br/><br/>
If you think this has been done in error, please contact our community team at
<a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -1,7 +0,0 @@
{% extends "layouts/user-notification.html" %}
{% block content %} Hello {{ context.username }},<br /><br />
Thank you for updating your {{ context.organizationName }} commenter account
information. The changes you made are effective immediately. If you did not make
this change please reach out to <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -0,0 +1,71 @@
import Queue from "bull";
import { groupBy } from "lodash";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/tenant/resolvers/Subscription/types";
import logger from "coral-server/logger";
import Task from "coral-server/queue/Task";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig } from "coral-server/services/jwt";
import {
categories,
NotificationCategory,
} from "coral-server/services/notifications/categories";
import TenantCache from "coral-server/services/tenant/cache";
import { createJobProcessor, JOB_NAME, NotifierData } from "./processor";
export const createNotifierTask = (
queue: Queue.QueueOptions,
options: Options
) => new NotifierQueue(queue, options);
interface Options {
mongo: Db;
mailerQueue: MailerQueue;
config: Config;
tenantCache: TenantCache;
signingConfig: JWTSigningConfig;
}
/**
* NotifierQueue is designed to handle creating and queuing notifications
* that could be sent to users.
*/
export class NotifierQueue {
private registry: Record<SUBSCRIPTION_CHANNELS, NotificationCategory[]>;
private task: Task<NotifierData>;
constructor(queue: Queue.QueueOptions, options: Options) {
// Notification categories have been grouped by their event name so that
// each event emitted need only access the associated notification once.
this.registry = groupBy(categories, "event") as Record<
SUBSCRIPTION_CHANNELS,
NotificationCategory[]
>;
this.task = new Task({
jobName: JOB_NAME,
jobProcessor: createJobProcessor({ registry: this.registry, ...options }),
queue,
});
}
public async add(data: NotifierData) {
// Get all the handlers that are active for this channel.
const c = this.registry[data.input.channel];
if (!c || c.length === 0) {
logger.debug(
{ channel: data.input.channel },
"no notifications registered on this channel"
);
return;
}
return this.task.add(data);
}
public process() {
return this.task.process();
}
}
@@ -0,0 +1,43 @@
import { filterSuperseded, SupersededNotification } from "./messages";
describe("filterSuperseded", () => {
it("handles when there is no superseded notifications", () => {
const notifications: SupersededNotification[] = [
{
category: { name: "staffReply", supersedesCategories: ["reply"] },
notification: { userID: "1" },
},
{
category: { name: "staffReply", supersedesCategories: ["reply"] },
notification: { userID: "1" },
},
{
category: { name: "staffReply", supersedesCategories: ["reply"] },
notification: { userID: "2" },
},
];
expect(notifications.filter(filterSuperseded)).toHaveLength(3);
});
it("handles when there is superseded notifications", () => {
const notifications: SupersededNotification[] = [
{
category: { name: "staffReply", supersedesCategories: ["reply"] },
notification: { userID: "1" },
},
{
category: { name: "staffReply", supersedesCategories: ["reply"] },
notification: { userID: "1" },
},
{
category: { name: "reply", supersedesCategories: [] },
notification: { userID: "1" },
},
{
category: { name: "reply", supersedesCategories: [] },
notification: { userID: "2" },
},
];
expect(notifications.filter(filterSuperseded)).toHaveLength(3);
});
});
@@ -0,0 +1,127 @@
import { SUBSCRIPTION_INPUT } from "coral-server/graph/tenant/resolvers/Subscription/types";
import { GQLDIGEST_FREQUENCY } from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import { NotificationCategory } from "coral-server/services/notifications/categories";
import NotificationContext from "coral-server/services/notifications/context";
import { Notification } from "coral-server/services/notifications/notification";
import { MailerQueue } from "../mailer";
import { DigestibleTemplate } from "../mailer/templates";
import { CategoryNotification } from "./processor";
/**
* SupersededNotification is a subset of `CategoryNotification` to provide a
* minimal implementation.
*/
export interface SupersededNotification {
category: Pick<
CategoryNotification["category"],
"name" | "supersedesCategories"
>;
notification: Pick<CategoryNotification["notification"], "userID">;
}
/**
* filterSuperseded will filter all the possible notifications and only send
* those notifications that are not superseded by another type of notification.
*/
export const filterSuperseded = (
{
category: { name },
notification: { userID: destinationUserID },
}: SupersededNotification,
index: number,
notifications: SupersededNotification[]
) =>
!notifications.some(
({
category: { 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 === name
)
);
export const handleHandlers = async (
ctx: NotificationContext,
categories: NotificationCategory[],
input: SUBSCRIPTION_INPUT
): Promise<CategoryNotification[]> => {
const notifications: Array<CategoryNotification | null> = await Promise.all(
categories.map(async category => {
const notification = await category.process(ctx, input.payload);
if (!notification) {
return null;
}
return { category, notification };
})
);
// Filter out the categories that don't have notifications.
return notifications.filter(
notification => notification !== null
) as CategoryNotification[];
};
/**
* 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.
*/
export const processNewNotifications = async (
ctx: NotificationContext,
notifications: Notification[],
mailer: MailerQueue
) => {
// Group all the notifications by user.
const userNotifications: Record<string, DigestibleTemplate[]> = {};
for (const { userID, template } of notifications) {
if (userID in userNotifications) {
userNotifications[userID].push(template);
} else {
userNotifications[userID] = [template];
}
}
// Load all the user's profiles into the context.
await ctx.users.loadMany(Object.keys(userNotifications));
// Send all the notifications for each user.
for (const [userID, templates] of Object.entries(userNotifications)) {
// Get the user from the context (which should have been already loaded from
// before).
const user = await ctx.users.load(userID);
if (!user) {
logger.warn(
{ userID },
"attempted notification for user that wasn't found"
);
continue;
}
if (user.notifications.digestFrequency === GQLDIGEST_FREQUENCY.NONE) {
// Send the notifications for the user now, they don't have digesting
// enabled.
for (const template of templates) {
await mailer.add({
tenantID: ctx.tenant.id,
message: {
to: user.email!,
},
template,
});
}
} else {
// Queue up the notifications to be sent in the next user's digest.
await ctx.addDigests(user.id, templates);
}
}
};
@@ -0,0 +1,128 @@
import { Job } from "bull";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import {
SUBSCRIPTION_CHANNELS,
SUBSCRIPTION_INPUT,
} from "coral-server/graph/tenant/resolvers/Subscription/types";
import logger from "coral-server/logger";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig } from "coral-server/services/jwt";
import { NotificationCategory } from "coral-server/services/notifications/categories";
import NotificationContext from "coral-server/services/notifications/context";
import { Notification } from "coral-server/services/notifications/notification";
import TenantCache from "coral-server/services/tenant/cache";
import {
filterSuperseded,
handleHandlers,
processNewNotifications,
} from "./messages";
export const JOB_NAME = "notifications";
/**
* NotifierData stores the data used by the notification system.
*/
export interface NotifierData {
tenantID: string;
input: SUBSCRIPTION_INPUT;
}
interface Options {
mailerQueue: MailerQueue;
mongo: Db;
config: Config;
registry: Record<SUBSCRIPTION_CHANNELS, NotificationCategory[]>;
tenantCache: TenantCache;
signingConfig: JWTSigningConfig;
}
/**
* CategoryNotification combines the category and notification's to collect the
* appropriate elements together that can be used for digesting purposes.
*/
export interface CategoryNotification {
category: NotificationCategory;
notification: Notification;
}
/**
* createJobProcessor creates the processor that is used to process the
* possible notifications and queueing them up in the mailer if they need to be
* sent.
*
* @param options options for the processor
*/
export const createJobProcessor = ({
mailerQueue,
mongo,
config,
registry,
tenantCache,
signingConfig,
}: Options) => {
return async (job: Job<NotifierData>) => {
const now = new Date();
// Pull the data out of the model.
const { tenantID, input } = job.data;
// Create a new logger to handle logging for this job.
const log = logger.child({
jobID: job.id,
jobName: JOB_NAME,
tenantID,
});
log.debug("starting to handle a notify operation");
try {
// Get all the handlers that are active for this channel.
const categories = registry[input.channel];
if (!categories || categories.length === 0) {
return;
}
// Grab the tenant from the cache.
const tenant = await tenantCache.retrieveByID(tenantID);
if (!tenant) {
throw new Error("tenant not found with ID");
}
// Create a notification context to handle processing notifications.
const ctx = new NotificationContext({
mongo,
config,
signingConfig,
tenant,
now,
});
// For each of the handler's we need to process, we should iterate to
// generate their notifications.
let notifications = await handleHandlers(ctx, categories, input);
// Check to see if some of the other notifications that are queued
// had this notification superseded.
notifications = notifications.filter(filterSuperseded);
// Send all the notifications now.
await processNewNotifications(
ctx,
notifications.map(({ notification }) => notification),
mailerQueue
);
log.debug(
{ notifications: notifications.length },
"notifications handled"
);
} catch (err) {
log.error({ err }, "could not handle the notifications");
throw err;
}
};
};
@@ -39,7 +39,6 @@ const createJobProcessor = ({ mongo }: ScrapeProcessorOptions) => async (
try {
await scrape(mongo, tenantID, storyID, storyURL);
log.debug("scraped the story");
} catch (err) {
log.error({ err }, "could not scrape the story");
@@ -0,0 +1,10 @@
import { NotificationCategory } from "./category";
import { reply } from "./reply";
import { staffReply } from "./staffReply";
/**
* categories stores all the notification categories in a flat list.
*/
const categories: NotificationCategory[] = [...reply, ...staffReply];
export default categories;
@@ -0,0 +1,46 @@
import {
SUBSCRIPTION_CHANNELS,
SUBSCRIPTION_INPUT,
} from "coral-server/graph/tenant/resolvers/Subscription/types";
import NotificationContext from "../context";
import { Notification } from "../notification";
/**
* NotificationCategory define the Category that is used to define a
* Notification type.
*/
export interface NotificationCategory {
/**
* name is the actual name of the notification that can be used to define the
* other category names that are superseded by this one.
*/
name: string;
/**
* process is the actual job processor. It accepts a input payload of the
* correct type and context to create the actual Notification to send.
*/
process: (
ctx: NotificationContext,
input: SUBSCRIPTION_INPUT["payload"]
) => Promise<Notification | null>;
/**
* event is the subscription event that when fired, will trigger this
* notification processor to be called.
*/
event: SUBSCRIPTION_CHANNELS;
/**
* digestOrder, when provided, allows the custom ordering of notifications in
* a digest.
*/
digestOrder: number;
/**
* supersedesCategories is the category names that this specific notification
* category supersedes.
*/
supersedesCategories?: string[];
}
@@ -0,0 +1,3 @@
export { default as categories } from "./categories";
export * from "./categories";
export * from "./category";
@@ -0,0 +1,115 @@
import { CommentReplyCreatedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentReplyCreated";
import { CommentStatusUpdatedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentStatusUpdated";
import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/tenant/resolvers/Subscription/types";
import { hasPublishedStatus } from "coral-server/models/comment";
import { getURLWithCommentID } from "coral-server/models/story";
import NotificationContext from "../context";
import { Notification } from "../notification";
import { NotificationCategory } from "./category";
async function processor(
ctx: NotificationContext,
input: CommentReplyCreatedInput
): Promise<Notification | null> {
const comment = await ctx.comments.load(input.commentID);
if (!comment || !hasPublishedStatus(comment)) {
return null;
}
// Check to see if this is a reply to an existing comment.
if (!comment.parentID) {
return null;
}
// Get the parent comment.
const parent = await ctx.comments.load(comment.parentID);
if (!parent || !hasPublishedStatus(parent) || !parent.authorID) {
return null;
}
// Get the parent comment's author.
const [author, parentAuthor] = await ctx.users.loadMany([
comment.authorID,
parent.authorID,
]);
if (!author || !parentAuthor) {
return null;
}
// Check to see if the target user has notifications enabled for this type.
if (!parentAuthor.notifications.onReply) {
return null;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (parentAuthor.id === author.id) {
return null;
}
// Check to see if this user is ignoring the user who replied to their
// comment.
if (parentAuthor.ignoredUsers.some(user => user.id === author.id)) {
return null;
}
// Get the story that this was written on.
const story = await ctx.stories.load(comment.storyID);
if (!story) {
return null;
}
// Generate the unsubscribe URL.
const unsubscribeURL = await ctx.generateUnsubscribeURL(parentAuthor);
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return {
userID: parentAuthor.id,
template: {
name: "notification/on-reply",
context: {
// We know that the user had a username because they wrote a comment!
authorUsername: author.username!,
commentPermalink: getURLWithCommentID(story.url, comment.id),
storyTitle:
story.metadata && story.metadata.title
? story.metadata.title
: story.url,
storyURL: story.url,
organizationName: ctx.tenant.organization.name,
organizationURL: ctx.tenant.organization.url,
unsubscribeURL,
},
},
};
}
export const reply: NotificationCategory[] = [
{
name: "reply",
process: processor,
event: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
digestOrder: 30,
},
{
name: "reply",
process: async (ctx, input: CommentStatusUpdatedInput) => {
const comment = await ctx.comments.load(input.commentID);
if (!comment || !hasPublishedStatus(comment)) {
return null;
}
// TODO: evaluate storing a history of comment statuses so we can ensure we don't double send.
// We've checked the status, let the processing continue!
return processor(ctx, {
commentID: comment.id,
ancestorIDs: comment.ancestorIDs,
});
},
event: SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
digestOrder: 30,
},
];

Some files were not shown because too many files have changed in this diff Show More