mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 19:17:09 +08:00
[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:
Generated
+13
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
+14
@@ -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();
|
||||
});
|
||||
+1
-1
@@ -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;
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ScheduledJob } from "./job";
|
||||
|
||||
export interface ScheduledJobGroup<T> {
|
||||
name: string;
|
||||
schedulers: Array<ScheduledJob<T>>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./group";
|
||||
export * from "./job";
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
+10
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
StoryCommentCounts,
|
||||
} from "./counts";
|
||||
|
||||
// Export everything under counts.
|
||||
export * from "./counts";
|
||||
export * from "./helpers";
|
||||
|
||||
const collection = createCollection<Story>("stories");
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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 }}.
|
||||
+9
@@ -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 %}
|
||||
+10
@@ -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 %}
|
||||
+10
@@ -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 %}
|
||||
+11
@@ -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
Reference in New Issue
Block a user