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

* chore: renamed old templates

* feat: initial notifications support

* feat: email enhancements

* fix: linting

* feat: initial digesting beheviour

* feat: added notification configuration

* feat: added unsubscribe routes

* fix: fixed failing snapshots/tests bc random ids

* feat: adjusted the save beheviour, added tests

* feat: added tests

* feat: added staff replies

* feat: renamed E-Mail to Email

* feat: enhanced cron processing

* fix: linting + updating tests

* feat: enhanced cron context

* fix: added staff replies back in
This commit is contained in:
Wyatt Johnson
2019-09-05 07:02:26 +00:00
committed by GitHub
parent 0fad1070a6
commit efea0e8e1c
111 changed files with 3439 additions and 382 deletions
+4
View File
@@ -9,6 +9,7 @@ import logger from "coral-server/logger";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { JWTSigningConfig } from "coral-server/services/jwt";
import TenantCache from "coral-server/services/tenant/cache";
@@ -20,6 +21,7 @@ export interface TenantContextOptions extends CommonContextOptions {
tenant: Tenant;
tenantCache: TenantCache;
mailerQueue: MailerQueue;
notifierQueue: NotifierQueue;
scraperQueue: ScraperQueue;
signingConfig?: JWTSigningConfig;
clientID?: string;
@@ -41,6 +43,7 @@ export default class TenantContext extends CommonContext {
constructor({
tenant,
logger: log = logger,
notifierQueue,
...options
}: TenantContextOptions) {
super({
@@ -57,6 +60,7 @@ export default class TenantContext extends CommonContext {
this.clientID = options.clientID;
this.publisher = createPublisher(
this.pubsub,
notifierQueue,
this.tenant.id,
this.clientID
);
@@ -21,6 +21,7 @@ import {
updateAvatar,
updateEmail,
updateEmailByID,
updateNotificationSettings,
updatePassword,
updateRole,
updateUsername,
@@ -46,6 +47,7 @@ import {
GQLSetUsernameInput,
GQLSuspendUserInput,
GQLUpdateEmailInput,
GQLUpdateNotificationSettingsInput,
GQLUpdatePasswordInput,
GQLUpdateUserAvatarInput,
GQLUpdateUserEmailInput,
@@ -53,6 +55,7 @@ import {
GQLUpdateUserRoleInput,
GQLUpdateUserUsernameInput,
} from "../schema/__generated__/types";
import { WithoutMutationID } from "./util";
export const Users = (ctx: TenantContext) => ({
invite: async ({ role, emails }: GQLInviteUsersInput) =>
@@ -178,6 +181,9 @@ export const Users = (ctx: TenantContext) => ({
input.email,
input.password
),
updateNotificationSettings: async (
input: WithoutMutationID<GQLUpdateNotificationSettingsInput>
) => updateNotificationSettings(ctx.mongo, ctx.tenant, ctx.user!, input),
updateUserAvatar: async (input: GQLUpdateUserAvatarInput) =>
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
@@ -17,10 +17,10 @@ import {
hasPublishedStatus,
} from "coral-server/models/comment/helpers";
import { createConnection } from "coral-server/models/helpers";
import { getURLWithCommentID } from "coral-server/models/story";
import { getCommentEditableUntilDate } from "coral-server/services/comments";
import TenantContext from "../context";
import { getURLWithCommentID } from "./util";
export const maybeLoadOnlyID = (
ctx: TenantContext,
@@ -25,6 +25,14 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
},
clientMutationId: input.clientMutationId,
}),
updateNotificationSettings: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
user: await ctx.mutators.Users.updateNotificationSettings(input),
clientMutationId,
}),
updateSettings: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.update(input),
clientMutationId: input.clientMutationId,
@@ -1,13 +1,22 @@
import { SubscriptionToCommentCreatedResolver } from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentCreatedInput extends SubscriptionPayload {
storyID: string;
commentID: string;
}
export type CommentCreatedSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_CREATED,
CommentCreatedInput
>;
export const commentCreated: SubscriptionToCommentCreatedResolver<
CommentCreatedInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_CREATED, {
@@ -4,7 +4,11 @@ import {
} from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentEnteredModerationQueueInput
extends SubscriptionPayload {
@@ -13,6 +17,11 @@ export interface CommentEnteredModerationQueueInput
storyID: string;
}
export type CommentEnteredModerationQueueSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
CommentEnteredModerationQueueInput
>;
export const commentEnteredModerationQueue: SubscriptionToCommentEnteredModerationQueueResolver<
CommentEnteredModerationQueueInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE, {
@@ -4,7 +4,11 @@ import {
} from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentLeftModerationQueueInput extends SubscriptionPayload {
queue: GQLMODERATION_QUEUE;
@@ -12,6 +16,11 @@ export interface CommentLeftModerationQueueInput extends SubscriptionPayload {
storyID: string;
}
export type CommentLeftModerationQueueSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
CommentLeftModerationQueueInput
>;
export const commentLeftModerationQueue: SubscriptionToCommentLeftModerationQueueResolver<
CommentLeftModerationQueueInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE, {
@@ -1,13 +1,22 @@
import { SubscriptionToCommentReplyCreatedResolver } from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentReplyCreatedInput extends SubscriptionPayload {
ancestorIDs: string[];
commentID: string;
}
export type CommentReplyCreatedSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
CommentReplyCreatedInput
>;
export const commentReplyCreated: SubscriptionToCommentReplyCreatedResolver<
CommentReplyCreatedInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, {
@@ -4,7 +4,11 @@ import {
} from "coral-server/graph/tenant/schema/__generated__/types";
import { createIterator } from "./helpers";
import { SUBSCRIPTION_CHANNELS, SubscriptionPayload } from "./types";
import {
SUBSCRIPTION_CHANNELS,
SubscriptionPayload,
SubscriptionType,
} from "./types";
export interface CommentStatusUpdatedInput extends SubscriptionPayload {
newStatus: GQLCOMMENT_STATUS;
@@ -13,6 +17,11 @@ export interface CommentStatusUpdatedInput extends SubscriptionPayload {
commentID: string;
}
export type CommentStatusUpdatedSubscription = SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
CommentStatusUpdatedInput
>;
export const commentStatusUpdated: SubscriptionToCommentStatusUpdatedResolver<
CommentStatusUpdatedInput
> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED, {
@@ -1,8 +1,8 @@
import { CommentCreatedInput } from "./commentCreated";
import { CommentEnteredModerationQueueInput } from "./commentEnteredModerationQueue";
import { CommentLeftModerationQueueInput } from "./commentLeftModerationQueue";
import { CommentReplyCreatedInput } from "./commentReplyCreated";
import { CommentStatusUpdatedInput } from "./commentStatusUpdated";
import { CommentCreatedSubscription } from "./commentCreated";
import { CommentEnteredModerationQueueSubscription } from "./commentEnteredModerationQueue";
import { CommentLeftModerationQueueSubscription } from "./commentLeftModerationQueue";
import { CommentReplyCreatedSubscription } from "./commentReplyCreated";
import { CommentStatusUpdatedSubscription } from "./commentStatusUpdated";
export enum SUBSCRIPTION_CHANNELS {
COMMENT_ENTERED_MODERATION_QUEUE = "COMMENT_ENTERED_MODERATION_QUEUE",
@@ -25,23 +25,8 @@ export interface SubscriptionType<
}
export type SUBSCRIPTION_INPUT =
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
CommentEnteredModerationQueueInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
CommentLeftModerationQueueInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_STATUS_UPDATED,
CommentStatusUpdatedInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
CommentReplyCreatedInput
>
| SubscriptionType<
SUBSCRIPTION_CHANNELS.COMMENT_CREATED,
CommentCreatedInput
>;
| CommentEnteredModerationQueueSubscription
| CommentLeftModerationQueueSubscription
| CommentStatusUpdatedSubscription
| CommentReplyCreatedSubscription
| CommentCreatedSubscription;
@@ -1,9 +1,7 @@
import { GraphQLResolveInfo } from "graphql";
import graphqlFields from "graphql-fields";
import { pull } from "lodash";
import { URL } from "url";
import { parseQuery, stringifyQuery } from "coral-common/utils";
import { constructTenantURL, reconstructURL } from "coral-server/app/url";
import TenantContext from "../context";
@@ -17,20 +15,6 @@ export function getRequestedFields<T>(info: GraphQLResolveInfo) {
return pull(Object.keys(graphqlFields<T>(info)), "__typename");
}
/**
* getURLWithCommentID returns the url with the comment id.
*
* @param storyURL url of the story
* @param commentID id of the comment
*/
export function getURLWithCommentID(storyURL: string, commentID?: string) {
const url = new URL(storyURL);
const query = parseQuery(url.search);
url.search = stringifyQuery({ ...query, commentID });
return url.toString();
}
export function reconstructTenantURLResolver<T = any>(path: string) {
return (parent: T, args: {}, ctx: TenantContext) => {
// If the request is available, then prefer it over building from the tenant
@@ -1088,11 +1088,11 @@ type CommenterAccountFeatures {
"""
changeUsername: Boolean!
"""
downloadComments when true, user may download their comment history
downloadComments when true, user may download their comment history
"""
downloadComments: Boolean!
"""
deleteAccount when true, non-sso user may permanently delete their account
deleteAccount when true, non-sso user may permanently delete their account
"""
deleteAccount: Boolean!
}
@@ -1535,6 +1535,60 @@ enum USER_STATUS {
SUSPENDED
}
enum DIGEST_FREQUENCY {
"""
NONE will have the notifications send immediatly rather than bundling for digesting.
"""
NONE
"""
DAILY will queue up the notifications and send them daily.
"""
DAILY
"""
HOURLY will queue up the notifications and send them hourly.
"""
HOURLY
}
"""
UserNotificationSettings stores the notification settings for a given User.
"""
type UserNotificationSettings {
"""
onReply, when true, will enable notifications to be sent to users that have
replies to their comments.
"""
onReply: Boolean!
"""
onFeatured, when true, will enable notifications to be sent to users that have
their comment's featured.
"""
onFeatured: Boolean!
"""
onStaffReplies when true, will enable notifications to be sent to users that
have a staff member reply to their comments. These notifications will
supercede notifications that would have been sent for a basic reply
notification.
"""
onStaffReplies: Boolean!
"""
onModeration when true, will enable notifications to be sent to users when a
comment that they wrote that was previously unpublished, becomes published due
to a moderator action.
"""
onModeration: Boolean!
"""
digestFrequency is the frequency to send notifications.
"""
digestFrequency: DIGEST_FREQUENCY!
}
"""
User is someone that leaves Comments, and logs in.
"""
@@ -1655,7 +1709,11 @@ type User {
tokens lists the access tokens associated with the account.
"""
tokens: [Token!]!
@auth(roles: [ADMIN], userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION])
@auth(
roles: [ADMIN]
userIDField: "id"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
ignoredUsers will return the list of ignored users.
@@ -1667,6 +1725,12 @@ type User {
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
notifications stores the notification settings for the given User.
"""
notifications: UserNotificationSettings!
@auth(userIDField: "id", permit: [SUSPENDED, BANNED])
"""
createdAt is the time that the User was created at.
"""
@@ -2503,6 +2567,61 @@ type Query {
## Mutations
################################################################################
##################
## updateNotificationSettings
##################
input UpdateNotificationSettingsInput {
"""
onReply, when true, will enable notifications to be sent to users that have
replies to their comments.
"""
onReply: Boolean
"""
onFeatured, when true, will enable notifications to be sent to users that have
their comment's featured.
"""
onFeatured: Boolean
"""
onStaffReplies when true, will enable notifications to be sent to users that
have a staff member reply to their comments. These notifications will
supercede notifications that would have been sent for a basic reply
notification.
"""
onStaffReplies: Boolean
"""
onModeration when true, will enable notifications to be sent to users when a
comment that they wrote that was previously unpublished, becomes published due
to a moderator action.
"""
onModeration: Boolean
"""
digestFrequency is the frequency to send notifications.
"""
digestFrequency: DIGEST_FREQUENCY
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateNotificationSettingsPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## createComment
##################
@@ -3140,11 +3259,11 @@ input CommenterAccountFeaturesInput {
"""
changeUsername: Boolean
"""
downloadComments when true, user may download their comment history
downloadComments when true, user may download their comment history
"""
downloadComments: Boolean
"""
deleteAccount when true, non-sso user may permanently delete their account
deleteAccount when true, non-sso user may permanently delete their account
"""
deleteAccount: Boolean
}
@@ -4887,7 +5006,9 @@ type Mutation {
before. This mutation will fail if the username is already set.
"""
setUsername(input: SetUsernameInput!): SetUsernamePayload!
@auth(permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION])
@auth(
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
)
"""
updateUsername will set the username on the current User if they have not set one
@@ -4959,6 +5080,14 @@ type Mutation {
"""
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload! @auth
"""
updateNotificationSettings can be used to update the notification settings for
the current logged in user.
"""
updateNotificationSettings(
input: UpdateNotificationSettingsInput!
): UpdateNotificationSettingsPayload! @auth
"""
updateUserEmail allows administrators to update a given User's email address
to the one provided.
@@ -5023,7 +5152,8 @@ type Mutation {
"""
requestCommentsDownload(
input: RequestCommentsDownloadInput!
): RequestCommentsDownloadPayload! @auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
): RequestCommentsDownloadPayload!
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
"""
requestUserCommentsDownload allows a user to request to download their comments.
@@ -3,12 +3,13 @@ import { RedisPubSub } from "graphql-redis-subscriptions";
import { createSubscriptionChannelName } from "coral-server/graph/tenant/resolvers/Subscription/helpers";
import { SUBSCRIPTION_INPUT } from "coral-server/graph/tenant/resolvers/Subscription/types";
import logger from "coral-server/logger";
import { NotifierQueue } from "coral-server/queue/tasks/notifier";
export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
/**
* createPublisher will create a new Publisher that can be used to send events
* over the pubsub broker to facilitate live updates.
* over the pubsub broker to facilitate live updates and notifications.
*
* @param pubsub the pubsub broker to be used to facilitate the publish action
* @param tenantID the ID of the Tenant where the event will be published with
@@ -16,13 +17,23 @@ export type Publisher = (input: SUBSCRIPTION_INPUT) => Promise<void>;
*/
export const createPublisher = (
pubsub: RedisPubSub,
notifier: NotifierQueue,
tenantID: string,
clientID?: string
): Publisher => async ({ channel, payload }) => {
): Publisher => async input => {
const { channel, payload } = input;
logger.trace({ channel, tenantID, clientID }, "publishing event");
return pubsub.publish(createSubscriptionChannelName(tenantID, channel), {
...payload,
clientID,
});
// Start the publishing operation out to all affected subscribers.
await Promise.all([
// Publish to the underlying pubsub system for subscriptions.
pubsub.publish(createSubscriptionChannelName(tenantID, channel), {
...payload,
clientID,
}),
// Notify the notifications queue so we can offload notification processing
// to it.
notifier.add({ tenantID, input }),
]);
};
@@ -38,7 +38,7 @@ import { getOperationMetadata } from "coral-server/graph/common/extensions/helpe
import { getPersistedQuery } from "coral-server/graph/tenant/persisted";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import { userIsStaff } from "coral-server/models/user/helpers";
import { hasStaffRole } from "coral-server/models/user/helpers";
import { extractTokenFromRequest } from "coral-server/services/jwt";
import TenantContext, { TenantContextOptions } from "../context";
@@ -133,7 +133,7 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
// TODO: (wyattjoh) if the story settings can only disable, and not
// enable live updates (as it takes precedence over global settings)
// then we can add a check for `!tenant.live.enabled` here too.
if (!opts.user || !userIsStaff(opts.user)) {
if (!opts.user || !hasStaffRole(opts.user)) {
throw new LiveUpdatesDisabled();
}
}