From e3e2e0f52e9fac4431abcf958aae0d8fac8d9633 Mon Sep 17 00:00:00 2001 From: Nick Funk Date: Tue, 7 Jan 2020 14:00:25 -0700 Subject: [PATCH] [CORL-688] Add user comment count tracking (#2744) * feat: initial impl * Create preliminary comment moderation slices CORL-688 * Move slices logic into stacks CORL-688 * Create user comment counts CORL-688 * Create naive mutation that initializes user comment counts CORL-688 * Use bulk updates in user counts migration CORL-688 * fix: review * fix: fixed issue with aggregation * Migrate creating comment into stacks CORL-688 * Migrate editing a comment to the stacks CORL-688 * Break publishing comment status out of updateAllCounts CORL-688 * review: removed variable scoping in favor of export * revert: feb8e8196cd448f5cd24f1ca2eb0b91fe9bd43c7 * review: simplification of stacks implementation This simplifies the stacks implementation to better reuse code related to count management and event publishing. This can be used to great effect with the upcomming events PR #2738. * fix: check if authorID is null before update user counts CORL-688 Co-authored-by: Wyatt Johnson --- .../server/graph/tenant/mutators/Actions.ts | 41 +- .../server/graph/tenant/mutators/Comments.ts | 45 +- .../models/action/moderation/comment.ts | 5 +- src/core/server/models/comment/comment.ts | 49 +- src/core/server/models/story/counts/index.ts | 4 +- src/core/server/models/story/counts/shared.ts | 5 +- src/core/server/models/user/user.ts | 64 ++- src/core/server/services/comments/comments.ts | 457 +----------------- .../services/comments/moderation/index.ts | 2 +- .../services/comments/moderation/moderate.ts | 46 ++ .../comments/moderation/moderation.spec.ts | 194 -------- .../comments/moderation/moderation.ts | 134 ----- .../1575649180_user_comment_counts.ts | 100 ++++ src/core/server/stacks/approveComment.ts | 52 ++ src/core/server/stacks/createComment.ts | 247 ++++++++++ src/core/server/stacks/editComment.ts | 212 ++++++++ src/core/server/stacks/helpers/index.ts | 2 + .../server/stacks/helpers/publishChanges.ts | 43 ++ .../server/stacks/helpers/updateAllCounts.ts | 86 ++++ src/core/server/stacks/index.ts | 4 + src/core/server/stacks/rejectComment.ts | 62 +++ 21 files changed, 987 insertions(+), 867 deletions(-) create mode 100644 src/core/server/services/comments/moderation/moderate.ts delete mode 100644 src/core/server/services/comments/moderation/moderation.spec.ts delete mode 100644 src/core/server/services/comments/moderation/moderation.ts create mode 100644 src/core/server/services/migrate/migrations/1575649180_user_comment_counts.ts create mode 100644 src/core/server/stacks/approveComment.ts create mode 100644 src/core/server/stacks/createComment.ts create mode 100644 src/core/server/stacks/editComment.ts create mode 100644 src/core/server/stacks/helpers/index.ts create mode 100644 src/core/server/stacks/helpers/publishChanges.ts create mode 100644 src/core/server/stacks/helpers/updateAllCounts.ts create mode 100644 src/core/server/stacks/index.ts create mode 100644 src/core/server/stacks/rejectComment.ts diff --git a/src/core/server/graph/tenant/mutators/Actions.ts b/src/core/server/graph/tenant/mutators/Actions.ts index fd63c3acc..8bc494d11 100644 --- a/src/core/server/graph/tenant/mutators/Actions.ts +++ b/src/core/server/graph/tenant/mutators/Actions.ts @@ -1,31 +1,32 @@ import TenantContext from "coral-server/graph/tenant/context"; -import { hasTag } from "coral-server/models/comment"; -import { removeTag } from "coral-server/services/comments"; -import { approve, reject } from "coral-server/services/comments/moderation"; +import { approveComment, rejectComment } from "coral-server/stacks"; + import { GQLApproveCommentInput, GQLRejectCommentInput, - GQLTAG, } from "../schema/__generated__/types"; export const Actions = (ctx: TenantContext) => ({ approveComment: (input: GQLApproveCommentInput) => - approve(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, { - commentID: input.commentID, - commentRevisionID: input.commentRevisionID, - moderatorID: ctx.user!.id, - }), + approveComment( + ctx.mongo, + ctx.redis, + ctx.publisher, + ctx.tenant, + input.commentID, + input.commentRevisionID, + ctx.user!.id, + ctx.now + ), rejectComment: (input: GQLRejectCommentInput) => - reject(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, { - commentID: input.commentID, - commentRevisionID: input.commentRevisionID, - moderatorID: ctx.user!.id, - }).then(comment => - // if a comment was previously featured - // and is now rejected, we need to remove the - // featured tag - hasTag(comment, GQLTAG.FEATURED) - ? removeTag(ctx.mongo, ctx.tenant, comment.id, GQLTAG.FEATURED) - : comment + rejectComment( + ctx.mongo, + ctx.redis, + ctx.publisher, + ctx.tenant, + input.commentID, + input.commentRevisionID, + ctx.user!.id, + ctx.now ), }); diff --git a/src/core/server/graph/tenant/mutators/Comments.ts b/src/core/server/graph/tenant/mutators/Comments.ts index 3922d4f26..987e4f45e 100644 --- a/src/core/server/graph/tenant/mutators/Comments.ts +++ b/src/core/server/graph/tenant/mutators/Comments.ts @@ -2,6 +2,21 @@ import { ERROR_CODES } from "coral-common/errors"; import { ADDITIONAL_DETAILS_MAX_LENGTH } from "coral-common/helpers/validate"; import { mapFieldsetToErrorCodes } from "coral-server/graph/common/errors"; import TenantContext from "coral-server/graph/tenant/context"; +import { addTag, removeTag } from "coral-server/services/comments"; +import { + createDontAgree, + createFlag, + createReaction, + removeDontAgree, + removeReaction, +} from "coral-server/services/comments/actions"; +import { publishCommentFeatured } from "coral-server/services/events"; +import { + approveComment, + createComment, + editComment, +} from "coral-server/stacks"; + import { GQLCOMMENT_STATUS, GQLCreateCommentDontAgreeInput, @@ -16,22 +31,7 @@ import { GQLTAG, GQLUnfeatureCommentInput, } from "coral-server/graph/tenant/schema/__generated__/types"; -import { - addTag, - create, - edit, - removeTag, -} from "coral-server/services/comments"; -import { - createDontAgree, - createFlag, - createReaction, - removeDontAgree, - removeReaction, -} 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) => ({ @@ -41,7 +41,7 @@ export const Comments = (ctx: TenantContext) => ({ ...comment }: GQLCreateCommentInput | GQLCreateCommentReplyInput) => mapFieldsetToErrorCodes( - create( + createComment( ctx.mongo, ctx.redis, ctx.config, @@ -64,7 +64,7 @@ export const Comments = (ctx: TenantContext) => ({ ), edit: ({ commentID, body }: GQLEditCommentInput) => mapFieldsetToErrorCodes( - edit( + editComment( ctx.mongo, ctx.redis, ctx.config, @@ -170,11 +170,16 @@ export const Comments = (ctx: TenantContext) => ({ ) .then(comment => comment.status !== GQLCOMMENT_STATUS.APPROVED - ? approve(ctx.mongo, ctx.redis, ctx.publisher, ctx.tenant, { + ? approveComment( + ctx.mongo, + ctx.redis, + ctx.publisher, + ctx.tenant, commentID, commentRevisionID, - moderatorID: ctx.user!.id, - }) + ctx.user!.id, + ctx.now + ) : comment ) .then(comment => { diff --git a/src/core/server/models/action/moderation/comment.ts b/src/core/server/models/action/moderation/comment.ts index dc7fbf84e..c6071031f 100644 --- a/src/core/server/models/action/moderation/comment.ts +++ b/src/core/server/models/action/moderation/comment.ts @@ -56,7 +56,8 @@ export type CreateCommentModerationActionInput = Omit< export async function createCommentModerationAction( mongo: Db, tenantID: string, - input: CreateCommentModerationActionInput + input: CreateCommentModerationActionInput, + now: Date ) { // default are the properties set by the application when a new comment // moderation action is created. @@ -66,7 +67,7 @@ export async function createCommentModerationAction( > = { id: uuid.v4(), tenantID, - createdAt: new Date(), + createdAt: now, }; // Merge the defaults and the input together. diff --git a/src/core/server/models/comment/comment.ts b/src/core/server/models/comment/comment.ts index 9f0cd4783..6192a3a59 100644 --- a/src/core/server/models/comment/comment.ts +++ b/src/core/server/models/comment/comment.ts @@ -1,4 +1,4 @@ -import { isEmpty, merge } from "lodash"; +import { isEmpty } from "lodash"; import { Db } from "mongodb"; import performanceNow from "performance-now"; import uuid from "uuid"; @@ -253,19 +253,19 @@ export function validateEditable( export interface EditComment { /** - * oldComment is the Comment that was previously set. + * before is the comment before the edit. */ - oldComment: Comment; + before: Readonly; /** - * editedComment is the Comment after the edit was performed. + * after is the comment after the edit. */ - editedComment: Comment; + after: Readonly; /** - * newRevision returns the new revision that was created in the Comment. + * revision is the new revision generated. */ - newRevision: Revision; + revision: Readonly; } /** @@ -345,25 +345,25 @@ export async function editComment( throw new Error("comment edit failed for an unexpected reason"); } - // Create a new "editedComment" where the same changes were applied to it as + // Create a new "after" where the same changes were applied to it as // we did to the MongoDB document. - const editedComment: Comment = merge({}, result.value, { - // Add in all the $set operations. + const after: Comment = { + ...result.value, + // $set status status, - metadata, - // Merge the actionCounts from the old Comment with the new actionCounts. + // $inc actionCounts actionCounts: mergeCommentActionCounts( result.value.actionCounts, actionCounts ), - // Add in the $push operations. + // $push revisions revisions: [...result.value.revisions, revision], - }); + }; return { - oldComment: result.value, - editedComment, - newRevision: revision, + before: result.value, + after, + revision, }; } @@ -709,14 +709,14 @@ function applyInputToQuery( export interface UpdateCommentStatus { /** - * comment is the updated Comment with the new status associated with it. + * before is the comment before editing the status. */ - comment: Readonly; + before: Readonly; /** - * oldStatus is the previous status that the given Comment had. + * after is the comment after editing the status. */ - oldStatus: GQLCOMMENT_STATUS; + after: Readonly; } export async function updateCommentStatus( @@ -748,15 +748,12 @@ export async function updateCommentStatus( return null; } - // Grab the old status. - const oldStatus = result.value.status; - return { - comment: { + before: result.value, + after: { ...result.value, status, }, - oldStatus, }; } diff --git a/src/core/server/models/story/counts/index.ts b/src/core/server/models/story/counts/index.ts index 53417a1fd..3c08d4f4b 100644 --- a/src/core/server/models/story/counts/index.ts +++ b/src/core/server/models/story/counts/index.ts @@ -126,8 +126,6 @@ export const updateStoryActionCounts = ( action: EncodedCommentActionCounts ) => updateStoryCounts(mongo, redis, tenantID, id, { action }); -export type StoryCounts = DeepPartial; - /** * updateStoryCounts will update the comment counts for the story indicated. * @@ -142,7 +140,7 @@ export async function updateStoryCounts( redis: AugmentedRedis, tenantID: string, id: string, - commentCounts: StoryCounts + commentCounts: DeepPartial ) { // Update all the specific comment moderation queue counts. const update: DeepPartial = { commentCounts }; diff --git a/src/core/server/models/story/counts/shared.ts b/src/core/server/models/story/counts/shared.ts index 426ede297..02916fab0 100644 --- a/src/core/server/models/story/counts/shared.ts +++ b/src/core/server/models/story/counts/shared.ts @@ -2,10 +2,11 @@ import { flatten, flattenDeep, identity, isEmpty, pickBy } from "lodash"; import { Db } from "mongodb"; import ms from "ms"; +import { DeepPartial } from "coral-common/types"; import logger from "coral-server/logger"; import { CommentModerationCountsPerQueue, - StoryCounts, + StoryCommentCounts, } from "coral-server/models/story/counts"; import { stories as collection } from "coral-server/services/mongodb/collections"; import { AugmentedPipeline, AugmentedRedis } from "coral-server/services/redis"; @@ -180,7 +181,7 @@ export async function retrieveSharedModerationQueueQueuesCounts( export async function updateSharedCommentCounts( redis: AugmentedRedis, tenantID: string, - commentCounts: StoryCounts + commentCounts: DeepPartial ) { const pipeline: AugmentedPipeline = redis.pipeline(); diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 837cd65e8..777869dfd 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -1,4 +1,5 @@ import bcrypt from "bcryptjs"; +import { identity, isEmpty, pickBy } from "lodash"; import { DateTime, DurationObject } from "luxon"; import { Db, MongoError } from "mongodb"; import uuid from "uuid"; @@ -19,16 +20,6 @@ import { UsernameAlreadySetError, UserNotFoundError, } from "coral-server/errors"; -import { - GQLBanStatus, - GQLDIGEST_FREQUENCY, - GQLPremodStatus, - GQLSuspensionStatus, - GQLTimeRange, - GQLUSER_ROLE, - GQLUsernameStatus, - GQLUserNotificationSettings, -} from "coral-server/graph/tenant/schema/__generated__/types"; import logger from "coral-server/logger"; import { Connection, @@ -40,6 +31,21 @@ import { TenantResource } from "coral-server/models/tenant"; import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates"; import { users as collection } from "coral-server/services/mongodb/collections"; +import { + GQLBanStatus, + GQLDIGEST_FREQUENCY, + GQLPremodStatus, + GQLSuspensionStatus, + GQLTimeRange, + GQLUSER_ROLE, + GQLUsernameStatus, + GQLUserNotificationSettings, +} from "coral-server/graph/tenant/schema/__generated__/types"; + +import { + CommentStatusCounts, + createEmptyCommentStatusCounts, +} from "../comment"; import { getLocalProfile, hasLocalProfile } from "./helpers"; export interface LocalProfile { @@ -352,6 +358,10 @@ export interface Digest { createdAt: Date; } +export interface UserCommentCounts { + status: CommentStatusCounts; +} + /** * User is someone that leaves Comments, and logs in. */ @@ -461,6 +471,8 @@ export interface User extends TenantResource { * deletedAt is the time that this user was deleted from our system. */ deletedAt?: Date; + + commentCounts: UserCommentCounts; } function hashPassword(password: string): Promise { @@ -514,6 +526,9 @@ async function findOrCreateUserInput( moderatorNotes: [], digests: [], createdAt: now, + commentCounts: { + status: createEmptyCommentStatusCounts(), + }, }; if (input.username) { @@ -2446,3 +2461,32 @@ export async function deleteModeratorNote( } return result.value; } + +export async function updateUserCommentCounts( + mongo: Db, + tenantID: string, + id: string, + commentCounts: DeepPartial +) { + // Update all the specific comment moderation queue counts. + const update: DeepPartial = { commentCounts }; + const $inc = pickBy(dotize(update), identity); + if (isEmpty($inc)) { + // Nothing needs to be incremented, just return the User. + return retrieveUser(mongo, tenantID, id); + } + + logger.trace({ update: { $inc } }, "incrementing user comment counts"); + + const result = await collection(mongo).findOneAndUpdate( + { id, tenantID }, + { $inc }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + + return result.value || null; +} diff --git a/src/core/server/services/comments/comments.ts b/src/core/server/services/comments/comments.ts index b46452e49..f13cf9885 100644 --- a/src/core/server/services/comments/comments.ts +++ b/src/core/server/services/comments/comments.ts @@ -1,469 +1,16 @@ import { DateTime } from "luxon"; import { Db } from "mongodb"; -import { ERROR_TYPES } from "coral-common/errors"; -import { Omit } from "coral-common/types"; -import { Config } from "coral-server/config"; -import { - CommentNotFoundError, - CoralError, - StoryNotFoundError, -} from "coral-server/errors"; +import { CommentNotFoundError } from "coral-server/errors"; import { GQLTAG } from "coral-server/graph/tenant/schema/__generated__/types"; -import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; -import logger from "coral-server/logger"; -import { - encodeActionCounts, - filterDuplicateActions, -} from "coral-server/models/action/comment"; -import { createCommentModerationAction } from "coral-server/models/action/moderation/comment"; import { addCommentTag, - createComment, - CreateCommentInput, - editComment, - EditCommentInput, - pushChildCommentIDOntoParent, removeCommentTag, retrieveComment, - validateEditable, } from "coral-server/models/comment"; -import { - getLatestRevision, - hasAncestors, - hasPublishedStatus, -} from "coral-server/models/comment/helpers"; -import { - retrieveStory, - StoryCounts, - updateStoryCounts, -} from "coral-server/models/story"; +import { getLatestRevision } from "coral-server/models/comment/helpers"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; -import { - publishCommentCreated, - publishCommentReplyCreated, - publishCommentStatusChanges, - publishModerationQueueChanges, -} from "coral-server/services/events"; -import { AugmentedRedis } from "coral-server/services/redis"; -import { Request } from "coral-server/types/express"; - -import { updateUserLastCommentID } from "../users"; -import { addCommentActions, CreateAction } from "./actions"; -import { calculateCounts, calculateCountsDiff } from "./moderation/counts"; -import { PhaseResult, processForModeration } from "./pipeline"; - -export type CreateComment = Omit< - CreateCommentInput, - "status" | "metadata" | "ancestorIDs" | "actionCounts" | "tags" ->; - -export async function create( - mongo: Db, - redis: AugmentedRedis, - config: Config, - publish: Publisher, - tenant: Tenant, - author: User, - input: CreateComment, - nudge: boolean, - now = new Date(), - req?: Request -) { - let log = logger.child( - { - authorID: author.id, - tenantID: tenant.id, - storyID: input.storyID, - parentID: input.parentID, - nudge, - }, - true - ); - - log.trace("creating comment on story"); - - // Grab the story that we'll use to check moderation pieces with. - const story = await retrieveStory(mongo, tenant.id, input.storyID); - if (!story) { - throw new StoryNotFoundError(input.storyID); - } - - const ancestorIDs: string[] = []; - if (input.parentID) { - // Check to see that the reference parent ID exists. - const parent = await retrieveComment(mongo, tenant.id, input.parentID); - if (!parent) { - throw new CommentNotFoundError(input.parentID); - } - - // Check that the parent comment was visible. - if (!hasPublishedStatus(parent)) { - throw new CommentNotFoundError(parent.id); - } - - ancestorIDs.push(input.parentID); - if (hasAncestors(parent)) { - // Push the parent's ancestors id's into the comment's ancestor id's. - ancestorIDs.push(...parent.ancestorIDs); - } - - log.trace( - { ancestorIDs: ancestorIDs.length }, - "pushed parent ancestorIDs into comment" - ); - } - - let result: PhaseResult; - - try { - // Run the comment through the moderation phases. - result = await processForModeration({ - action: "NEW", - log, - mongo, - redis, - config, - nudge, - story, - tenant, - comment: input, - author, - req, - now, - }); - } catch (err) { - if ( - err instanceof CoralError && - err.type === ERROR_TYPES.MODERATION_NUDGE_ERROR - ) { - log.info({ err }, "detected pipeline nudge"); - } - - throw err; - } - - const { actions, body, status, metadata, tags } = result; - - // This is the first time this comment is being published.. So we need to - // ensure we don't run into any race conditions when we create the comment. - // One of the situations where we could encounter a race is when the comment - // is created, and does not have it's flag data associated with it. This would - // cause the comment to not be added to the flagged queue. If a flag is - // pending, and a user flags this comment before the next step can proceed, - // then we would end up double adding the comment to the flagged queue. - // Instead, we need to add the action metadata to the comment before we add it - // for the first time to ensure that the data is there for when the next flag - // is added, that it can already know that the comment is already in the - // queue. - let actionCounts = {}; - if (actions.length > 0) { - // Determine the unique actions, we will use this to compute the comment - // action counts. This should match what is added below. - const deDuplicatedActions = filterDuplicateActions(actions); - - // Encode the action counts. - actionCounts = encodeActionCounts(...deDuplicatedActions); - } - - // Create the comment! - const comment = await createComment( - mongo, - tenant.id, - { - ...input, - tags, - body, - status, - ancestorIDs, - metadata, - actionCounts, - }, - now - ); - - await updateUserLastCommentID(redis, tenant, author, comment.id); - - // Pull the revision out. - const revision = getLatestRevision(comment); - - log = log.child( - { commentID: comment.id, status, revisionID: revision.id }, - true - ); - - log.trace("comment created"); - - if (input.parentID) { - // Push the child's ID onto the parent. - await pushChildCommentIDOntoParent( - mongo, - tenant.id, - input.parentID, - comment.id - ); - - log.trace("pushed child comment id onto parent"); - } - - if (actions.length > 0) { - // Actually add the actions to the database. This will not interact with the - // counts at all. - const upsertedActions = await addCommentActions( - mongo, - tenant, - actions.map( - (action): CreateAction => ({ - ...action, - commentID: comment.id, - commentRevisionID: revision.id, - - // Store the Story ID on the action. - storyID: story.id, - }) - ), - now - ); - - log.trace({ actions: upsertedActions.length }, "added actions to comment"); - } - const moderationQueue = calculateCounts(comment); - - // Publish changes. - publishModerationQueueChanges(publish, moderationQueue, comment); - - // If this is a reply, publish it. - if (input.parentID) { - publishCommentReplyCreated(publish, comment); - } - - // If this comment is visible (and not a reply), publish it. - if (!input.parentID && hasPublishedStatus(comment)) { - publishCommentCreated(publish, comment); - } - - // Compile the changes we want to apply to the story counts. - const storyCounts: Required> = { - // This is a new comment, so we need to increment for this status. - status: { [status]: 1 }, - // This comment is being created, so we can compute it raw from the comment - // that we created. - moderationQueue, - }; - - log.trace({ storyCounts }, "updating story status counts"); - - // Increment the status count for the particular status on the Story. - await updateStoryCounts(mongo, redis, tenant.id, story.id, storyCounts); - - return comment; -} - -export type EditComment = Omit< - EditCommentInput, - "status" | "authorID" | "lastEditableCommentCreatedAt" | "metadata" ->; - -export async function edit( - mongo: Db, - redis: AugmentedRedis, - config: Config, - publish: Publisher, - tenant: Tenant, - author: User, - input: EditComment, - now = new Date(), - req?: Request -) { - let log = logger.child({ commentID: input.id, tenantID: tenant.id }, true); - - // Get the comment that we're editing. This comment is considered stale, - // because it wasn't involved in the atomic transaction. - const originalStaleComment = await retrieveComment( - mongo, - tenant.id, - input.id - ); - if (!originalStaleComment) { - throw new CommentNotFoundError(input.id); - } - - // The editable time is based on the current time, and the edit window - // length. By subtracting the current date from the edit window length, we - // get the maximum value for the `createdAt` time that would be permitted - // for the comment edit to succeed. - const lastEditableCommentCreatedAt = getLastCommentEditableUntilDate( - tenant, - now - ); - - // Validate and potentially return with a more useful error. - validateEditable(originalStaleComment, { - authorID: author.id, - lastEditableCommentCreatedAt, - }); - - // Grab the story that we'll use to check moderation pieces with. - const story = await retrieveStory( - mongo, - tenant.id, - originalStaleComment.storyID - ); - if (!story) { - throw new StoryNotFoundError(originalStaleComment.storyID); - } - - // Run the comment through the moderation phases. - const { body, status, metadata, actions } = await processForModeration({ - action: "EDIT", - log, - mongo, - redis, - config, - story, - tenant, - comment: input, - author, - req, - now, - }); - - let actionCounts = {}; - if (actions.length > 0) { - // Encode the new action counts that are going to be added to the new - // revision. - actionCounts = encodeActionCounts(...filterDuplicateActions(actions)); - } - - log.trace( - { predictedActionCounts: actionCounts }, - "associating action counts with comment" - ); - - // Perform the edit. - const result = await editComment( - mongo, - tenant.id, - { - id: input.id, - authorID: author.id, - body, - status, - metadata, - actionCounts, - lastEditableCommentCreatedAt, - }, - now - ); - if (!result) { - throw new CommentNotFoundError(input.id); - } - - // Pull the old/edited comments out of the edit result. - const { oldComment, editedComment, newRevision } = result; - - log = log.child({ revisionID: newRevision.id }, true); - - if (actions.length > 0) { - // Insert and handle creating the actions. - const upsertedActions = await addCommentActions( - mongo, - tenant, - actions.map( - (action): CreateAction => ({ - ...action, - commentID: editedComment.id, - commentRevisionID: newRevision.id, - storyID: story.id, - }) - ), - now - ); - - log.trace( - { - actualActionCounts: encodeActionCounts(...upsertedActions), - actions: upsertedActions.length, - }, - "added actions to comment" - ); - } - - // Compute the changes in queue counts. This looks at the action counts that - // are encoded, as well as the comment status's. We however may have had the - // comment status when we grabbed the updated comment after changing the - // action counts, so we extract the action counts out of the edited comment - // and use the status from the moderation decision. - const moderationQueue = calculateCountsDiff(oldComment, { - status, - actionCounts: editedComment.actionCounts, - }); - - // Compile the changes we want to apply to the story counts. - const storyCounts: Required> = { - // Status is updated below if it has been changed. - status: {}, - moderationQueue, - }; - - if (oldComment.status !== editedComment.status) { - // Increment the status count for the particular status on the Story, and - // decrement the status on the comment's previous status. The old comment - // status was only there before the atomic mutation. The new status is based - // on the moderation pipeline. - storyCounts.status[oldComment.status] = -1; - storyCounts.status[status] = 1; - - // The comment status changed as a result of a pipeline operation, create a - // moderation action as a result. - await createCommentModerationAction(mongo, tenant.id, { - commentID: editedComment.id, - commentRevisionID: newRevision.id, - status: editedComment.status, - moderatorID: null, - }); - } - - log.trace({ storyCounts }, "updating story status counts"); - - // Update the story counts as a result. - await updateStoryCounts(mongo, redis, tenant.id, story.id, storyCounts); - - // Publish changes. - publishModerationQueueChanges(publish, moderationQueue, editedComment); - publishCommentStatusChanges( - publish, - oldComment.status, - editedComment.status, - editedComment.id, - // This is a comment that was edited, so it should not present a moderator. - null - ); - - return editedComment; -} - -/** - * getLastCommentEditableUntilDate will return the `createdAt` date that will - * represent the _oldest_ date that a comment could have been created on in - * order to still be editable. - * - * @param tenant the tenant that contains settings related editing - * @param now the date that is the base, defaulting to the current time - */ -export function getLastCommentEditableUntilDate( - tenant: Pick, - now = new Date() -): Date { - return ( - DateTime.fromJSDate(now) - // editCommentWindowLength is in seconds, so multiply by 1000 to get - // milliseconds. - .minus(tenant.editCommentWindowLength * 1000) - .toJSDate() - ); -} /** * getCommentEditableUntilDate will return the date that the given comment is diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts index bd46c87d5..f6274822e 100644 --- a/src/core/server/services/comments/moderation/index.ts +++ b/src/core/server/services/comments/moderation/index.ts @@ -1,2 +1,2 @@ export * from "./counts"; -export * from "./moderation"; +export { default as moderate } from "./moderate"; diff --git a/src/core/server/services/comments/moderation/moderate.ts b/src/core/server/services/comments/moderation/moderate.ts new file mode 100644 index 000000000..89f47429b --- /dev/null +++ b/src/core/server/services/comments/moderation/moderate.ts @@ -0,0 +1,46 @@ +import { Db } from "mongodb"; + +import { CommentNotFoundError } from "coral-server/errors"; +import { + createCommentModerationAction, + CreateCommentModerationActionInput, +} from "coral-server/models/action/moderation/comment"; +import { updateCommentStatus } from "coral-server/models/comment"; +import { Tenant } from "coral-server/models/tenant"; + +export type Moderate = CreateCommentModerationActionInput; + +export default async function moderate( + mongo: Db, + tenant: Tenant, + input: Moderate, + now: Date +) { + // TODO: wrap these operations in a transaction? + + // Create the moderation action in the audit log. + const action = await createCommentModerationAction( + mongo, + tenant.id, + input, + now + ); + if (!action) { + // TODO: wrap in better error? + throw new Error("could not create moderation action"); + } + + // Update the Comment's status. + const result = await updateCommentStatus( + mongo, + tenant.id, + input.commentID, + input.commentRevisionID, + input.status + ); + if (!result) { + throw new CommentNotFoundError(input.commentID, input.commentRevisionID); + } + + return result; +} diff --git a/src/core/server/services/comments/moderation/moderation.spec.ts b/src/core/server/services/comments/moderation/moderation.spec.ts deleted file mode 100644 index acda37d06..000000000 --- a/src/core/server/services/comments/moderation/moderation.spec.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; -import { calculateCountsDiff } from "./counts"; - -it("allows transition from NONE to APPROVED", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.NONE, actionCounts: {} }, - { status: GQLCOMMENT_STATUS.APPROVED, actionCounts: {} } - ) - ).toEqual({ - total: -1, - queues: { - reported: 0, - pending: 0, - unmoderated: -1, - }, - }); -}); - -it("allows transition from NONE to REJECTED", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.NONE, actionCounts: {} }, - { status: GQLCOMMENT_STATUS.REJECTED, actionCounts: {} } - ) - ).toEqual({ - total: -1, - queues: { - reported: 0, - pending: 0, - unmoderated: -1, - }, - }); -}); - -it("allows transition from NONE to FLAGGED*", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.NONE, actionCounts: {} }, - { status: GQLCOMMENT_STATUS.NONE, actionCounts: { FLAG: 1 } } - ) - ).toEqual({ - total: 0, - queues: { - reported: 1, - pending: 0, - unmoderated: 0, - }, - }); -}); - -it("allows transition from FLAGGED* to APPROVED", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.NONE, actionCounts: { FLAG: 1 } }, - { status: GQLCOMMENT_STATUS.APPROVED, actionCounts: { FLAG: 1 } } - ) - ).toEqual({ - total: -1, - queues: { - reported: -1, - pending: 0, - unmoderated: -1, - }, - }); -}); - -it("allows transition from FLAGGED* to REJECTED", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.NONE, actionCounts: { FLAG: 1 } }, - { status: GQLCOMMENT_STATUS.REJECTED, actionCounts: { FLAG: 1 } } - ) - ).toEqual({ - total: -1, - queues: { - reported: -1, - pending: 0, - unmoderated: -1, - }, - }); -}); - -it("allows transition from PREMOD to APPROVED", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.PREMOD, actionCounts: {} }, - { status: GQLCOMMENT_STATUS.APPROVED, actionCounts: {} } - ) - ).toEqual({ - total: -1, - queues: { - reported: 0, - pending: -1, - unmoderated: -1, - }, - }); -}); - -it("allows transition from PREMOD to REJECTED", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.PREMOD, actionCounts: {} }, - { status: GQLCOMMENT_STATUS.REJECTED, actionCounts: {} } - ) - ).toEqual({ - total: -1, - queues: { - reported: 0, - pending: -1, - unmoderated: -1, - }, - }); -}); - -it("allows transition from SYSTEM_WITHHELD to APPROVED", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actionCounts: {} }, - { status: GQLCOMMENT_STATUS.APPROVED, actionCounts: {} } - ) - ).toEqual({ - total: -1, - queues: { - reported: 0, - pending: -1, - unmoderated: -1, - }, - }); -}); - -it("allows transition from SYSTEM_WITHHELD to REJECTED", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actionCounts: {} }, - { status: GQLCOMMENT_STATUS.REJECTED, actionCounts: {} } - ) - ).toEqual({ - total: -1, - queues: { - reported: 0, - pending: -1, - unmoderated: -1, - }, - }); -}); - -it("allows transition from APPROVED to REJECTED", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.APPROVED, actionCounts: {} }, - { status: GQLCOMMENT_STATUS.REJECTED, actionCounts: {} } - ) - ).toEqual({ - total: 0, - queues: { - reported: 0, - pending: 0, - unmoderated: 0, - }, - }); -}); - -it("allows transition from REJECTED to APPROVED", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.REJECTED, actionCounts: {} }, - { status: GQLCOMMENT_STATUS.APPROVED, actionCounts: {} } - ) - ).toEqual({ - total: 0, - queues: { - reported: 0, - pending: 0, - unmoderated: 0, - }, - }); -}); - -it("allows no transition once a comment has been flagged more than once", () => { - expect( - calculateCountsDiff( - { status: GQLCOMMENT_STATUS.NONE, actionCounts: { FLAG: 1 } }, - { status: GQLCOMMENT_STATUS.NONE, actionCounts: { FLAG: 2 } } - ) - ).toEqual({ - total: 0, - queues: { - reported: 0, - pending: 0, - unmoderated: 0, - }, - }); -}); diff --git a/src/core/server/services/comments/moderation/moderation.ts b/src/core/server/services/comments/moderation/moderation.ts deleted file mode 100644 index 7922689a0..000000000 --- a/src/core/server/services/comments/moderation/moderation.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Db } from "mongodb"; - -import { Omit } from "coral-common/types"; -import { CommentNotFoundError, StoryNotFoundError } from "coral-server/errors"; -import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; -import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; -import logger from "coral-server/logger"; -import { - createCommentModerationAction, - CreateCommentModerationActionInput, -} from "coral-server/models/action/moderation/comment"; -import { updateCommentStatus } from "coral-server/models/comment"; -import { updateStoryCounts } from "coral-server/models/story"; -import { Tenant } from "coral-server/models/tenant"; -import { - publishCommentReleased, - publishCommentStatusChanges, - publishModerationQueueChanges, -} from "coral-server/services/events"; -import { AugmentedRedis } from "coral-server/services/redis"; - -import { calculateCountsDiff } from "./counts"; - -export type Moderate = Omit; - -const moderate = ( - status: GQLCOMMENT_STATUS.APPROVED | GQLCOMMENT_STATUS.REJECTED -) => async ( - mongo: Db, - redis: AugmentedRedis, - publish: Publisher, - tenant: Tenant, - input: Moderate -) => { - // TODO: wrap these operations in a transaction? - - // Create the logger. - const log = logger.child( - { - ...input, - tenantID: tenant.id, - newStatus: status, - }, - true - ); - - // Update the Comment's status. - const result = await updateCommentStatus( - mongo, - tenant.id, - input.commentID, - input.commentRevisionID, - status - ); - if (!result) { - throw new CommentNotFoundError(input.commentID, input.commentRevisionID); - } - - log.trace("updated comment status"); - - // Create the moderation action in the audit log. - const action = await createCommentModerationAction(mongo, tenant.id, { - ...input, - status, - }); - if (!action) { - // TODO: wrap in better error? - throw new Error("could not create moderation action"); - } - - log.trace( - { commentModerationActionID: action.id }, - "created the moderation action" - ); - - // Compute the queue difference as a result of the old status and the new - // status. - const moderationQueue = calculateCountsDiff( - { - status: result.oldStatus, - actionCounts: result.comment.actionCounts, - }, - { - status, - actionCounts: result.comment.actionCounts, - } - ); - - // Update the story comment counts. - const story = await updateStoryCounts( - mongo, - redis, - tenant.id, - result.comment.storyID, - { - // Update the comment counts. - status: { - [result.oldStatus]: -1, - [status]: 1, - }, - - moderationQueue, - } - ); - if (!story) { - throw new StoryNotFoundError(result.comment.storyID); - } - - // Publish changes. - publishModerationQueueChanges(publish, moderationQueue, result.comment); - publishCommentStatusChanges( - publish, - result.oldStatus, - status, - result.comment.id, - input.moderatorID - ); - if ( - [GQLCOMMENT_STATUS.PREMOD, GQLCOMMENT_STATUS.SYSTEM_WITHHELD].includes( - result.oldStatus - ) && - status === "APPROVED" - ) { - publishCommentReleased(publish, result.comment); - } - - log.trace({ oldStatus: result.oldStatus }, "adjusted story comment counts"); - - return result.comment; -}; - -export const approve = moderate(GQLCOMMENT_STATUS.APPROVED); - -export const reject = moderate(GQLCOMMENT_STATUS.REJECTED); diff --git a/src/core/server/services/migrate/migrations/1575649180_user_comment_counts.ts b/src/core/server/services/migrate/migrations/1575649180_user_comment_counts.ts new file mode 100644 index 000000000..4cc454a3e --- /dev/null +++ b/src/core/server/services/migrate/migrations/1575649180_user_comment_counts.ts @@ -0,0 +1,100 @@ +import { Db } from "mongodb"; + +import { + CommentStatusCounts, + createEmptyCommentStatusCounts, +} from "coral-server/models/comment"; +import Migration from "coral-server/services/migrate/migration"; +import collections from "coral-server/services/mongodb/collections"; + +import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; + +const BATCH_SIZE = 500; + +export default class extends Migration { + public async up(mongo: Db, tenantID: string) { + const cursor = collections + .comments<{ + _id: string; + statuses: { status: GQLCOMMENT_STATUS; sum: number }[]; + }>(mongo) + .aggregate([ + // Find all comments written by this Tenant. + { + $match: { tenantID }, + }, + // Group each comment into it's authorID and status. + { + $group: { + _id: { + authorID: "$authorID", + status: "$status", + }, + sum: { $sum: 1 }, + }, + }, + // Group the documents from the previous stage into a count of each + // author's status counts. + { + $group: { + _id: "$_id.authorID", + statuses: { + $push: { + status: "$_id.status", + sum: "$sum", + }, + }, + }, + }, + ]); + + let updates: { + updateOne: { + filter: { tenantID: string; id: string }; + update: { $set: { commentCounts: { status: CommentStatusCounts } } }; + }; + }[] = []; + + while (await cursor.hasNext()) { + const doc = await cursor.next(); + if (!doc) { + break; + } + + // Reconstruct the comment status counts. + const statuses = createEmptyCommentStatusCounts(); + for (const { status, sum } of doc.statuses) { + statuses[status] += sum; + } + + // Push the update. + updates.push({ + updateOne: { + filter: { + tenantID, + id: doc._id, + }, + update: { + $set: { + commentCounts: { + status: statuses, + }, + }, + }, + }, + }); + + // Process updates if we are at the batch size. + if (updates.length >= BATCH_SIZE) { + await collections.users(mongo).bulkWrite(updates); + updates = []; + } + } + + // Process any missed updates. + if (updates.length > 0) { + await collections.users(mongo).bulkWrite(updates); + updates = []; + } + } +} diff --git a/src/core/server/stacks/approveComment.ts b/src/core/server/stacks/approveComment.ts new file mode 100644 index 000000000..3e2732721 --- /dev/null +++ b/src/core/server/stacks/approveComment.ts @@ -0,0 +1,52 @@ +import { Db } from "mongodb"; + +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; +import { Tenant } from "coral-server/models/tenant"; +import { moderate } from "coral-server/services/comments/moderation"; +import { AugmentedRedis } from "coral-server/services/redis"; + +import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { publishChanges, updateAllCounts } from "./helpers"; + +const approveComment = async ( + mongo: Db, + redis: AugmentedRedis, + publisher: Publisher, + tenant: Tenant, + commentID: string, + commentRevisionID: string, + moderatorID: string, + now: Date +) => { + // Approve the comment. + const result = await moderate( + mongo, + tenant, + { + commentID, + commentRevisionID, + moderatorID, + status: GQLCOMMENT_STATUS.APPROVED, + }, + now + ); + + // Update all the comment counts on stories and users. + const counts = await updateAllCounts(mongo, redis, { + tenant, + ...result, + }); + + // Publish changes to the event publisher. + await publishChanges(publisher, { + ...result, + ...counts, + moderatorID, + }); + + // Return the resulting comment. + return result.after; +}; + +export default approveComment; diff --git a/src/core/server/stacks/createComment.ts b/src/core/server/stacks/createComment.ts new file mode 100644 index 000000000..bb9124911 --- /dev/null +++ b/src/core/server/stacks/createComment.ts @@ -0,0 +1,247 @@ +import { Db } from "mongodb"; + +import { ERROR_TYPES } from "coral-common/errors"; +import { Omit } from "coral-common/types"; +import { Config } from "coral-server/config"; +import { + CommentNotFoundError, + CoralError, + StoryNotFoundError, +} from "coral-server/errors"; +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; +import logger from "coral-server/logger"; +import { + encodeActionCounts, + filterDuplicateActions, +} from "coral-server/models/action/comment"; +import { + createComment, + CreateCommentInput, + pushChildCommentIDOntoParent, + retrieveComment, +} from "coral-server/models/comment"; +import { + getLatestRevision, + hasAncestors, + hasPublishedStatus, +} from "coral-server/models/comment/helpers"; +import { retrieveStory } from "coral-server/models/story"; +import { Tenant } from "coral-server/models/tenant"; +import { User } from "coral-server/models/user"; +import { + addCommentActions, + CreateAction, +} from "coral-server/services/comments/actions"; +import { + PhaseResult, + processForModeration, +} from "coral-server/services/comments/pipeline"; +import { + publishCommentCreated, + publishCommentReplyCreated, +} from "coral-server/services/events"; +import { AugmentedRedis } from "coral-server/services/redis"; +import { updateUserLastCommentID } from "coral-server/services/users"; +import { Request } from "coral-server/types/express"; + +import { publishChanges, updateAllCounts } from "./helpers"; + +export type CreateComment = Omit< + CreateCommentInput, + "status" | "metadata" | "ancestorIDs" | "actionCounts" | "tags" +>; + +export default async function create( + mongo: Db, + redis: AugmentedRedis, + config: Config, + publisher: Publisher, + tenant: Tenant, + author: User, + input: CreateComment, + nudge: boolean, + now = new Date(), + req?: Request +) { + let log = logger.child( + { + authorID: author.id, + tenantID: tenant.id, + storyID: input.storyID, + parentID: input.parentID, + nudge, + }, + true + ); + + log.trace("creating comment on story"); + + // Grab the story that we'll use to check moderation pieces with. + const story = await retrieveStory(mongo, tenant.id, input.storyID); + if (!story) { + throw new StoryNotFoundError(input.storyID); + } + + const ancestorIDs: string[] = []; + if (input.parentID) { + // Check to see that the reference parent ID exists. + const parent = await retrieveComment(mongo, tenant.id, input.parentID); + if (!parent) { + throw new CommentNotFoundError(input.parentID); + } + + // Check that the parent comment was visible. + if (!hasPublishedStatus(parent)) { + throw new CommentNotFoundError(parent.id); + } + + ancestorIDs.push(input.parentID); + if (hasAncestors(parent)) { + // Push the parent's ancestors id's into the comment's ancestor id's. + ancestorIDs.push(...parent.ancestorIDs); + } + + log.trace( + { ancestorIDs: ancestorIDs.length }, + "pushed parent ancestorIDs into comment" + ); + } + + let result: PhaseResult; + try { + // Run the comment through the moderation phases. + result = await processForModeration({ + action: "NEW", + log, + mongo, + redis, + config, + nudge, + story, + tenant, + comment: input, + author, + req, + now, + }); + } catch (err) { + if ( + err instanceof CoralError && + err.type === ERROR_TYPES.MODERATION_NUDGE_ERROR + ) { + log.info({ err }, "detected pipeline nudge"); + } + + throw err; + } + + const { actions, body, status, metadata, tags } = result; + + // This is the first time this comment is being published.. So we need to + // ensure we don't run into any race conditions when we create the comment. + // One of the situations where we could encounter a race is when the comment + // is created, and does not have it's flag data associated with it. This would + // cause the comment to not be added to the flagged queue. If a flag is + // pending, and a user flags this comment before the next step can proceed, + // then we would end up double adding the comment to the flagged queue. + // Instead, we need to add the action metadata to the comment before we add it + // for the first time to ensure that the data is there for when the next flag + // is added, that it can already know that the comment is already in the + // queue. + let actionCounts = {}; + if (actions.length > 0) { + // Determine the unique actions, we will use this to compute the comment + // action counts. This should match what is added below. + const deDuplicatedActions = filterDuplicateActions(actions); + + // Encode the action counts. + actionCounts = encodeActionCounts(...deDuplicatedActions); + } + + // Create the comment! + const comment = await createComment( + mongo, + tenant.id, + { + ...input, + tags, + body, + status, + ancestorIDs, + metadata, + actionCounts, + }, + now + ); + + await updateUserLastCommentID(redis, tenant, author, comment.id); + + // Pull the revision out. + const revision = getLatestRevision(comment); + + log = log.child( + { commentID: comment.id, status, revisionID: revision.id }, + true + ); + + log.trace("comment created"); + + if (input.parentID) { + // Push the child's ID onto the parent. + await pushChildCommentIDOntoParent( + mongo, + tenant.id, + input.parentID, + comment.id + ); + + log.trace("pushed child comment id onto parent"); + } + + if (actions.length > 0) { + // Actually add the actions to the database. This will not interact with the + // counts at all. + const upsertedActions = await addCommentActions( + mongo, + tenant, + actions.map( + (action): CreateAction => ({ + ...action, + commentID: comment.id, + commentRevisionID: revision.id, + + // Store the Story ID on the action. + storyID: story.id, + }) + ), + now + ); + + log.trace({ actions: upsertedActions.length }, "added actions to comment"); + } + + // Update all the comment counts on stories and users. + const counts = await updateAllCounts(mongo, redis, { + tenant, + after: comment, + }); + + // Publish changes to the event publisher. + await publishChanges(publisher, { + ...counts, + after: comment, + moderatorID: null, + }); + + // If this is a reply, publish it. + if (input.parentID) { + publishCommentReplyCreated(publisher, comment); + } + + // If this comment is visible (and not a reply), publish it. + if (!input.parentID && hasPublishedStatus(comment)) { + publishCommentCreated(publisher, comment); + } + + return comment; +} diff --git a/src/core/server/stacks/editComment.ts b/src/core/server/stacks/editComment.ts new file mode 100644 index 000000000..dee56eb2e --- /dev/null +++ b/src/core/server/stacks/editComment.ts @@ -0,0 +1,212 @@ +import { DateTime } from "luxon"; +import { Db } from "mongodb"; + +import { Omit } from "coral-common/types"; +import { Config } from "coral-server/config"; +import { CommentNotFoundError, StoryNotFoundError } from "coral-server/errors"; +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; +import logger from "coral-server/logger"; +import { + encodeActionCounts, + filterDuplicateActions, +} from "coral-server/models/action/comment"; +import { createCommentModerationAction } from "coral-server/models/action/moderation/comment"; +import { + editComment, + EditCommentInput, + retrieveComment, + validateEditable, +} from "coral-server/models/comment"; +import { retrieveStory } from "coral-server/models/story"; +import { Tenant } from "coral-server/models/tenant"; +import { User } from "coral-server/models/user"; +import { + addCommentActions, + CreateAction, +} from "coral-server/services/comments/actions"; +import { processForModeration } from "coral-server/services/comments/pipeline"; +import { AugmentedRedis } from "coral-server/services/redis"; +import { Request } from "coral-server/types/express"; + +import { publishChanges, updateAllCounts } from "./helpers"; + +/** + * getLastCommentEditableUntilDate will return the `createdAt` date that will + * represent the _oldest_ date that a comment could have been created on in + * order to still be editable. + * + * @param tenant the tenant that contains settings related editing + * @param now the date that is the base, defaulting to the current time + */ +function getLastCommentEditableUntilDate( + tenant: Pick, + now = new Date() +): Date { + return ( + DateTime.fromJSDate(now) + // editCommentWindowLength is in seconds, so multiply by 1000 to get + // milliseconds. + .minus(tenant.editCommentWindowLength * 1000) + .toJSDate() + ); +} + +export type EditComment = Omit< + EditCommentInput, + "status" | "authorID" | "lastEditableCommentCreatedAt" | "metadata" +>; + +export default async function edit( + mongo: Db, + redis: AugmentedRedis, + config: Config, + publisher: Publisher, + tenant: Tenant, + author: User, + input: EditComment, + now = new Date(), + req?: Request +) { + let log = logger.child({ commentID: input.id, tenantID: tenant.id }, true); + + // Get the comment that we're editing. This comment is considered stale, + // because it wasn't involved in the atomic transaction. + const originalStaleComment = await retrieveComment( + mongo, + tenant.id, + input.id + ); + if (!originalStaleComment) { + throw new CommentNotFoundError(input.id); + } + + // The editable time is based on the current time, and the edit window + // length. By subtracting the current date from the edit window length, we + // get the maximum value for the `createdAt` time that would be permitted + // for the comment edit to succeed. + const lastEditableCommentCreatedAt = getLastCommentEditableUntilDate( + tenant, + now + ); + + // Validate and potentially return with a more useful error. + validateEditable(originalStaleComment, { + authorID: author.id, + lastEditableCommentCreatedAt, + }); + + // Grab the story that we'll use to check moderation pieces with. + const story = await retrieveStory( + mongo, + tenant.id, + originalStaleComment.storyID + ); + if (!story) { + throw new StoryNotFoundError(originalStaleComment.storyID); + } + + // Run the comment through the moderation phases. + const { body, status, metadata, actions } = await processForModeration({ + action: "EDIT", + log, + mongo, + redis, + config, + story, + tenant, + comment: input, + author, + req, + now, + }); + + let actionCounts = {}; + if (actions.length > 0) { + // Encode the new action counts that are going to be added to the new + // revision. + actionCounts = encodeActionCounts(...filterDuplicateActions(actions)); + } + + log.trace( + { predictedActionCounts: actionCounts }, + "associating action counts with comment" + ); + + // Perform the edit. + const result = await editComment( + mongo, + tenant.id, + { + id: input.id, + authorID: author.id, + body, + status, + metadata, + actionCounts, + lastEditableCommentCreatedAt, + }, + now + ); + if (!result) { + throw new CommentNotFoundError(input.id); + } + + log = log.child({ revisionID: result.revision.id }, true); + + if (actions.length > 0) { + // Insert and handle creating the actions. + const upsertedActions = await addCommentActions( + mongo, + tenant, + actions.map( + (action): CreateAction => ({ + ...action, + commentID: result.after.id, + commentRevisionID: result.revision.id, + storyID: story.id, + }) + ), + now + ); + + log.trace( + { + actualActionCounts: encodeActionCounts(...upsertedActions), + actions: upsertedActions.length, + }, + "added actions to comment" + ); + } + + // If the comment status changed as a result of a pipeline operation, create a + // moderation action. + if (result.before.status !== result.after.status) { + await createCommentModerationAction( + mongo, + tenant.id, + { + commentID: result.after.id, + commentRevisionID: result.revision.id, + status: result.after.status, + moderatorID: null, + }, + now + ); + } + + // Update all the comment counts on stories and users. + const counts = await updateAllCounts(mongo, redis, { + tenant, + ...result, + }); + + // Publish changes to the event publisher. + await publishChanges(publisher, { + ...result, + ...counts, + moderatorID: null, + }); + + // Return the resulting comment. + return result.after; +} diff --git a/src/core/server/stacks/helpers/index.ts b/src/core/server/stacks/helpers/index.ts new file mode 100644 index 000000000..97c4e8d71 --- /dev/null +++ b/src/core/server/stacks/helpers/index.ts @@ -0,0 +1,2 @@ +export { default as publishChanges } from "./publishChanges"; +export { default as updateAllCounts } from "./updateAllCounts"; diff --git a/src/core/server/stacks/helpers/publishChanges.ts b/src/core/server/stacks/helpers/publishChanges.ts new file mode 100644 index 000000000..a42595f54 --- /dev/null +++ b/src/core/server/stacks/helpers/publishChanges.ts @@ -0,0 +1,43 @@ +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; +import { + Comment, + hasModeratorStatus, + hasPublishedStatus, +} from "coral-server/models/comment"; +import { CommentModerationQueueCounts } from "coral-server/models/story"; +import { + publishCommentReleased, + publishCommentStatusChanges, + publishModerationQueueChanges, +} from "coral-server/services/events"; + +interface PublishChangesInput { + before?: Readonly; + after: Readonly; + moderationQueue: CommentModerationQueueCounts; + moderatorID: string | null; +} + +export default async function publishChanges( + publish: Publisher, + input: PublishChangesInput +) { + // Publish changes. + publishModerationQueueChanges(publish, input.moderationQueue, input.after); + + // If this was a change, and it has a "before" state for the comment, process + // those updates too. + if (input.before) { + publishCommentStatusChanges( + publish, + input.before.status, + input.after.status, + input.after.id, + input.moderatorID + ); + + if (hasModeratorStatus(input.before) && hasPublishedStatus(input.after)) { + publishCommentReleased(publish, input.after); + } + } +} diff --git a/src/core/server/stacks/helpers/updateAllCounts.ts b/src/core/server/stacks/helpers/updateAllCounts.ts new file mode 100644 index 000000000..803f4cfd8 --- /dev/null +++ b/src/core/server/stacks/helpers/updateAllCounts.ts @@ -0,0 +1,86 @@ +import { Db } from "mongodb"; + +import { Comment, CommentStatusCounts } from "coral-server/models/comment"; +import { + CommentModerationQueueCounts, + updateStoryCounts, +} from "coral-server/models/story"; +import { Tenant } from "coral-server/models/tenant"; +import { updateUserCommentCounts } from "coral-server/models/user"; +import { + calculateCounts, + calculateCountsDiff, +} from "coral-server/services/comments/moderation"; +import { AugmentedRedis } from "coral-server/services/redis"; + +interface UpdateAllCountsInput { + tenant: Readonly; + before?: Readonly; + after: Readonly; +} + +function calculateModerationQueue( + input: UpdateAllCountsInput +): CommentModerationQueueCounts { + if (input.before) { + return calculateCountsDiff(input.before, input.after); + } + + return calculateCounts(input.after); +} + +function calculateStatus( + input: UpdateAllCountsInput +): Partial { + if (input.before) { + if (input.before.status !== input.after.status) { + return { + [input.before.status]: -1, + [input.after.status]: 1, + }; + } + + return {}; + } + + return { + [input.after.status]: 1, + }; +} + +export default async function updateAllCounts( + mongo: Db, + redis: AugmentedRedis, + input: UpdateAllCountsInput +) { + // Compute the queue difference as a result of the old status and the new + // status and the action counts. + const moderationQueue = calculateModerationQueue(input); + + // Compute the status changes as a result of the change to the comment status. + const status = calculateStatus(input); + + // Pull out some params from the input for easier usage. + const { + tenant, + after: { storyID, authorID }, + } = input; + + // Update the story comment counts. + await updateStoryCounts(mongo, redis, tenant.id, storyID, { + status, + moderationQueue, + }); + + if (authorID) { + // Update the user comment counts. + await updateUserCommentCounts(mongo, tenant.id, authorID, { + status, + }); + } + + return { + status, + moderationQueue, + }; +} diff --git a/src/core/server/stacks/index.ts b/src/core/server/stacks/index.ts new file mode 100644 index 000000000..6daf4527a --- /dev/null +++ b/src/core/server/stacks/index.ts @@ -0,0 +1,4 @@ +export { default as approveComment } from "./approveComment"; +export { default as createComment } from "./createComment"; +export { default as editComment } from "./editComment"; +export { default as rejectComment } from "./rejectComment"; diff --git a/src/core/server/stacks/rejectComment.ts b/src/core/server/stacks/rejectComment.ts new file mode 100644 index 000000000..e77c1eae1 --- /dev/null +++ b/src/core/server/stacks/rejectComment.ts @@ -0,0 +1,62 @@ +import { Db } from "mongodb"; + +import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; +import { hasTag } from "coral-server/models/comment"; +import { Tenant } from "coral-server/models/tenant"; +import { removeTag } from "coral-server/services/comments"; +import { moderate } from "coral-server/services/comments/moderation"; +import { AugmentedRedis } from "coral-server/services/redis"; + +import { + GQLCOMMENT_STATUS, + GQLTAG, +} from "coral-server/graph/tenant/schema/__generated__/types"; + +import { publishChanges, updateAllCounts } from "./helpers"; + +const rejectComment = async ( + mongo: Db, + redis: AugmentedRedis, + publisher: Publisher, + tenant: Tenant, + commentID: string, + commentRevisionID: string, + moderatorID: string, + now: Date +) => { + // Reject the comment. + const result = await moderate( + mongo, + tenant, + { + commentID, + commentRevisionID, + moderatorID, + status: GQLCOMMENT_STATUS.REJECTED, + }, + now + ); + + // Update all the comment counts on stories and users. + const counts = await updateAllCounts(mongo, redis, { + tenant, + ...result, + }); + + // Publish changes to the event publisher. + await publishChanges(publisher, { + ...result, + ...counts, + moderatorID, + }); + + // If there was a featured tag on this comment, remove it. + if (hasTag(result.after, GQLTAG.FEATURED)) { + return removeTag(mongo, tenant, result.after.id, GQLTAG.FEATURED); + } + + // Return the resulting comment. + return result.after; +}; + +export default rejectComment;