diff --git a/package-lock.json b/package-lock.json index c815c0849..e6337e017 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 } diff --git a/package.json b/package.json index 9475878e2..e2e489506 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/client/account/routeConfig.tsx b/src/core/client/account/routeConfig.tsx index 4e3fc7782..c5db29729 100644 --- a/src/core/client/account/routeConfig.tsx +++ b/src/core/client/account/routeConfig.tsx @@ -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( + + + ); diff --git a/src/core/client/account/routes/notifications/Unsubscribe/Sorry.tsx b/src/core/client/account/routes/notifications/Unsubscribe/Sorry.tsx new file mode 100644 index 000000000..c33c6c91f --- /dev/null +++ b/src/core/client/account/routes/notifications/Unsubscribe/Sorry.tsx @@ -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 = ({ reason }) => { + return ( + + + Oops Sorry! + + + {reason ? ( + reason + ) : ( + + + The specified link is invalid, check to see if it was copied + correctly. + + + )} + + + ); +}; + +export default Sorry; diff --git a/src/core/client/account/routes/notifications/Unsubscribe/Success.tsx b/src/core/client/account/routes/notifications/Unsubscribe/Success.tsx new file mode 100644 index 000000000..e822533c7 --- /dev/null +++ b/src/core/client/account/routes/notifications/Unsubscribe/Success.tsx @@ -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 ( + + + + You are now unsubscribed from all notifications + + + + ); +}; + +export default Success; diff --git a/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeForm.tsx b/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeForm.tsx new file mode 100644 index 000000000..d14fd7710 --- /dev/null +++ b/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeForm.tsx @@ -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 = ({ + 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 ( +
+
+ {({ handleSubmit, submitting, submitError }) => ( + + + + + Click below to confirm that you want to unsubscribe from all + notifications. + + + {submitError && ( + + {submitError} + + )} + + + + +
+ )} + +
+ ); +}; + +export default UnsubscribeForm; diff --git a/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeNotificationsMutation.tsx b/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeNotificationsMutation.tsx new file mode 100644 index 000000000..8d77affe6 --- /dev/null +++ b/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeNotificationsMutation.tsx @@ -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("/account/notifications/unsubscribe", { + method: "DELETE", + token: variables.token, + }) +); + +export default UnsubscribeNotificationsMutation; diff --git a/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeRoute.css b/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeRoute.css new file mode 100644 index 000000000..30ac6a8f1 --- /dev/null +++ b/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeRoute.css @@ -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; +} diff --git a/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeRoute.tsx b/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeRoute.tsx new file mode 100644 index 000000000..bf491e3ef --- /dev/null +++ b/src/core/client/account/routes/notifications/Unsubscribe/UnsubscribeRoute.tsx @@ -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("/account/notifications/unsubscribe", { + method: "GET", + token: variables.token, + }) +); + +interface Props { + token: string | undefined; +} + +const UnsubscribeRoute: React.FunctionComponent = ({ token }) => { + const [finished, setFinished] = useState(false); + const onSuccess = useCallback(() => { + setFinished(true); + }, []); + const [state, error] = useToken(fetcher, token); + + if (state === "UNCHECKED") { + return ( +
+
+ +
+
+ ); + } + + if (state !== "VALID" || error) { + return ( +
+
+ +
+
+ ); + } + + return !finished ? ( +
+
+ +
+
+ ) : ( +
+
+ +
+
+ ); +}; + +const enhanced = withRouteConfig({ + render: ({ match, Component }) => ( + + ), +})(UnsubscribeRoute); + +export default enhanced; diff --git a/src/core/client/account/routes/notifications/Unsubscribe/index.ts b/src/core/client/account/routes/notifications/Unsubscribe/index.ts new file mode 100644 index 000000000..a88dcf301 --- /dev/null +++ b/src/core/client/account/routes/notifications/Unsubscribe/index.ts @@ -0,0 +1 @@ +export { default, default as UnsubscribeRoute } from "./UnsubscribeRoute"; diff --git a/src/core/client/account/test/__snapshots__/unsubscribeNotifications.spec.tsx.snap b/src/core/client/account/test/__snapshots__/unsubscribeNotifications.spec.tsx.snap new file mode 100644 index 000000000..ceee687be --- /dev/null +++ b/src/core/client/account/test/__snapshots__/unsubscribeNotifications.spec.tsx.snap @@ -0,0 +1,96 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders form 1`] = ` +
+
+
+
+
+
+
+
+

+ Click below to confirm that you want to unsubscribe from all notifications. +

+ +
+
+
+
+
+
+
+`; + +exports[`renders missing confirm token 1`] = ` +
+
+
+
+
+
+

+ Oops Sorry! +

+
+
+ + The specified link is invalid, check to see if it was copied correctly. + +
+
+
+
+
+
+
+`; diff --git a/src/core/client/account/test/unsubscribeNotifications.spec.tsx b/src/core/client/account/test/unsubscribeNotifications.spec.tsx new file mode 100644 index 000000000..1cb162bab --- /dev/null +++ b/src/core/client/account/test/unsubscribeNotifications.spec.tsx @@ -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 = {} +) { + 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(); +}); diff --git a/src/core/client/admin/routes/Configure/sections/Organization/OrganizationContactEmailConfig.tsx b/src/core/client/admin/routes/Configure/sections/Organization/OrganizationContactEmailConfig.tsx index fc5058a6a..22208fa03 100644 --- a/src/core/client/admin/routes/Configure/sections/Organization/OrganizationContactEmailConfig.tsx +++ b/src/core/client/admin/routes/Configure/sections/Organization/OrganizationContactEmailConfig.tsx @@ -37,7 +37,7 @@ const OrganizationNameConfig: FunctionComponent = ({ disabled }) => ( id="configure-organization-emailExplanation" strong={} > - This E-Mail will be used + This Email will be used = ({ + 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 ( + + + Email Notifications + + + Receive notifications when: + + +
+ {({ + handleSubmit, + submitting, + submitError, + pristine, + submitSucceeded, + }) => ( + + +
+ + + {({ input }) => ( + + + My comment receives a reply + + + )} + + + + + {({ input }) => ( + + + My comment is featured + + + )} + + + + + {({ input }) => ( + + + A staff member replies to my comment + + + )} + + + + + {({ input }) => ( + + + My pending comment has been reviewed + + + )} + + + + + + + Send Notifications: + + + + {({ values }) => ( + + {({ input }) => ( + + + + + + + + + + + + )} + + )} + + + +
+ {submitError && ( + + {submitError} + + )} + {submitSucceeded && ( + + + Your notification settings have been updated + + + )} + + + + + +
+
+ )} + +
+
+ ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment NotificationSettingsContainer_viewer on User { + notifications { + onReply + onFeatured + onStaffReplies + onModeration + digestFrequency + } + } + `, +})(NotificationSettingsContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx b/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx index ea764b3fc..9c962c74f 100644 --- a/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx @@ -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 = ({ viewer, settings }) => ( {settings.accountFeatures.deleteAccount && ( )} + ); @@ -37,6 +39,7 @@ const enhanced = withFragmentContainer({ ...IgnoreUserSettingsContainer_viewer ...DownloadCommentsContainer_viewer ...DeleteAccountContainer_viewer + ...NotificationSettingsContainer_viewer } `, settings: graphql` diff --git a/src/core/client/stream/tabs/Profile/Settings/UpdateNotificationSettingsMutation.ts b/src/core/client/stream/tabs/Profile/Settings/UpdateNotificationSettingsMutation.ts new file mode 100644 index 000000000..103a493f2 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/UpdateNotificationSettingsMutation.ts @@ -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) => + commitMutationPromiseNormalized(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; diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 8473fbdd1..b4669081c 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -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({ hasNextPage: false, }, }, + notifications: { + onReply: false, + onModeration: false, + onStaffReplies: false, + onFeatured: false, + digestFrequency: GQLDIGEST_FREQUENCY.NONE, + }, ignoreable: true, profiles: [ { diff --git a/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap index e5831aa2e..e16b82da7 100644 --- a/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap +++ b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap @@ -495,6 +495,225 @@ all your comments from this site.
+
+

+ Email Notifications +

+

+ Receive notifications when: +

+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+

+ Send Notifications: +

+ + + + + + +
+
+
+
+ +
+
+
+
+
diff --git a/src/core/client/stream/test/profile/settings.spec.tsx b/src/core/client/stream/test/profile/settings.spec.tsx index 78275a641..8ce885421 100644 --- a/src/core/client/stream/test/profile/settings.spec.tsx +++ b/src/core/client/stream/test/profile/settings.spec.tsx @@ -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(viewer, { + notifications, + }), + clientMutationId, + }; + }); + const { testRenderer } = await createTestRenderer({ + resolvers: createResolversStub({ + 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); +}); diff --git a/src/core/client/ui/components/Table/Table.mdx b/src/core/client/ui/components/Table/Table.mdx index 24c4635e8..795190272 100644 --- a/src/core/client/ui/components/Table/Table.mdx +++ b/src/core/client/ui/components/Table/Table.mdx @@ -16,7 +16,7 @@ import { Table, TableBody, TableHead, TableRow, TableCell } from "./"; Username - E-Mail Address + Email Address Member Since diff --git a/src/core/client/ui/components/Table/Table.spec.tsx b/src/core/client/ui/components/Table/Table.spec.tsx index 3f52ded17..3f3c7dae7 100644 --- a/src/core/client/ui/components/Table/Table.spec.tsx +++ b/src/core/client/ui/components/Table/Table.spec.tsx @@ -10,7 +10,7 @@ it("renders correctly", () => { Username - E-Mail Address + Email Address Member Since diff --git a/src/core/client/ui/components/Table/__snapshots__/Table.spec.tsx.snap b/src/core/client/ui/components/Table/__snapshots__/Table.spec.tsx.snap index d5c659866..cc7452766 100644 --- a/src/core/client/ui/components/Table/__snapshots__/Table.spec.tsx.snap +++ b/src/core/client/ui/components/Table/__snapshots__/Table.spec.tsx.snap @@ -16,7 +16,7 @@ exports[`renders correctly 1`] = ` Username - E-Mail Address + Email Address Member Since diff --git a/src/core/server/app/handlers/api/account/index.ts b/src/core/server/app/handlers/api/account/index.ts index 648f118c8..dac8ecfbf 100644 --- a/src/core/server/app/handlers/api/account/index.ts +++ b/src/core/server/app/handlers/api/account/index.ts @@ -1,3 +1,4 @@ export * from "./confirm"; export * from "./invite"; export * from "./download"; +export * from "./notifications"; diff --git a/src/core/server/app/handlers/api/account/notifications.ts b/src/core/server/app/handlers/api/account/notifications.ts new file mode 100644 index 000000000..9848017c1 --- /dev/null +++ b/src/core/server/app/handlers/api/account/notifications.ts @@ -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); + } + }; +}; diff --git a/src/core/server/app/handlers/api/auth/local/forgot.ts b/src/core/server/app/handlers/api/auth/local/forgot.ts index f1fa5d6c8..0d14827d3 100644 --- a/src/core/server/app/handlers/api/auth/local/forgot.ts +++ b/src/core/server/app/handlers/api/auth/local/forgot.ts @@ -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. diff --git a/src/core/server/app/handlers/api/graphql.ts b/src/core/server/app/handlers/api/graphql.ts index 76046e4b5..717a738c2 100644 --- a/src/core/server/app/handlers/api/graphql.ts +++ b/src/core/server/app/handlers/api/graphql.ts @@ -19,6 +19,7 @@ export type GraphMiddlewareOptions = Pick< | "pubsub" | "tenantCache" | "metrics" + | "notifierQueue" >; export const graphQLHandler = ({ diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index ad83f9b42..f152edc51 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -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; diff --git a/src/core/server/app/router/api/account.ts b/src/core/server/app/router/api/account.ts index c924b2138..12cc7f466 100644 --- a/src/core/server/app/router/api/account.ts +++ b/src/core/server/app/router/api/account.ts @@ -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", diff --git a/src/core/server/cron/accountDeletion.ts b/src/core/server/cron/accountDeletion.ts index 517a9453f..a13195477 100644 --- a/src/core/server/cron/accountDeletion.ts +++ b/src/core/server/cron/accountDeletion.ts @@ -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("users"), comments: createCollection("comments"), @@ -22,80 +26,75 @@ const collections = { commentActions: createCollection("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 { + 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 = 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( collection: Collection, 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( - collections.comments(db), + collections.comments(mongo), batch.comments ); batch.comments = []; - await executeBulkOperations(collections.stories(db), batch.stories); + await executeBulkOperations( + 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, diff --git a/src/core/server/cron/index.ts b/src/core/server/cron/index.ts index e5dd16ab5..b9f328fdd 100644 --- a/src/core/server/cron/index.ts +++ b/src/core/server/cron/index.ts @@ -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; + notificationDigesting: ReturnType; } -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; } diff --git a/src/core/server/cron/notificationDigesting.ts b/src/core/server/cron/notificationDigesting.ts new file mode 100644 index 000000000..f56cab314 --- /dev/null +++ b/src/core/server/cron/notificationDigesting.ts @@ -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 { + 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; +} + +const processNotificationDigesting = ( + frequency: GQLDIGEST_FREQUENCY +): ScheduledJobCommand => 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), + }, + }, + }); + } + } +}; diff --git a/src/core/server/cron/scheduled/group.ts b/src/core/server/cron/scheduled/group.ts new file mode 100644 index 000000000..d2892cffc --- /dev/null +++ b/src/core/server/cron/scheduled/group.ts @@ -0,0 +1,6 @@ +import { ScheduledJob } from "./job"; + +export interface ScheduledJobGroup { + name: string; + schedulers: Array>; +} diff --git a/src/core/server/cron/scheduled/index.ts b/src/core/server/cron/scheduled/index.ts new file mode 100644 index 000000000..8f79f490e --- /dev/null +++ b/src/core/server/cron/scheduled/index.ts @@ -0,0 +1,2 @@ +export * from "./group"; +export * from "./job"; diff --git a/src/core/server/cron/scheduled/job.ts b/src/core/server/cron/scheduled/job.ts new file mode 100644 index 000000000..83855d98a --- /dev/null +++ b/src/core/server/cron/scheduled/job.ts @@ -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 = ( + ctx: T & { log: Logger } +) => Promise; + +interface Options { + name: string; + cronTime: string; + command: ScheduledJobCommand; +} + +export class ScheduledJob { + public readonly job: CronJob; + public readonly log: Logger; + public readonly context: T; + + constructor(context: T, opts: Options) { + 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): 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"); + } + }; + } +} diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 92f8e78ac..b4ad0502e 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -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 ); diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index 71c0cf081..1f1a6020a 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -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 + ) => 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) => diff --git a/src/core/server/graph/tenant/resolvers/Comment.ts b/src/core/server/graph/tenant/resolvers/Comment.ts index 53e14e7fc..54a543e25 100644 --- a/src/core/server/graph/tenant/resolvers/Comment.ts +++ b/src/core/server/graph/tenant/resolvers/Comment.ts @@ -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, diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index 863dd7003..bf0c81236 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -25,6 +25,14 @@ export const Mutation: Required> = { }, 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, diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentCreated.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentCreated.ts index d23a87cfb..7a51f32a3 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/commentCreated.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentCreated.ts @@ -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, { diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts index 2d00f917e..1f7030c35 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentEnteredModerationQueue.ts @@ -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, { diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts index 53542a2e2..4e1367e20 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentLeftModerationQueue.ts @@ -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, { diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentReplyCreated.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentReplyCreated.ts index ea7fcaab8..012eb758d 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/commentReplyCreated.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentReplyCreated.ts @@ -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, { diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts index a2c0f3447..1804c69d2 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentStatusUpdated.ts @@ -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, { diff --git a/src/core/server/graph/tenant/resolvers/Subscription/types.ts b/src/core/server/graph/tenant/resolvers/Subscription/types.ts index c9977a18f..84e7bb23f 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/types.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/types.ts @@ -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; diff --git a/src/core/server/graph/tenant/resolvers/util.ts b/src/core/server/graph/tenant/resolvers/util.ts index ce9e605be..f4dba3138 100644 --- a/src/core/server/graph/tenant/resolvers/util.ts +++ b/src/core/server/graph/tenant/resolvers/util.ts @@ -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(info: GraphQLResolveInfo) { return pull(Object.keys(graphqlFields(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(path: string) { return (parent: T, args: {}, ctx: TenantContext) => { // If the request is available, then prefer it over building from the tenant diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 3c4734232..d4e12b21d 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -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. diff --git a/src/core/server/graph/tenant/subscriptions/publisher.ts b/src/core/server/graph/tenant/subscriptions/publisher.ts index 5736f8ddc..79d20f62f 100644 --- a/src/core/server/graph/tenant/subscriptions/publisher.ts +++ b/src/core/server/graph/tenant/subscriptions/publisher.ts @@ -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; /** * 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; */ 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 }), + ]); }; diff --git a/src/core/server/graph/tenant/subscriptions/server.ts b/src/core/server/graph/tenant/subscriptions/server.ts index e48c8b396..0419b6ea2 100644 --- a/src/core/server/graph/tenant/subscriptions/server.ts +++ b/src/core/server/graph/tenant/subscriptions/server.ts @@ -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(); } } diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 34f41a34a..137b242e2 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -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); } } diff --git a/src/core/server/locales/en-US/email.ftl b/src/core/server/locales/en-US/email.ftl index c44df23fc..cda671928 100644 --- a/src/core/server/locales/en-US/email.ftl +++ b/src/core/server/locales/en-US/email.ftl @@ -1,66 +1,61 @@ -email-notification-footer = +# Account Notifications + +email-footer-accountNotification = Sent by { $organizationName } -email-notification-template-forgotPassword = +email-subject-accountNotificationForgotPassword = Password Reset Request +email-template-accountNotificationForgotPassword = Hello { $username },

We received a request to reset your password on { $organizationName }.

Please follow this link reset your password: Click here to reset your password

If you did not request this, you can ignore this email.
-email-subject-forgotPassword = Password Reset Request - -email-notification-template-ban = +email-subject-accountNotificationBan = Your account has been banned +email-template-accountNotificationBan = { $customMessage }

If you think this has been done in error, please contact our community team at { $organizationContactEmail }. -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 },

The password on your account has been changed.

If you did not request this change, please contact our community team at { $organizationContactEmail }. -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 },

Thank you for updating your { $organizationName } commenter account information. The changes you made are effective immediately.

If you did not make this change please reach out to our community team at { $organizationContactEmail }. -email-notification-template-suspend = +email-subject-accountNotificationSuspend = Your account has been suspended +email-template-accountNotificationSuspend = { $customMessage }

If you think this has been done in error, please contact our community team at { $organizationContactEmail }. -email-subject-suspend = Your account has been suspended - -email-notification-template-confirmEmail = +email-subject-accountNotificationConfirmEmail = Confirm Email +email-template-accountNotificationConfirmEmail = Hello { $username },

To confirm your email address for use with your commenting account at { $organizationName }, please follow this link: Click here to confirm your email

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 here. -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.

Download my comment archive -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 }.

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!

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 { $organizationName } - Unsubscribe from these notifications + +## On Reply + +email-subject-notificationOnReply = Someone has replied to your comment on { $organizationName } +email-template-notificationOnReply = + { $organizationName } - { $storyTitle }

+ { $authorUsername } has replied to your comment: View comment + +## On Staff Reply + +email-subject-notificationOnStaffReply = Someone at { $organizationName } has replied to your comment +email-template-notificationOnStaffReply = + { $organizationName } - { $storyTitle }

+ { $authorUsername } works for { $organizationName } and has replied to your comment: View comment + +# Notification Digest + +email-subject-notificationDigest = Your latest comment activity at { $organizationName } diff --git a/src/core/server/locales/pt-BR/email.ftl b/src/core/server/locales/pt-BR/email.ftl index 08adad66b..bc4677c8d 100644 --- a/src/core/server/locales/pt-BR/email.ftl +++ b/src/core/server/locales/pt-BR/email.ftl @@ -1,47 +1,45 @@ -email-notification-footer = +# Account Notifications + +email-footer-accountNotification = Enviado por { $organizationName } -email-notification-template-forgotPassword = +email-subject-accountNotificationForgotPassword = Pedido de Redefinição de Senha +email-template-accountNotificationForgotPassword = Olá { $username },

Recebemos uma solicitação para redefinir sua senha em { $organizationName }.

Por favor, use este link redefinir sua senha: Clique aqui para redefinir sua senha

Se você não solicitou isso, ignore este e-mail.
-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 },

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 { $organizationContactEmail }. -email-subject-ban = Sua conta foi banida - -email-notification-template-passwordChange = +email-subject-accountNotificationPasswordChange = Sua senha foi alterada +email-template-accountNotificationPasswordChange = Olá { $username },

A senha da sua conta foi alterada.

Se você não solicitou essa alteração, entre em contato com nossa equipe da comunidade em { $organizationContactEmail }. -email-subject-passwordChange = Sua senha foi alterada - -email-notification-template-suspend = +email-subject-accountNotificationSuspend = A sua conta foi suspensa +email-template-accountNotificationSuspend = Olá { $username },

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 }.

Se você acha que isso foi feito por engano, entre em contato com nossa equipe em { $organizationContactEmail }. -email-subject-suspend = A sua conta foi suspensa - -email-notification-template-confirmEmail = +email-subject-accountNotificationConfirmEmail = Confirmar e-mail +email-template-accountNotificationConfirmEmail = Olá { $username },

Para confirmar seu endereço de e-mail para usar sua conta nos comentários em { $organizationName } Clique aqui

Se você não criou recentemente uma conta de comentários em { $organizationName }, você pode ignorar este email. -email-subject-confirmEmail = Confirmar e-mail diff --git a/src/core/server/models/story/helpers.ts b/src/core/server/models/story/helpers.ts new file mode 100644 index 000000000..cab52a844 --- /dev/null +++ b/src/core/server/models/story/helpers.ts @@ -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(); +} diff --git a/src/core/server/models/story/index.ts b/src/core/server/models/story/index.ts index 436344759..02165e088 100644 --- a/src/core/server/models/story/index.ts +++ b/src/core/server/models/story/index.ts @@ -26,8 +26,8 @@ import { StoryCommentCounts, } from "./counts"; -// Export everything under counts. export * from "./counts"; +export * from "./helpers"; const collection = createCollection("stories"); diff --git a/src/core/server/models/user/helpers.ts b/src/core/server/models/user/helpers.ts index b4fec4940..cec072dae 100644 --- a/src/core/server/models/user/helpers.ts +++ b/src/core/server/models/user/helpers.ts @@ -14,7 +14,7 @@ export function roleIsStaff(role: GQLUSER_ROLE) { return false; } -export function userIsStaff(user: Pick) { +export function hasStaffRole(user: Pick) { return roleIsStaff(user.role); } diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 014172373..bf47c4acb 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -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; + +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; +} diff --git a/src/core/server/queue/index.ts b/src/core/server/queue/index.ts index 50ce7725a..3561ad5e1 100644 --- a/src/core/server/queue/index.ts +++ b/src/core/server/queue/index.ts @@ -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 => { @@ -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 { @@ -61,10 +63,15 @@ export async function createQueue(options: QueueOptions): Promise { // 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, }; } diff --git a/src/core/server/queue/tasks/Task.ts b/src/core/server/queue/tasks/Task.ts index bfd77fdfb..df86ec389 100644 --- a/src/core/server/queue/tasks/Task.ts +++ b/src/core/server/queue/tasks/Task.ts @@ -46,13 +46,20 @@ export default class Task { "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( diff --git a/src/core/server/queue/tasks/mailer/README.md b/src/core/server/queue/tasks/mailer/README.md new file mode 100644 index 000000000..dae8800e2 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/README.md @@ -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`. diff --git a/src/core/server/queue/tasks/mailer/assets/main.css b/src/core/server/queue/tasks/mailer/assets/main.css index 39f2ddddb..ae87dfe19 100644 --- a/src/core/server/queue/tasks/mailer/assets/main.css +++ b/src/core/server/queue/tasks/mailer/assets/main.css @@ -12,3 +12,7 @@ table { padding: 10px; background-color: whitesmoke; } + +.footer { + text-align: center; +} diff --git a/src/core/server/queue/tasks/mailer/content.ts b/src/core/server/queue/tasks/mailer/content.ts index b3a31991a..6e672725f 100644 --- a/src/core/server/queue/tasks/mailer/content.ts +++ b/src/core/server/queue/tasks/mailer/content.ts @@ -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((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 { // Get the environment to render with. const env = this.getEnvironment(tenant); diff --git a/src/core/server/queue/tasks/mailer/index.ts b/src/core/server/queue/tasks/mailer/index.ts index 8a545e7c3..8a91ffdf6 100644 --- a/src/core/server/queue/tasks/mailer/index.ts +++ b/src/core/server/queue/tasks/mailer/index.ts @@ -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; } diff --git a/src/core/server/queue/tasks/mailer/processor.ts b/src/core/server/queue/tasks/mailer/processor.ts index dd7ddfea2..f423cf137 100644 --- a/src/core/server/queue/tasks/mailer/processor.ts +++ b/src/core/server/queue/tasks/mailer/processor.ts @@ -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(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, diff --git a/src/core/server/queue/tasks/mailer/templates/account-notification/ban.html b/src/core/server/queue/tasks/mailer/templates/account-notification/ban.html new file mode 100644 index 000000000..5819cae3b --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/ban.html @@ -0,0 +1,11 @@ +{% extends "layouts/account-notification.html" %} + +{% block content %} +
+ Hello {{ context.username }},

+ 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 {{ context.organizationContactEmail }}. +
+{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/account-notification/confirm-email.html b/src/core/server/queue/tasks/mailer/templates/account-notification/confirm-email.html new file mode 100644 index 000000000..6715348e6 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/confirm-email.html @@ -0,0 +1,11 @@ +{% extends "layouts/account-notification.html" %} + +{% block content %} +
+ Hello {{ context.username }},

+ To confirm your email address for use with your commenting account at {{ context.organizationName }}, + please follow this link: Click here to confirm your email

+ If you did not recently create a commenting account with + {{ context.organizationName }}, you can safely ignore this email. +
+{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/delete-request-cancel.html b/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-cancel.html similarity index 75% rename from src/core/server/queue/tasks/mailer/templates/delete-request-cancel.html rename to src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-cancel.html index 5b41a7744..99b4f900d 100644 --- a/src/core/server/queue/tasks/mailer/templates/delete-request-cancel.html +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-cancel.html @@ -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. diff --git a/src/core/server/queue/tasks/mailer/templates/delete-request-completed.html b/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-completed.html similarity index 88% rename from src/core/server/queue/tasks/mailer/templates/delete-request-completed.html rename to src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-completed.html index a2a6ca104..443bfe3ce 100644 --- a/src/core/server/queue/tasks/mailer/templates/delete-request-completed.html +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-completed.html @@ -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! diff --git a/src/core/server/queue/tasks/mailer/templates/delete-request-confirmation.html b/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-confirmation.html similarity index 90% rename from src/core/server/queue/tasks/mailer/templates/delete-request-confirmation.html rename to src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-confirmation.html index 34c97589d..ea86a3fce 100644 --- a/src/core/server/queue/tasks/mailer/templates/delete-request-confirmation.html +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-confirmation.html @@ -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 }}. diff --git a/src/core/server/queue/tasks/mailer/templates/account-notification/download-comments.html b/src/core/server/queue/tasks/mailer/templates/account-notification/download-comments.html new file mode 100644 index 000000000..d9ffb56d3 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/download-comments.html @@ -0,0 +1,9 @@ +{% extends "layouts/account-notification.html" %} + +{% block content %} +
+ Your comments from {{ context.organizationName }} as of {{ context.date }} are + now available for download.

+ Download my comment archive +
+{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/account-notification/forgot-password.html b/src/core/server/queue/tasks/mailer/templates/account-notification/forgot-password.html new file mode 100644 index 000000000..f86db15b4 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/forgot-password.html @@ -0,0 +1,10 @@ +{% extends "layouts/account-notification.html" %} + +{% block content %} +
+ Hello {{ context.username }},

+ We received a request to reset your password on .

+ Please follow this link reset your password: Click here to reset your password

+ If you did not request this, you can ignore this email.
+
+{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/account-notification/invite.html b/src/core/server/queue/tasks/mailer/templates/account-notification/invite.html new file mode 100644 index 000000000..93af8c053 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/invite.html @@ -0,0 +1,8 @@ +{% extends "layouts/account-notification.html" %} + +{% block content %} +
+ You have been invited to join the {{ context.organizationName }} team on Coral. Finish + setting up your account here. +
+{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/account-notification/password-change.html b/src/core/server/queue/tasks/mailer/templates/account-notification/password-change.html new file mode 100644 index 000000000..ed22fd3d2 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/password-change.html @@ -0,0 +1,10 @@ +{% extends "layouts/account-notification.html" %} + +{% block content %} +
+ Hello {{ context.username }},

+ The password on your account has been changed.

+ If you did not request this change, + please contact please contact our community team at {{ context.organizationContactEmail }}. +
+{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/account-notification/suspend.html b/src/core/server/queue/tasks/mailer/templates/account-notification/suspend.html new file mode 100644 index 000000000..5735a3403 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/suspend.html @@ -0,0 +1,15 @@ +{% extends "layouts/account-notification.html" %} + +{% block content %} +
+ Hello {{ context.username }},

+ + 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 }}.

+ + If you think this has been done in error, please contact our community team at + {{ context.organizationContactEmail }}. +
+{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/account-notification/update-username.html b/src/core/server/queue/tasks/mailer/templates/account-notification/update-username.html new file mode 100644 index 000000000..a924e60ee --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/account-notification/update-username.html @@ -0,0 +1,11 @@ +{% extends "layouts/account-notification.html" %} + +{% block content %} +
+ Hello {{ context.username }},

+ + 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 {{ context.organizationContactEmail }}. +
+{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/ban.html b/src/core/server/queue/tasks/mailer/templates/ban.html deleted file mode 100644 index a5af08274..000000000 --- a/src/core/server/queue/tasks/mailer/templates/ban.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "layouts/user-notification.html" %} - -{% block content %} - Hello {{ context.username }},

- 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 {{ context.organizationContactEmail }}. -{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/confirm-email.html b/src/core/server/queue/tasks/mailer/templates/confirm-email.html deleted file mode 100644 index 973156938..000000000 --- a/src/core/server/queue/tasks/mailer/templates/confirm-email.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "layouts/user-notification.html" %} - -{% block content %} - Hello {{ context.username }},

- To confirm your email address for use with your commenting account at {{ context.organizationName }}, - please follow this link: Click here to confirm your email

- If you did not recently create a commenting account with - {{ context.organizationName }}, you can safely ignore this email. -{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/download-comments.html b/src/core/server/queue/tasks/mailer/templates/download-comments.html deleted file mode 100644 index 6ee13b1be..000000000 --- a/src/core/server/queue/tasks/mailer/templates/download-comments.html +++ /dev/null @@ -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.

- Download my comment archive -{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/forgot-password.html b/src/core/server/queue/tasks/mailer/templates/forgot-password.html deleted file mode 100644 index 48267c1cc..000000000 --- a/src/core/server/queue/tasks/mailer/templates/forgot-password.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "layouts/user-notification.html" %} - -{% block content %} - Hello {{ context.username }},

- We received a request to reset your password on .

- Please follow this link reset your password: Click here to reset your password

- If you did not request this, you can ignore this email.
-{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/index.ts b/src/core/server/queue/tasks/mailer/templates/index.ts index d772bb478..24c0eb1a0 100644 --- a/src/core/server/queue/tasks/mailer/templates/index.ts +++ b/src/core/server/queue/tasks/mailer/templates/index.ts @@ -1,9 +1,67 @@ -interface Template { +interface EmailTemplate { name: T; context: U; } -type UserNotificationContext = Template< +/** + * NotificationContext + */ + +type NotificationContext = 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; + }>; + } +>; + +/** + * AccountNotificationContext + */ + +type AccountNotificationContext = EmailTemplate< T, U & { organizationURL: string; @@ -11,16 +69,16 @@ type UserNotificationContext = 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 }; diff --git a/src/core/server/queue/tasks/mailer/templates/invite.html b/src/core/server/queue/tasks/mailer/templates/invite.html deleted file mode 100644 index fc035316a..000000000 --- a/src/core/server/queue/tasks/mailer/templates/invite.html +++ /dev/null @@ -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 here. -{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/layouts/account-notification.html b/src/core/server/queue/tasks/mailer/templates/layouts/account-notification.html new file mode 100644 index 000000000..047b510f2 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/layouts/account-notification.html @@ -0,0 +1,7 @@ +{% extends "layouts/base.html" %} + +{% block footer %} + +{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/layouts/base.html b/src/core/server/queue/tasks/mailer/templates/layouts/base.html index 7ebf19e03..f7ca294e8 100644 --- a/src/core/server/queue/tasks/mailer/templates/layouts/base.html +++ b/src/core/server/queue/tasks/mailer/templates/layouts/base.html @@ -9,6 +9,17 @@ - {% block body %}{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
diff --git a/src/core/server/queue/tasks/mailer/templates/layouts/notification.html b/src/core/server/queue/tasks/mailer/templates/layouts/notification.html new file mode 100644 index 000000000..4df77c168 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/layouts/notification.html @@ -0,0 +1,11 @@ +{% extends "layouts/base.html" %} + +{% block content %} + {% include "notification/partials/" + baseName + ".html" %} +{% endblock %} + +{% block footer %} + +{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/layouts/user-notification.html b/src/core/server/queue/tasks/mailer/templates/layouts/user-notification.html deleted file mode 100644 index 8c4357532..000000000 --- a/src/core/server/queue/tasks/mailer/templates/layouts/user-notification.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "layouts/base.html" %} - -{% block body %} - - - - - - - -
- {% block content %}{% endblock %} -
- Sent by {{ context.organizationName }} -
-{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/notification/digest.html b/src/core/server/queue/tasks/mailer/templates/notification/digest.html new file mode 100644 index 000000000..9a877fffd --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/notification/digest.html @@ -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 %} diff --git a/src/core/server/queue/tasks/mailer/templates/notification/on-reply.html b/src/core/server/queue/tasks/mailer/templates/notification/on-reply.html new file mode 100644 index 000000000..e1fe7a742 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/notification/on-reply.html @@ -0,0 +1 @@ +{% extends "layouts/notification.html" %} diff --git a/src/core/server/queue/tasks/mailer/templates/notification/on-staff-reply.html b/src/core/server/queue/tasks/mailer/templates/notification/on-staff-reply.html new file mode 100644 index 000000000..e1fe7a742 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/notification/on-staff-reply.html @@ -0,0 +1 @@ +{% extends "layouts/notification.html" %} diff --git a/src/core/server/queue/tasks/mailer/templates/notification/partials/on-reply.html b/src/core/server/queue/tasks/mailer/templates/notification/partials/on-reply.html new file mode 100644 index 000000000..6426ec062 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/notification/partials/on-reply.html @@ -0,0 +1,4 @@ +
+ {{ context.organizationName }} - {{ context.storyTitle }}

+ {{ context.authorUsername }} has replied to your comment: View comment +
diff --git a/src/core/server/queue/tasks/mailer/templates/notification/partials/on-staff-reply.html b/src/core/server/queue/tasks/mailer/templates/notification/partials/on-staff-reply.html new file mode 100644 index 000000000..88881164d --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/notification/partials/on-staff-reply.html @@ -0,0 +1,4 @@ +
+ {{ context.organizationName }} - {{ context.storyTitle }}

+ {{ context.authorUsername }} works for {{ context.organizationName }} and has replied to your comment: View comment +
diff --git a/src/core/server/queue/tasks/mailer/templates/password-change.html b/src/core/server/queue/tasks/mailer/templates/password-change.html deleted file mode 100644 index cc9fd8fa2..000000000 --- a/src/core/server/queue/tasks/mailer/templates/password-change.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "layouts/user-notification.html" %} - -{% block content %} - Hello {{ context.username }},

- The password on your account has been changed.

- If you did not request this change, - please contact please contact our community team at {{ context.organizationContactEmail }}. -{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/suspend.html b/src/core/server/queue/tasks/mailer/templates/suspend.html deleted file mode 100644 index 217e8041f..000000000 --- a/src/core/server/queue/tasks/mailer/templates/suspend.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "layouts/user-notification.html" %} - -{% block content %} - Hello {{ context.username }},

- - 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 }}.

- - If you think this has been done in error, please contact our community team at - {{ context.organizationContactEmail }}. -{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/update-username.html b/src/core/server/queue/tasks/mailer/templates/update-username.html deleted file mode 100644 index eef3bab15..000000000 --- a/src/core/server/queue/tasks/mailer/templates/update-username.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "layouts/user-notification.html" %} -{% block content %} Hello {{ context.username }},

- -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 {{ context.organizationContactEmail }}. -{% endblock %} \ No newline at end of file diff --git a/src/core/server/queue/tasks/notifier/index.ts b/src/core/server/queue/tasks/notifier/index.ts new file mode 100644 index 000000000..89e7c048b --- /dev/null +++ b/src/core/server/queue/tasks/notifier/index.ts @@ -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; + private task: Task; + + 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(); + } +} diff --git a/src/core/server/queue/tasks/notifier/messages.spec.ts b/src/core/server/queue/tasks/notifier/messages.spec.ts new file mode 100644 index 000000000..f506f5a10 --- /dev/null +++ b/src/core/server/queue/tasks/notifier/messages.spec.ts @@ -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); + }); +}); diff --git a/src/core/server/queue/tasks/notifier/messages.ts b/src/core/server/queue/tasks/notifier/messages.ts new file mode 100644 index 000000000..a4707d883 --- /dev/null +++ b/src/core/server/queue/tasks/notifier/messages.ts @@ -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; +} + +/** + * 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 => { + const notifications: Array = 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 = {}; + 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); + } + } +}; diff --git a/src/core/server/queue/tasks/notifier/processor.ts b/src/core/server/queue/tasks/notifier/processor.ts new file mode 100644 index 000000000..f448c4626 --- /dev/null +++ b/src/core/server/queue/tasks/notifier/processor.ts @@ -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; + 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) => { + 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; + } + }; +}; diff --git a/src/core/server/queue/tasks/scraper/index.ts b/src/core/server/queue/tasks/scraper.ts similarity index 97% rename from src/core/server/queue/tasks/scraper/index.ts rename to src/core/server/queue/tasks/scraper.ts index 23712f746..9e90dace8 100644 --- a/src/core/server/queue/tasks/scraper/index.ts +++ b/src/core/server/queue/tasks/scraper.ts @@ -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"); diff --git a/src/core/server/services/notifications/categories/categories.ts b/src/core/server/services/notifications/categories/categories.ts new file mode 100644 index 000000000..dbe386d59 --- /dev/null +++ b/src/core/server/services/notifications/categories/categories.ts @@ -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; diff --git a/src/core/server/services/notifications/categories/category.ts b/src/core/server/services/notifications/categories/category.ts new file mode 100644 index 000000000..e25a200c8 --- /dev/null +++ b/src/core/server/services/notifications/categories/category.ts @@ -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; + + /** + * 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[]; +} diff --git a/src/core/server/services/notifications/categories/index.ts b/src/core/server/services/notifications/categories/index.ts new file mode 100644 index 000000000..406dedc49 --- /dev/null +++ b/src/core/server/services/notifications/categories/index.ts @@ -0,0 +1,3 @@ +export { default as categories } from "./categories"; +export * from "./categories"; +export * from "./category"; diff --git a/src/core/server/services/notifications/categories/reply.ts b/src/core/server/services/notifications/categories/reply.ts new file mode 100644 index 000000000..2df806570 --- /dev/null +++ b/src/core/server/services/notifications/categories/reply.ts @@ -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 { + 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, + }, +]; diff --git a/src/core/server/services/notifications/categories/staffReply.ts b/src/core/server/services/notifications/categories/staffReply.ts new file mode 100644 index 000000000..81a87040d --- /dev/null +++ b/src/core/server/services/notifications/categories/staffReply.ts @@ -0,0 +1,117 @@ +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 { hasStaffRole } from "coral-server/models/user/helpers"; + +import NotificationContext from "../context"; +import { Notification } from "../notification"; +import { NotificationCategory } from "./category"; + +async function processor( + ctx: NotificationContext, + input: CommentReplyCreatedInput +): Promise { + 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 author was a staff member. + if (!hasStaffRole(author)) { + return null; + } + + // Check to see if the target user has notifications enabled for this type. + if (!parentAuthor.notifications.onStaffReplies) { + 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; + } + + // 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-staff-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 staffReply: NotificationCategory[] = [ + { + name: "staffReply", + process: processor, + event: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, + digestOrder: 30, + supersedesCategories: ["reply"], + }, + { + name: "staffReply", + 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, + supersedesCategories: ["reply"], + }, +]; diff --git a/src/core/server/services/notifications/categories/unsubscribe.ts b/src/core/server/services/notifications/categories/unsubscribe.ts new file mode 100644 index 000000000..c97d4cb61 --- /dev/null +++ b/src/core/server/services/notifications/categories/unsubscribe.ts @@ -0,0 +1,124 @@ +import Joi from "joi"; +import { isNull } from "lodash"; +import { DateTime } from "luxon"; +import { Db } from "mongodb"; +import uuid from "uuid"; + +import { constructTenantURL } from "coral-server/app/url"; +import { Config } from "coral-server/config"; +import { TokenInvalidError, UserNotFoundError } from "coral-server/errors"; +import { Tenant } from "coral-server/models/tenant"; +import { retrieveUser, User } from "coral-server/models/user"; +import { + JWTSigningConfig, + signString, + StandardClaims, + StandardClaimsSchema, + verifyJWT, +} from "coral-server/services/jwt"; + +export interface UnsubscribeToken extends Required { + // aud specifies `unsubscribe` as the audience to indicate that this is a + // unsubscribe token. + aud: "unsubscribe"; +} + +const UnsubscribeTokenSchema = StandardClaimsSchema.keys({ + aud: Joi.string().only("unsubscribe"), +}); + +export function validateUnsubscribeToken( + token: UnsubscribeToken | object +): Error | null { + const { error } = Joi.validate(token, UnsubscribeTokenSchema, { + presence: "required", + }); + return error || null; +} + +export function isUnsubscribeToken( + token: UnsubscribeToken | object +): token is UnsubscribeToken { + return isNull(validateUnsubscribeToken(token)); +} + +export async function generateUnsubscribeURL( + tenant: Tenant, + config: Config, + signingConfig: JWTSigningConfig, + user: Pick, + now: Date +) { + // Pull some stuff out of the user. + const { id } = user; + + // Change the JS Date to a DateTime for ease of use. + const nowDate = DateTime.fromJSDate(now); + const nowSeconds = Math.round(nowDate.toSeconds()); + + // The expiry of this token is linked as 1 week after issuance. + const expiresAt = Math.round(nowDate.plus({ weeks: 1 }).toSeconds()); + + // Generate a token. + const token: UnsubscribeToken = { + jti: uuid.v4(), + iss: tenant.id, + sub: id, + exp: expiresAt, + iat: nowSeconds, + nbf: nowSeconds, + aud: "unsubscribe", + }; + + // Sign it with the signing config. + const tokenString = await signString(signingConfig, token); + + // Generate the unsubscribe url. + return constructTenantURL( + config, + tenant, + `/account/notifications/unsubscribe#unsubscribeToken=${tokenString}` + ); +} + +export async function verifyUnsubscribeTokenString( + mongo: Db, + tenant: Tenant, + signingConfig: JWTSigningConfig, + tokenString: string, + now: Date +) { + const token = verifyJWT(tokenString, signingConfig, now, { + // Verify that the token is for this Tenant. + issuer: tenant.id, + // Verify that this is a unsubscribe token based on the audience. + audience: "unsubscribe", + }); + + // Validate that this is indeed a unsubscribe token. + if (!isUnsubscribeToken(token)) { + // TODO: (wyattjoh) look into a way of pulling the error into this one + throw new TokenInvalidError( + tokenString, + "does not conform to the unsubscribe token schema" + ); + } + + // Unpack some of the token. + const { sub: userID, iss } = token; + + // TODO: (wyattjoh) verify that the token has not been revoked. + + // Check to see if this unsubscribe token has already verified this email. + const user = await retrieveUser(mongo, tenant.id, userID); + if (!user) { + throw new UserNotFoundError(userID); + } + + if (iss !== tenant.id) { + throw new TokenInvalidError(tokenString, "invalid tenant"); + } + + // Now that we've verified that the token is valid, we're good to go! + return { token, user }; +} diff --git a/src/core/server/services/notifications/context.ts b/src/core/server/services/notifications/context.ts new file mode 100644 index 000000000..58da5d814 --- /dev/null +++ b/src/core/server/services/notifications/context.ts @@ -0,0 +1,164 @@ +import DataLoader from "dataloader"; +import { Db } from "mongodb"; + +import { Config } from "coral-server/config"; +import { GQLDIGEST_FREQUENCY } from "coral-server/graph/tenant/schema/__generated__/types"; +import logger, { Logger } from "coral-server/logger"; +import { Comment, retrieveManyComments } from "coral-server/models/comment"; +import { retrieveManyStories, Story } from "coral-server/models/story"; +import { Tenant } from "coral-server/models/tenant"; +import { + insertUserNotificationDigests, + pullUserNotificationDigests, + retrieveManyUsers, + User, +} from "coral-server/models/user"; +import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates"; +import { JWTSigningConfig } from "coral-server/services/jwt"; + +import { generateUnsubscribeURL } from "./categories/unsubscribe"; + +interface Options { + mongo: Db; + tenant: Tenant; + config: Config; + signingConfig: JWTSigningConfig; + now?: Date; + log?: Logger; +} + +/** + * NotificationContext provides a caching layer used by the notifications to + * collect data to include in notifications to be sent. + */ +export default class NotificationContext { + private readonly mongo: Db; + private readonly signingConfig: JWTSigningConfig; + + /** + * tenant is the tenant performing this particular operation. + */ + public readonly tenant: Tenant; + + /** + * now is the current date. + */ + public readonly now: Date; + + /** + * config is used when generating the unsubscribe url's. + */ + public readonly config: Config; + + /** + * log is the context wrapped logger for this NotificationContext. + */ + public readonly log: Logger; + + /** + * users is a `DataLoader` used to retrieve users efficiently. + */ + public readonly users: DataLoader< + string, + Readonly | null + > = new DataLoader(userIDs => + retrieveManyUsers(this.mongo, this.tenant.id, userIDs) + ); + + /** + * comments is a `DataLoader` used to retrieve comments efficiently. + */ + public readonly comments: DataLoader< + string, + Readonly | null + > = new DataLoader(commentIDs => + retrieveManyComments(this.mongo, this.tenant.id, commentIDs) + ); + + /** + * stories is a `DataLoader` used to retrieve stories efficiently. + */ + public readonly stories: DataLoader< + string, + Readonly | null + > = new DataLoader(storyIDs => + retrieveManyStories(this.mongo, this.tenant.id, storyIDs) + ); + + constructor({ + mongo, + tenant, + now = new Date(), + log = logger, + config, + signingConfig, + }: Options) { + this.mongo = mongo; + this.tenant = tenant; + this.now = now; + this.config = config; + this.signingConfig = signingConfig; + this.log = log.child({ tenantID: tenant.id }); + } + + /** + * generateUnsubscribeURL will generate a unsubscribe token. + * + * @param user the user to generate the unsubscribe token for + */ + public async generateUnsubscribeURL(user: Pick) { + return generateUnsubscribeURL( + this.tenant, + this.config, + this.signingConfig, + user, + this.now + ); + } + + /** + * addDigests will add the given templates to the User so that they will be + * digested. + */ + public async addDigests(userID: string, templates: DigestibleTemplate[]) { + const user = await insertUserNotificationDigests( + this.mongo, + this.tenant.id, + userID, + templates, + this.now + ); + + this.users.prime(user.id, user); + + return user; + } + + /** + * digest will return an `asyncIterator` that can be used to iterate over all + * the users on a Tenant that have digests available configured with the given + * frequency. + * + * @param frequency the frequency to get the digests for + */ + public digest(frequency: GQLDIGEST_FREQUENCY) { + // `this` isn't available inside the iterator function, so extract it here. + const { mongo, tenant } = this; + return { + async *[Symbol.asyncIterator]() { + while (true) { + const user = await pullUserNotificationDigests( + mongo, + tenant.id, + frequency + ); + if (!user) { + break; + } + + yield user; + } + }, + }; + } +} diff --git a/src/core/server/services/notifications/notification.ts b/src/core/server/services/notifications/notification.ts new file mode 100644 index 000000000..34b66da24 --- /dev/null +++ b/src/core/server/services/notifications/notification.ts @@ -0,0 +1,17 @@ +import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates"; + +/** + * Notification stores the data used to issue a given notification. + */ +export interface Notification { + /** + * userID is the ID of the user to send the notification for. + */ + userID: string; + + /** + * template is the actual template/email data to use when sending the + * notification to the user. + */ + template: DigestibleTemplate; +} diff --git a/src/core/server/services/tenant/cache/index.ts b/src/core/server/services/tenant/cache/index.ts index 9846fa256..7cba1229a 100644 --- a/src/core/server/services/tenant/cache/index.ts +++ b/src/core/server/services/tenant/cache/index.ts @@ -171,6 +171,43 @@ export default class TenantCache { this.primed = true; } + /** + * Symbol.asyncIterator implements the asyncIterator interface for the + * TenantCache. This allows you to use the TenantCache as a asyncIterator with + * a `for await (const tenant of tenants) {}` pattern to iterate over all the + * tenant's on the cache. If the cache is cacheable, and not primed, the cache + * will be primed at the first async iteration process. If caching is + * disabled, then the tenants will bne loaded on demand and not persisted + * after the iteration. + */ + public async *[Symbol.asyncIterator]() { + // If the cache isn't primed, and caching is enabled, then prime the cache + // now, as this will increase performance dramatically. + if (!this.primed && this.cachingEnabled) { + await this.primeAll(); + } + + // If the tenant's are primed in the cache, then just use the count cache as + // the iteration source. + if (this.primed) { + for (const tenantID of this.tenantCountCache) { + const tenant = await this.tenantsByID.load(tenantID); + if (!tenant) { + continue; + } + + yield tenant; + } + } + + // Caching must be disabled, so just grab all the tenants for this node and + // iterate through each of them as we handle it. + const tenants = await retrieveAllTenants(this.mongo); + for (const tenant of tenants) { + yield tenant; + } + } + /** * onMessage is fired every time the client gets a subscription event. */ diff --git a/src/core/server/services/users/auth/confirm.ts b/src/core/server/services/users/auth/confirm.ts index b6d4b0695..0d1e03078 100644 --- a/src/core/server/services/users/auth/confirm.ts +++ b/src/core/server/services/users/auth/confirm.ts @@ -1,6 +1,8 @@ import Joi from "joi"; import { isNull } from "lodash"; import { DateTime } from "luxon"; +import { Db } from "mongodb"; +import uuid from "uuid"; import { constructTenantURL } from "coral-server/app/url"; import { Config } from "coral-server/config"; @@ -24,8 +26,6 @@ import { StandardClaimsSchema, verifyJWT, } from "coral-server/services/jwt"; -import { Db } from "mongodb"; -import uuid = require("uuid"); export interface ConfirmToken extends Required { // aud specifies `confirm` as the audience to indicate that this is a confirm @@ -109,7 +109,6 @@ export async function generateConfirmURL( return constructTenantURL( config, tenant, - // TODO: (kiwi) verify that url is correct. `/account/email/confirm#confirmToken=${token}` ); } @@ -233,7 +232,7 @@ export async function sendConfirmationEmail( to: user.email, }, template: { - name: "confirm-email", + name: "account-notification/confirm-email", context: { // TODO: (wyattjoh) possibly reevaluate the use of a required username. username: user.username, diff --git a/src/core/server/services/users/auth/invite.ts b/src/core/server/services/users/auth/invite.ts index 7e5eb161e..4b40dae69 100644 --- a/src/core/server/services/users/auth/invite.ts +++ b/src/core/server/services/users/auth/invite.ts @@ -239,7 +239,7 @@ export async function invite( // Send the invited user an email with the invite token. await mailerQueue.add({ template: { - name: "invite", + name: "account-notification/invite", context: { organizationName: tenant.organization.name, organizationURL: tenant.organization.url, diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 9212301b4..cf96ee969 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -39,6 +39,7 @@ import { ignoreUser, insertUser, InsertUserInput, + NotificationSettingsInput, removeActiveUserSuspensions, removeUserBan, removeUserIgnore, @@ -52,6 +53,7 @@ import { suspendUser, updateUserAvatar, updateUserEmail, + updateUserNotificationSettings, updateUserPassword, updateUserRole, updateUserUsername, @@ -62,7 +64,7 @@ import { getLocalProfile, hasLocalProfile, } from "coral-server/models/user/helpers"; -import { userIsStaff } from "coral-server/models/user/helpers"; +import { hasStaffRole } from "coral-server/models/user/helpers"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { sendConfirmationEmail } from "coral-server/services/users/auth"; @@ -300,7 +302,7 @@ export async function updatePassword( to: updatedUser.email, }, template: { - name: "password-change", + name: "account-notification/password-change", context: { // TODO: (wyattjoh) possibly reevaluate the use of a required username. username: updatedUser.username!, @@ -362,7 +364,7 @@ export async function requestAccountDeletion( to: user.email, }, template: { - name: "delete-request-confirmation", + name: "account-notification/delete-request-confirmation", context: { requestDate: formattedDate, organizationName: tenant.organization.name, @@ -392,7 +394,7 @@ export async function cancelAccountDeletion( to: user.email, }, template: { - name: "delete-request-cancel", + name: "account-notification/delete-request-cancel", context: { organizationName: tenant.organization.name, organizationURL: tenant.organization.url, @@ -524,7 +526,7 @@ export async function updateUsername( to: user.email, }, template: { - name: "update-username", + name: "account-notification/update-username", context: { username: user.username!, organizationName: tenant.organization.name, @@ -763,7 +765,7 @@ export async function ban( to: user.email, }, template: { - name: "ban", + name: "account-notification/ban", context: { // TODO: (wyattjoh) possibly reevaluate the use of a required username. username: user.username!, @@ -839,7 +841,7 @@ export async function suspend( to: updatedUser.email, }, template: { - name: "suspend", + name: "account-notification/suspend", context: { // TODO: (wyattjoh) possibly reevaluate the use of a required username. username: updatedUser.username!, @@ -922,7 +924,7 @@ export async function ignore( throw new UserNotFoundError(userID); } - const userToBeIgnoredIsStaff = userIsStaff(targetUser); + const userToBeIgnoredIsStaff = hasStaffRole(targetUser); if (userToBeIgnoredIsStaff) { throw new UserCannotBeIgnoredError(userID); } @@ -999,7 +1001,7 @@ export async function requestCommentsDownload( to: user.email, }, template: { - name: "download-comments", + name: "account-notification/download-comments", context: { username: user.username!, date: Intl.DateTimeFormat(tenant.locale).format(now), @@ -1037,3 +1039,12 @@ export async function requestUserCommentsDownload( return downloadUrl; } + +export async function updateNotificationSettings( + mongo: Db, + tenant: Tenant, + user: User, + settings: NotificationSettingsInput +) { + return updateUserNotificationSettings(mongo, tenant.id, user.id, settings); +} diff --git a/src/locales/en-US/account.ftl b/src/locales/en-US/account.ftl index cf4ad892f..becdc4a0a 100644 --- a/src/locales/en-US/account.ftl +++ b/src/locales/en-US/account.ftl @@ -34,12 +34,12 @@ confirmEmail-youMayClose = ## Download download-landingPage-title = Download Your Comment History -download-landingPage-description = +download-landingPage-description = Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma separated value (or .csv) file that you can easily import into your favorite spreadsheet application. -download-landingPage-contentsDescription = +download-landingPage-contentsDescription = For each of your comments the following information is included: download-landingPage-contentsDate = When you wrote the comment @@ -51,4 +51,11 @@ download-landingPage-contentsStoryUrl = The URL on the article or story where the comment appears download-landingPage-downloadComments = Download My Comment History -download-landingPage-sorry = Your download link is invalid. \ No newline at end of file +download-landingPage-sorry = Your download link is invalid. + +## Unsubscribe + +unsubscribe-oopsSorry = Oops Sorry! +unsubscribe-successfullyUnsubscribed = You are now unsubscribed from all notifications +unsubscribe-clickToConfirm = Click below to confirm that you want to unsubscribe from all notifications. +unsubscribe-confirm = Confirm diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 15b9704a4..a121923e2 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -218,6 +218,8 @@ profile-settings-download-comments-recentRequest = profile-settings-download-comments-timeOut = You can submit another request in { framework-timeago-time } +## Delete Account + profile-settings-deleteAccount-title = Delete My Account profile-settings-deleteAccount-description = Deleting your account will permanently erase your profile and remove @@ -290,6 +292,22 @@ profile-settings-deleteAccount-pages-completeWhyDeleteAccount = We'd like to know why you chose to delete your account. Send us feedback on our comment system by emailing { $email }. +## Notifications + +profile-settings-notifications-emailNotifications = E-Mail Notifications +profile-settings-notifications-emailNotifications = Email Notifications +profile-settings-notifications-receiveWhen = Receive notifications when: +profile-settings-notifications-onReply = My comment receives a reply +profile-settings-notifications-onFeatured = My comment is featured +profile-settings-notifications-onStaffReplies = A staff member replies to my comment +profile-settings-notifications-onModeration = My pending comment has been reviewed +profile-settings-notifications-sendNotifications = Send Notifications: +profile-settings-notifications-sendNotifications-immediately = Immediately +profile-settings-notifications-sendNotifications-daily = Daily +profile-settings-notifications-sendNotifications-hourly = Hourly +profile-settings-notifications-updated = Your notification settings have been updated +profile-settings-notifications-button = Update Notification Settings + ## Report Comment Popover comments-reportPopover = .description = A dialog for reporting comments diff --git a/src/types/dompurify.d.ts b/src/types/dompurify.d.ts index b0c05f149..7d9b4b03d 100644 --- a/src/types/dompurify.d.ts +++ b/src/types/dompurify.d.ts @@ -1,14 +1,24 @@ declare module "dompurify" { interface Config { + ADD_TAGS?: string[]; ALLOWED_ATTR?: string[]; ALLOWED_TAGS?: string[]; + FORBID_TAGS?: string[]; + FORBID_ATTR?: string[]; RETURN_DOM?: boolean; RETURN_DOM_FRAGMENT?: T; + ALLOW_DATA_ATTR?: boolean; + WHOLE_DOCUMENT?: boolean; + SANITIZE_DOM?: boolean; + IN_PLACE?: boolean; } class DOMPurify { public setConfig(config: Config): void; - public sanitize(source: string): T extends true ? HTMLBodyElement : string; + public sanitize( + source: HTMLElement | string, + config?: Config + ): T extends true ? HTMLBodyElement : string; public addHook(name: string, callback: (node: Element) => void): void; }