mirror of
https://github.com/wassname/talk.git
synced 2026-07-02 15:06:47 +08:00
feat: added featured comment notifications (#2524)
This commit is contained in:
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user