feat: added featured comment notifications (#2524)

This commit is contained in:
Wyatt Johnson
2019-09-05 17:10:03 +00:00
committed by GitHub
parent 04c56b3fb5
commit e35978096f
18 changed files with 228 additions and 58 deletions
@@ -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<GQLUnfeatureCommentInput>) =>
removeTag(ctx.mongo, ctx.tenant, commentID, GQLTAG.FEATURED),
});
@@ -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<story.Story> = {
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
@@ -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;
},
});
@@ -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,
};
@@ -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;
@@ -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])
}
+7
View File
@@ -93,6 +93,13 @@ email-template-notificationOnReply =
{ $organizationName } - <a data-l10n-name="storyLink">{ $storyTitle }</a><br /><br />
{ $authorUsername } has replied to your comment: <a data-l10n-name="commentPermalink">View comment</a>
## On Featured
email-subject-notificationOnFeatured = One of your comments was featured on { $organizationName }
email-template-notificationOnFeatured =
{ $organizationName } - <a data-l10n-name="storyLink">{ $storyTitle }</a><br /><br />
A member of our team has selected this comment to be featured for other readers: <a data-l10n-name="commentPermalink">View comment</a>
## On Staff Reply
email-subject-notificationOnStaffReply = Someone at { $organizationName } has replied to your comment
+41
View File
@@ -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<Story, "metadata" | "url">) {
return story.metadata && story.metadata.title
? story.metadata.title
: story.url;
}
export function getStoryClosedAt(
tenant: Pick<Tenant, "closeCommenting">,
story: Pick<Story, "closedAt" | "createdAt">
): 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;
}
@@ -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 };
@@ -0,0 +1 @@
{% extends "layouts/notification.html" %}
@@ -0,0 +1,4 @@
<div data-l10n-id="email-template-notificationOnFeatured" data-l10n-args="{{ context | dump }}">
{{ context.organizationName }} - <a data-l10n-name="storyLink" href="{{ context.storyURL }}">{{ context.storyTitle }}</a><br /><br/>
A member of our team has selected this comment to be featured for other readers: <a data-l10n-name="commentPermalink" href="{{ context.commentPermalink }}">View comment</a>
</div>
@@ -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 = ({
@@ -57,6 +57,21 @@ export function publishCommentCreated(
}
}
export function publishCommentFeatured(
publish: Publisher,
comment: Pick<Comment, "id" | "status" | "storyID">
) {
if (hasPublishedStatus(comment)) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_FEATURED,
payload: {
commentID: comment.id,
storyID: comment.storyID,
},
});
}
}
export function publishModerationQueueChanges(
publish: Publisher,
moderationQueue: Pick<CommentModerationQueueCounts, "queues">,
@@ -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;
@@ -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<Notification | null> {
// 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,
},
];
@@ -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,
@@ -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,
-32
View File
@@ -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<Tenant, "closeCommenting">,
story: Pick<Story, "closedAt" | "createdAt">
): 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;
}