[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 <accounts+github@wyattjoh.ca>
This commit is contained in:
Nick Funk
2020-01-07 21:00:25 +00:00
committed by Wyatt Johnson
co-authored by Wyatt Johnson
parent 0dc3e8968a
commit e3e2e0f52e
21 changed files with 987 additions and 867 deletions
+52
View File
@@ -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;
+247
View File
@@ -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;
}
+212
View File
@@ -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<Tenant, "editCommentWindowLength">,
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;
}
+2
View File
@@ -0,0 +1,2 @@
export { default as publishChanges } from "./publishChanges";
export { default as updateAllCounts } from "./updateAllCounts";
@@ -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<Comment>;
after: Readonly<Comment>;
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);
}
}
}
@@ -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<Tenant>;
before?: Readonly<Comment>;
after: Readonly<Comment>;
}
function calculateModerationQueue(
input: UpdateAllCountsInput
): CommentModerationQueueCounts {
if (input.before) {
return calculateCountsDiff(input.before, input.after);
}
return calculateCounts(input.after);
}
function calculateStatus(
input: UpdateAllCountsInput
): Partial<CommentStatusCounts> {
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,
};
}
+4
View File
@@ -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";
+62
View File
@@ -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;