diff --git a/src/core/server/graph/tenant/mutators/Comments.ts b/src/core/server/graph/tenant/mutators/Comments.ts index 7221c115e..a38acbf90 100644 --- a/src/core/server/graph/tenant/mutators/Comments.ts +++ b/src/core/server/graph/tenant/mutators/Comments.ts @@ -31,6 +31,7 @@ import { } from "coral-server/services/comments/actions"; import { approve } from "coral-server/services/comments/moderation"; +import { publishCommentFeatured } from "coral-server/services/events"; import { validateMaximumLength, WithoutMutationID } from "./util"; export const Comments = (ctx: TenantContext) => ({ @@ -164,15 +165,23 @@ export const Comments = (ctx: TenantContext) => ({ ctx.user!, GQLTAG.FEATURED, ctx.now - ).then(comment => - comment.status !== GQLCOMMENT_STATUS.APPROVED - ? approve(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, { - commentID, - commentRevisionID, - moderatorID: ctx.user!.id, - }) - : comment - ), + ) + .then(comment => + comment.status !== GQLCOMMENT_STATUS.APPROVED + ? approve(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, { + commentID, + commentRevisionID, + moderatorID: ctx.user!.id, + }) + : comment + ) + .then(comment => { + // Publish that the comment was featured. + publishCommentFeatured(ctx.publisher, comment); + + // Return it to the next step. + return comment; + }), unfeature: ({ commentID }: WithoutMutationID) => removeTag(ctx.mongo, ctx.tenant, commentID, GQLTAG.FEATURED), }); diff --git a/src/core/server/graph/tenant/resolvers/Story.ts b/src/core/server/graph/tenant/resolvers/Story.ts index 5a1021f1c..1a5167d97 100644 --- a/src/core/server/graph/tenant/resolvers/Story.ts +++ b/src/core/server/graph/tenant/resolvers/Story.ts @@ -7,14 +7,13 @@ import { } from "coral-server/graph/tenant/schema/__generated__/types"; import { decodeActionCounts } from "coral-server/models/action/comment"; import * as story from "coral-server/models/story"; -import { getStoryClosedAt } from "coral-server/services/stories"; import TenantContext from "../context"; import { CommentCountsInput } from "./CommentCounts"; import { storyModerationInputResolver } from "./ModerationQueues"; const isStoryClosed = (s: story.Story, ctx: TenantContext) => { - const closedAt = getStoryClosedAt(ctx.tenant, s) || null; + const closedAt = story.getStoryClosedAt(ctx.tenant, s) || null; return !!closedAt && new Date() >= closedAt; }; @@ -25,7 +24,7 @@ export const Story: GQLStoryTypeResolver = { status: (s, input, ctx) => isStoryClosed(s, ctx) ? GQLSTORY_STATUS.CLOSED : GQLSTORY_STATUS.OPEN, isClosed: (s, input, ctx) => isStoryClosed(s, ctx), - closedAt: (s, input, ctx) => getStoryClosedAt(ctx.tenant, s) || null, + closedAt: (s, input, ctx) => story.getStoryClosedAt(ctx.tenant, s) || null, commentActionCounts: s => decodeActionCounts(s.commentCounts.action), commentCounts: (s): CommentCountsInput => s, // Merge tenant settings into the story settings so we can easily inherit the diff --git a/src/core/server/graph/tenant/resolvers/Subscription/commentFeatured.ts b/src/core/server/graph/tenant/resolvers/Subscription/commentFeatured.ts new file mode 100644 index 000000000..5a146b3cb --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Subscription/commentFeatured.ts @@ -0,0 +1,30 @@ +import { SubscriptionToCommentFeaturedResolver } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { createIterator } from "./helpers"; +import { + SUBSCRIPTION_CHANNELS, + SubscriptionPayload, + SubscriptionType, +} from "./types"; + +export interface CommentFeaturedInput extends SubscriptionPayload { + storyID: string; + commentID: string; +} + +export type CommentFeaturedSubscription = SubscriptionType< + SUBSCRIPTION_CHANNELS.COMMENT_FEATURED, + CommentFeaturedInput +>; + +export const commentFeatured: SubscriptionToCommentFeaturedResolver< + CommentFeaturedInput +> = createIterator(SUBSCRIPTION_CHANNELS.COMMENT_FEATURED, { + filter: (source, { storyID }) => { + if (source.storyID !== storyID) { + return false; + } + + return true; + }, +}); diff --git a/src/core/server/graph/tenant/resolvers/Subscription/index.ts b/src/core/server/graph/tenant/resolvers/Subscription/index.ts index 2a42932be..bbd7d7b2a 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/index.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/index.ts @@ -2,6 +2,7 @@ import { GQLSubscriptionTypeResolver } from "coral-server/graph/tenant/schema/__ import { commentCreated } from "./commentCreated"; import { commentEnteredModerationQueue } from "./commentEnteredModerationQueue"; +import { commentFeatured } from "./commentFeatured"; import { commentLeftModerationQueue } from "./commentLeftModerationQueue"; import { commentReplyCreated } from "./commentReplyCreated"; import { commentStatusUpdated } from "./commentStatusUpdated"; @@ -12,4 +13,5 @@ export const Subscription: GQLSubscriptionTypeResolver = { commentLeftModerationQueue, commentReplyCreated, commentStatusUpdated, + commentFeatured, }; diff --git a/src/core/server/graph/tenant/resolvers/Subscription/types.ts b/src/core/server/graph/tenant/resolvers/Subscription/types.ts index 84e7bb23f..21d9e2017 100644 --- a/src/core/server/graph/tenant/resolvers/Subscription/types.ts +++ b/src/core/server/graph/tenant/resolvers/Subscription/types.ts @@ -1,5 +1,6 @@ import { CommentCreatedSubscription } from "./commentCreated"; import { CommentEnteredModerationQueueSubscription } from "./commentEnteredModerationQueue"; +import { CommentFeaturedSubscription } from "./commentFeatured"; import { CommentLeftModerationQueueSubscription } from "./commentLeftModerationQueue"; import { CommentReplyCreatedSubscription } from "./commentReplyCreated"; import { CommentStatusUpdatedSubscription } from "./commentStatusUpdated"; @@ -10,6 +11,7 @@ export enum SUBSCRIPTION_CHANNELS { COMMENT_STATUS_UPDATED = "COMMENT_STATUS_UPDATED", COMMENT_REPLY_CREATED = "COMMENT_REPLY_CREATED", COMMENT_CREATED = "COMMENT_CREATED", + COMMENT_FEATURED = "COMMENT_FEATURED", } export interface SubscriptionPayload { @@ -29,4 +31,5 @@ export type SUBSCRIPTION_INPUT = | CommentLeftModerationQueueSubscription | CommentStatusUpdatedSubscription | CommentReplyCreatedSubscription - | CommentCreatedSubscription; + | CommentCreatedSubscription + | CommentFeaturedSubscription; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 3dcf06873..5075f1527 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -5275,6 +5275,16 @@ type CommentReplyCreatedPayload { comment: Comment! } +""" +CommentFeaturedPayload is returned when a Comment is featured. +""" +type CommentFeaturedPayload { + """ + comment is the Comment that was featured. + """ + comment: Comment! +} + type Subscription { """ commentEnteredModerationQueue returns when a Comment enters a ModerationQueue. @@ -5312,4 +5322,10 @@ type Subscription { comments. """ commentReplyCreated(ancestorID: ID!): CommentReplyCreatedPayload! + + """ + commentFeatured returns when a Comment is featured. + """ + commentFeatured(storyID: ID!): CommentFeaturedPayload! + @auth(roles: [MODERATOR, ADMIN]) } diff --git a/src/core/server/locales/en-US/email.ftl b/src/core/server/locales/en-US/email.ftl index cda671928..a175cc390 100644 --- a/src/core/server/locales/en-US/email.ftl +++ b/src/core/server/locales/en-US/email.ftl @@ -93,6 +93,13 @@ email-template-notificationOnReply = { $organizationName } - { $storyTitle }

{ $authorUsername } has replied to your comment: View comment +## On Featured + +email-subject-notificationOnFeatured = One of your comments was featured on { $organizationName } +email-template-notificationOnFeatured = + { $organizationName } - { $storyTitle }

+ A member of our team has selected this comment to be featured for other readers: View comment + ## On Staff Reply email-subject-notificationOnStaffReply = Someone at { $organizationName } has replied to your comment diff --git a/src/core/server/models/story/helpers.ts b/src/core/server/models/story/helpers.ts index cab52a844..9906e9e0f 100644 --- a/src/core/server/models/story/helpers.ts +++ b/src/core/server/models/story/helpers.ts @@ -1,6 +1,10 @@ +import { DateTime } from "luxon"; import { URL } from "url"; import { parseQuery, stringifyQuery } from "coral-common/utils"; +import { Tenant } from "coral-server/models/tenant"; + +import { Story } from "."; /** * getURLWithCommentID returns the url with the comment id. @@ -15,3 +19,40 @@ export function getURLWithCommentID(storyURL: string, commentID?: string) { return url.toString(); } + +export function getStoryTitle(story: Pick) { + return story.metadata && story.metadata.title + ? story.metadata.title + : story.url; +} + +export function getStoryClosedAt( + tenant: Pick, + story: Pick +): Story["closedAt"] { + // Try to get the closedAt time from the story. + if (story.closedAt) { + return story.closedAt; + } + + // Check to see if the story has been forced open again. + if (story.closedAt === false) { + return false; + } + + // If the story hasn't already been closed, then check to see if the Tenant + // has the auto close stream enabled. + if (tenant.closeCommenting.auto) { + // Auto-close stream has been enabled, convert the createdAt time into the + // closedAt time by adding the closedTimeout. + return ( + DateTime.fromJSDate(story.createdAt) + // closedTimeout is in seconds, so multiply by 1000 to get + // milliseconds. + .plus(tenant.closeCommenting.timeout * 1000) + .toJSDate() + ); + } + + return; +} diff --git a/src/core/server/queue/tasks/mailer/templates/index.ts b/src/core/server/queue/tasks/mailer/templates/index.ts index 24c0eb1a0..293e2a3f1 100644 --- a/src/core/server/queue/tasks/mailer/templates/index.ts +++ b/src/core/server/queue/tasks/mailer/templates/index.ts @@ -36,7 +36,19 @@ export type OnStaffReplyTemplate = NotificationContext< } >; -export type DigestibleTemplate = OnReplyTemplate | OnStaffReplyTemplate; +export type OnFeaturedTemplate = NotificationContext< + "notification/on-featured", + { + storyTitle: string; + storyURL: string; + commentPermalink: string; + } +>; + +export type DigestibleTemplate = + | OnReplyTemplate + | OnStaffReplyTemplate + | OnFeaturedTemplate; type DigestTemplate = NotificationContext< "notification/digest", @@ -174,6 +186,7 @@ type Templates = | AccountDeletionCompleted | OnReplyTemplate | OnStaffReplyTemplate + | OnFeaturedTemplate | DigestTemplate; export { Templates as EmailTemplate }; diff --git a/src/core/server/queue/tasks/mailer/templates/notification/on-featured.html b/src/core/server/queue/tasks/mailer/templates/notification/on-featured.html new file mode 100644 index 000000000..e1fe7a742 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/notification/on-featured.html @@ -0,0 +1 @@ +{% extends "layouts/notification.html" %} diff --git a/src/core/server/queue/tasks/mailer/templates/notification/partials/on-featured.html b/src/core/server/queue/tasks/mailer/templates/notification/partials/on-featured.html new file mode 100644 index 000000000..4b07eca4a --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/notification/partials/on-featured.html @@ -0,0 +1,4 @@ +
+ {{ context.organizationName }} - {{ context.storyTitle }}

+ A member of our team has selected this comment to be featured for other readers: View comment +
diff --git a/src/core/server/services/comments/pipeline/phases/storyClosed.ts b/src/core/server/services/comments/pipeline/phases/storyClosed.ts index 3fce8e97e..05bdb7a16 100644 --- a/src/core/server/services/comments/pipeline/phases/storyClosed.ts +++ b/src/core/server/services/comments/pipeline/phases/storyClosed.ts @@ -1,9 +1,9 @@ import { StoryClosedError } from "coral-server/errors"; +import { getStoryClosedAt } from "coral-server/models/story"; import { IntermediatePhaseResult, ModerationPhaseContext, } from "coral-server/services/comments/pipeline"; -import { getStoryClosedAt } from "coral-server/services/stories"; // This phase checks to see if the story being processed is closed or not. export const storyClosed = ({ diff --git a/src/core/server/services/events/comments.ts b/src/core/server/services/events/comments.ts index b297c909e..cfebdfde6 100644 --- a/src/core/server/services/events/comments.ts +++ b/src/core/server/services/events/comments.ts @@ -57,6 +57,21 @@ export function publishCommentCreated( } } +export function publishCommentFeatured( + publish: Publisher, + comment: Pick +) { + if (hasPublishedStatus(comment)) { + publish({ + channel: SUBSCRIPTION_CHANNELS.COMMENT_FEATURED, + payload: { + commentID: comment.id, + storyID: comment.storyID, + }, + }); + } +} + export function publishModerationQueueChanges( publish: Publisher, moderationQueue: Pick, diff --git a/src/core/server/services/notifications/categories/categories.ts b/src/core/server/services/notifications/categories/categories.ts index dbe386d59..0d8163051 100644 --- a/src/core/server/services/notifications/categories/categories.ts +++ b/src/core/server/services/notifications/categories/categories.ts @@ -1,10 +1,15 @@ import { NotificationCategory } from "./category"; +import { featured } from "./featured"; import { reply } from "./reply"; import { staffReply } from "./staffReply"; /** * categories stores all the notification categories in a flat list. */ -const categories: NotificationCategory[] = [...reply, ...staffReply]; +const categories: NotificationCategory[] = [ + ...reply, + ...staffReply, + ...featured, +]; export default categories; diff --git a/src/core/server/services/notifications/categories/featured.ts b/src/core/server/services/notifications/categories/featured.ts new file mode 100644 index 000000000..d16608a06 --- /dev/null +++ b/src/core/server/services/notifications/categories/featured.ts @@ -0,0 +1,63 @@ +import { CommentFeaturedInput } from "coral-server/graph/tenant/resolvers/Subscription/commentFeatured"; +import { SUBSCRIPTION_CHANNELS } from "coral-server/graph/tenant/resolvers/Subscription/types"; +import { hasPublishedStatus } from "coral-server/models/comment"; + +import { getStoryTitle, getURLWithCommentID } from "coral-server/models/story"; +import NotificationContext from "../context"; +import { Notification } from "../notification"; +import { NotificationCategory } from "./category"; + +async function processor( + ctx: NotificationContext, + input: CommentFeaturedInput +): Promise { + // Get the comment that was featured. + const comment = await ctx.comments.load(input.commentID); + if (!comment || !hasPublishedStatus(comment)) { + return null; + } + + // Get the comment's author. + const author = await ctx.users.load(comment.authorID); + if (!author) { + return null; + } + + // Check to see if the user has this notification type enabled. + if (!author.notifications.onFeatured) { + 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(author); + + return { + userID: author.id, + template: { + name: "notification/on-featured", + context: { + commentPermalink: getURLWithCommentID(story.url, comment.id), + storyTitle: getStoryTitle(story), + storyURL: story.url, + organizationName: ctx.tenant.organization.name, + organizationURL: ctx.tenant.organization.url, + unsubscribeURL, + }, + }, + }; +} + +export const featured: NotificationCategory[] = [ + { + name: "featured", + process: processor, + event: SUBSCRIPTION_CHANNELS.COMMENT_FEATURED, + digestOrder: 30, + }, +]; diff --git a/src/core/server/services/notifications/categories/reply.ts b/src/core/server/services/notifications/categories/reply.ts index 2df806570..809123ba7 100644 --- a/src/core/server/services/notifications/categories/reply.ts +++ b/src/core/server/services/notifications/categories/reply.ts @@ -2,7 +2,7 @@ import { CommentReplyCreatedInput } from "coral-server/graph/tenant/resolvers/Su 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 { getStoryTitle, getURLWithCommentID } from "coral-server/models/story"; import NotificationContext from "../context"; import { Notification } from "../notification"; @@ -73,10 +73,7 @@ async function processor( // 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, + storyTitle: getStoryTitle(story), storyURL: story.url, organizationName: ctx.tenant.organization.name, organizationURL: ctx.tenant.organization.url, diff --git a/src/core/server/services/notifications/categories/staffReply.ts b/src/core/server/services/notifications/categories/staffReply.ts index 81a87040d..94a27ae09 100644 --- a/src/core/server/services/notifications/categories/staffReply.ts +++ b/src/core/server/services/notifications/categories/staffReply.ts @@ -2,7 +2,7 @@ import { CommentReplyCreatedInput } from "coral-server/graph/tenant/resolvers/Su 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 { getStoryTitle, getURLWithCommentID } from "coral-server/models/story"; import { hasStaffRole } from "coral-server/models/user/helpers"; import NotificationContext from "../context"; @@ -73,10 +73,7 @@ async function processor( // 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, + storyTitle: getStoryTitle(story), storyURL: story.url, organizationName: ctx.tenant.organization.name, organizationURL: ctx.tenant.organization.url, diff --git a/src/core/server/services/stories/index.ts b/src/core/server/services/stories/index.ts index b8ce0f051..2759ee487 100644 --- a/src/core/server/services/stories/index.ts +++ b/src/core/server/services/stories/index.ts @@ -1,5 +1,4 @@ import { zip } from "lodash"; -import { DateTime } from "luxon"; import { Db } from "mongodb"; import { StoryURLInvalidError } from "coral-server/errors"; @@ -356,34 +355,3 @@ export async function merge( // Return the story that had the other stories merged into. return destinationStory; } - -export function getStoryClosedAt( - tenant: Pick, - story: Pick -): Story["closedAt"] { - // Try to get the closedAt time from the story. - if (story.closedAt) { - return story.closedAt; - } - - // Check to see if the story has been forced open again. - if (story.closedAt === false) { - return false; - } - - // If the story hasn't already been closed, then check to see if the Tenant - // has the auto close stream enabled. - if (tenant.closeCommenting.auto) { - // Auto-close stream has been enabled, convert the createdAt time into the - // closedAt time by adding the closedTimeout. - return ( - DateTime.fromJSDate(story.createdAt) - // closedTimeout is in seconds, so multiply by 1000 to get - // milliseconds. - .plus(tenant.closeCommenting.timeout * 1000) - .toJSDate() - ); - } - - return; -}