[next] Moderation Queues (#2098)

* fix: edit comment fix for reactions

* feat: Comment Queue Counts

- Removed "remove flag"
- Rearranged moderation services
- Rearranged comment counts on stories
- Added moderation queue counts to stories
- Added comments edge
- Improved Cursor/Connection types
- Improved count updators for stories

* feat: added shared comment counts and queues

- Added new AugmentedRedis type
- Added more log calls
- Update counts in shared counter as well
- Return a Query level Moderation Queue

* fix: fixed test
This commit is contained in:
Wyatt Johnson
2018-12-07 00:37:33 +01:00
committed by Kiwi
parent 5b62082693
commit 4584c3d4fb
53 changed files with 2444 additions and 768 deletions
+82 -68
View File
@@ -4,6 +4,7 @@ import { Omit } from "talk-common/types";
import { GQLCOMMENT_FLAG_REPORTED_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
import {
ACTION_TYPE,
CommentAction,
CreateActionInput,
createActions,
encodeActionCounts,
@@ -18,89 +19,107 @@ import {
updateCommentActionCounts,
} from "talk-server/models/comment";
import { Comment } from "talk-server/models/comment";
import { updateStoryActionCounts } from "talk-server/models/story";
import {
updateStoryActionCounts,
updateStoryCounts,
} from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
export type CreateAction = Omit<CreateActionInput, "storyID"> &
Required<Pick<CreateActionInput, "storyID">>;
import { AugmentedRedis } from "../redis";
import { calculateCountsDiff } from "./moderation/counts";
export type CreateAction = CreateActionInput;
export async function addCommentActions(
mongo: Db,
tenant: Tenant,
comment: Readonly<Comment>,
inputs: CreateAction[]
): Promise<Readonly<Comment>> {
...inputs: CreateAction[]
) {
// Create each of the actions, returning each of the action results.
const results = await createActions(mongo, tenant.id, inputs);
// Get the actions that were upserted, we only want to increment the action
// counts of actions that were just created.
const upsertedActions = results
return results
.filter(({ wasUpserted }) => wasUpserted)
.map(({ action }) => action);
}
if (upsertedActions.length > 0) {
// Compute the action counts.
const actionCounts = encodeActionCounts(...upsertedActions);
export async function addCommentActionCounts(
mongo: Db,
tenant: Tenant,
oldComment: Readonly<Comment>,
...actions: CommentAction[]
) {
// Compute the action counts.
const action = encodeActionCounts(...actions);
// Grab the last revision (the most recent).
const revision = getLatestRevision(comment);
// Grab the last revision (the most recent).
const revision = getLatestRevision(oldComment);
// Update the comment action counts here.
const updatedComment = await updateCommentActionCounts(
mongo,
tenant.id,
comment.id,
revision.id,
actionCounts
);
// Update the Story with the updated action counts.
await updateStoryActionCounts(
mongo,
tenant.id,
comment.storyID,
actionCounts
);
// Check to see if there was an actual comment returned (there should
// have been, we just created it!).
if (!updatedComment) {
// TODO: (wyattjoh) return a better error.
throw new Error("could not update comment action counts");
}
return updatedComment;
// Update the comment action counts here.
const updatedComment = await updateCommentActionCounts(
mongo,
tenant.id,
oldComment.id,
revision.id,
action
);
if (!updatedComment) {
// TODO: (wyattjoh) return a better error.
throw new Error("could not update comment action counts");
}
return comment;
return updatedComment;
}
async function addCommentAction(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
input: CreateActionInput
input: Omit<CreateActionInput, "storyID">
): Promise<Readonly<Comment>> {
const comment = await retrieveComment(mongo, tenant.id, input.commentID);
if (!comment) {
const oldComment = await retrieveComment(mongo, tenant.id, input.commentID);
if (!oldComment) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
}
// Store the story ID on the action as a story_id.
input.storyID = comment.storyID;
// Create the action creator input.
const action: CreateAction = {
...input,
storyID: oldComment.storyID,
};
// We have to perform a type assertion here because for some reason, the type
// coercion is not determining that because we filled in the `storyID`
// above, that at this point, it satisfies the CreateAction type.
return addCommentActions(mongo, tenant, comment, [input as CreateAction]);
// Update the actions for the comment.
const commentActions = await addCommentActions(mongo, tenant, action);
if (commentActions.length > 0) {
// Update the comment action counts.
const updatedComment = await addCommentActionCounts(
mongo,
tenant,
oldComment,
...commentActions
);
// Calculate the new story counts.
await updateStoryCounts(mongo, redis, tenant.id, updatedComment.storyID, {
action: encodeActionCounts(...commentActions),
moderationQueue: calculateCountsDiff(oldComment, updatedComment),
});
return updatedComment;
}
return oldComment;
}
export async function removeCommentAction(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
input: Omit<RemoveActionInput, "commentRevisionID">
input: Omit<RemoveActionInput, "commentRevisionID" | "reason">
): Promise<Readonly<Comment>> {
// Get the Comment that we are leaving the Action on.
const comment = await retrieveComment(mongo, tenant.id, input.commentID);
@@ -144,9 +163,14 @@ export async function removeCommentAction(
actionCounts
);
// Flags can't be removed, an that is the only type of operation that will
// affect the moderation queue counts, so we don't have to interact with
// updating the moderation queue counts.
// Update the Story with the updated action counts.
await updateStoryActionCounts(
mongo,
redis,
tenant.id,
comment.storyID,
actionCounts
@@ -171,11 +195,12 @@ export type CreateCommentReaction = Pick<
export async function createReaction(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
author: User,
input: CreateCommentReaction
) {
return addCommentAction(mongo, tenant, {
return addCommentAction(mongo, redis, tenant, {
actionType: ACTION_TYPE.REACTION,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
@@ -187,11 +212,12 @@ export type RemoveCommentReaction = Pick<RemoveActionInput, "commentID">;
export async function removeReaction(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
author: User,
input: RemoveCommentReaction
) {
return removeCommentAction(mongo, tenant, {
return removeCommentAction(mongo, redis, tenant, {
actionType: ACTION_TYPE.REACTION,
commentID: input.commentID,
userID: author.id,
@@ -205,11 +231,12 @@ export type CreateCommentDontAgree = Pick<
export async function createDontAgree(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
author: User,
input: CreateCommentDontAgree
) {
return addCommentAction(mongo, tenant, {
return addCommentAction(mongo, redis, tenant, {
actionType: ACTION_TYPE.DONT_AGREE,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
@@ -221,11 +248,12 @@ export type RemoveCommentDontAgree = Pick<RemoveActionInput, "commentID">;
export async function removeDontAgree(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
author: User,
input: RemoveCommentDontAgree
) {
return removeCommentAction(mongo, tenant, {
return removeCommentAction(mongo, redis, tenant, {
actionType: ACTION_TYPE.DONT_AGREE,
commentID: input.commentID,
userID: author.id,
@@ -241,11 +269,12 @@ export type CreateCommentFlag = Pick<
export async function createFlag(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
author: User,
input: CreateCommentFlag
) {
return addCommentAction(mongo, tenant, {
return addCommentAction(mongo, redis, tenant, {
actionType: ACTION_TYPE.FLAG,
reason: input.reason,
commentID: input.commentID,
@@ -253,18 +282,3 @@ export async function createFlag(
userID: author.id,
});
}
export type RemoveCommentFlag = Pick<RemoveActionInput, "commentID">;
export async function removeFlag(
mongo: Db,
tenant: Tenant,
author: User,
input: RemoveCommentFlag
) {
return removeCommentAction(mongo, tenant, {
actionType: ACTION_TYPE.FLAG,
commentID: input.commentID,
userID: author.id,
});
}
+191 -65
View File
@@ -1,6 +1,11 @@
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import logger from "talk-server/logger";
import {
encodeActionCounts,
filterDuplicateActions,
} from "talk-server/models/action/comment";
import {
createComment,
CreateCommentInput,
@@ -9,32 +14,46 @@ import {
getLatestRevision,
pushChildCommentIDOntoParent,
retrieveComment,
validateEditable,
} from "talk-server/models/comment";
import {
retrieveStory,
updateCommentStatusCount,
StoryCounts,
updateStoryCounts,
} from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import {
addCommentActions,
CreateAction,
} from "talk-server/services/comments/actions";
import { processForModeration } from "talk-server/services/comments/moderation";
import { Request } from "talk-server/types/express";
import { AugmentedRedis } from "../redis";
import { addCommentActions, CreateAction } from "./actions";
import { calculateCounts, calculateCountsDiff } from "./moderation/counts";
import { processForModeration } from "./pipeline";
export type CreateComment = Omit<
CreateCommentInput,
"status" | "metadata" | "grandparentIDs"
"status" | "metadata" | "grandparentIDs" | "actionCounts"
>;
export async function create(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
author: User,
input: CreateComment,
req?: Request
) {
let log = logger.child({
authorID: author.id,
tenantID: tenant.id,
storyID: input.storyID,
parentID: input.parentID,
});
// TODO: (wyattjoh) perform rate limiting based on the user?
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) {
@@ -42,8 +61,6 @@ export async function create(
throw new Error("story referenced does not exist");
}
// TODO: (wyattjoh) Check that the story was visible.
const grandparentIDs: string[] = [];
if (input.parentID) {
// Check to see that the reference parent ID exists.
@@ -53,7 +70,7 @@ export async function create(
throw new Error("parent comment referenced does not exist");
}
// TODO: (wyattjoh) Check that the parent comment was visible.
// FIXME: (wyattjoh) Check that the parent comment was visible!
// Push the parent's parent id's into the comment's grandparent id's.
grandparentIDs.push(...parent.grandparentIDs);
@@ -61,6 +78,11 @@ export async function create(
// If this parent has a parent, push it down as well.
grandparentIDs.push(parent.parentID);
}
log.trace(
{ grandparentIDs: grandparentIDs.length },
"pushed grandparent id's into comment creation"
);
}
// Run the comment through the moderation phases.
@@ -72,32 +94,42 @@ export async function create(
req,
});
// 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!
let comment = await createComment(mongo, tenant.id, {
const comment = await createComment(mongo, tenant.id, {
...input,
status,
grandparentIDs,
metadata,
actionCounts,
});
if (actions.length > 0) {
// The actions coming from the moderation phases didn't know the commentID
// at the time, and we didn't want the repetitive nature of adding the
// item_type each time, so this mapping function adds them!
const inputs = actions.map(
(action): CreateAction => ({
...action,
commentID: comment.id,
commentRevisionID: getLatestRevision(comment!).id,
// Pull the revision out.
const revision = getLatestRevision(comment);
// Store the Story ID on the action.
storyID: story.id,
})
);
log = log.child({ commentID: comment.id, status, revisionID: revision.id });
// Insert and handle creating the actions.
comment = await addCommentActions(mongo, tenant, comment, inputs);
}
log.trace("comment created");
if (input.parentID) {
// Push the child's ID onto the parent.
@@ -107,12 +139,44 @@ export async function create(
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,
})
)
);
log.trace({ actions: upsertedActions.length }, "added actions to comment");
}
// Compile the changes we want to apply to the story counts.
const storyCounts: Required<Omit<StoryCounts, "action">> = {
// 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: calculateCounts(comment),
};
log.trace({ storyCounts }, "updating story status counts");
// Increment the status count for the particular status on the Story.
await updateCommentStatusCount(mongo, tenant.id, story.id, {
[status]: 1,
});
await updateStoryCounts(mongo, redis, tenant.id, story.id, storyCounts);
return comment;
}
@@ -124,20 +188,46 @@ export type EditComment = Omit<
export async function edit(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
author: User,
input: EditComment,
req?: Request
) {
// Get the comment that we're editing.
const comment = await retrieveComment(mongo, tenant.id, input.id);
if (!comment) {
let log = logger.child({ commentID: input.id, tenantID: tenant.id });
// 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) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
}
// 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 `created_at` time that would be permitted
// for the comment edit to succeed.
const lastEditableCommentCreatedAt = new Date(
Date.now() - tenant.editCommentWindowLength
);
// 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, comment.storyID);
const story = await retrieveStory(
mongo,
tenant.id,
originalStaleComment.storyID
);
if (!story) {
// TODO: (wyattjoh) return better error.
throw new Error("story referenced does not exist");
@@ -152,54 +242,90 @@ export async function edit(
req,
});
let editedComment = await editComment(mongo, tenant.id, {
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: input.body,
status,
metadata,
// 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 `created_at` time that would be permitted
// for the comment edit to succeed.
lastEditableCommentCreatedAt: new Date(
Date.now() - tenant.editCommentWindowLength
),
actionCounts,
lastEditableCommentCreatedAt,
});
if (!comment) {
if (!result) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
}
if (actions.length > 0) {
// The actions coming from the moderation phases didn't know the commentID
// at the time, and we didn't want the repetitive nature of adding the
// item_type each time, so this mapping function adds them!
const inputs = actions.map(
(action): CreateAction => ({
...action,
// Strict null check seems to have failed here... Null checking was done
// above where we errored if the comment was falsely.
commentID: comment!.id,
commentRevisionID: getLatestRevision(comment!).id,
// Pull the old/edited comments out of the edit result.
const { oldComment, editedComment, newRevision } = result;
// Store the Story ID on the action.
storyID: story.id,
})
log = log.child({ revisionID: newRevision.id });
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,
})
)
);
// Insert and handle creating the actions.
editedComment = await addCommentActions(mongo, tenant, comment, inputs);
log.trace(
{
actualActionCounts: encodeActionCounts(...upsertedActions),
actions: upsertedActions.length,
},
"added actions to comment"
);
}
if (comment.status !== editedComment.status) {
// Compile the changes we want to apply to the story counts.
const storyCounts: Required<Omit<StoryCounts, "action">> = {
// Status is updated below if it has been changed.
status: {},
// 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.
moderationQueue: calculateCountsDiff(oldComment, {
status,
actionCounts: editedComment.actionCounts,
}),
};
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.
await updateCommentStatusCount(mongo, tenant.id, story.id, {
[comment.status]: -1,
[editedComment.status]: 1,
});
// 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;
}
log.trace({ storyCounts }, "updating story status counts");
// Update the story counts as a result.
await updateStoryCounts(mongo, redis, tenant.id, story.id, storyCounts);
return editedComment;
}
@@ -0,0 +1,194 @@
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { calculateCountsDiff } from "./counts";
it("allows transition from NONE to ACCEPTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.NONE, actionCounts: {} },
{ status: GQLCOMMENT_STATUS.ACCEPTED, 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 ACCEPTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.NONE, actionCounts: { FLAG: 1 } },
{ status: GQLCOMMENT_STATUS.ACCEPTED, 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 ACCEPTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.PREMOD, actionCounts: {} },
{ status: GQLCOMMENT_STATUS.ACCEPTED, 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 ACCEPTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actionCounts: {} },
{ status: GQLCOMMENT_STATUS.ACCEPTED, 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 ACCEPTED to REJECTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.ACCEPTED, actionCounts: {} },
{ status: GQLCOMMENT_STATUS.REJECTED, actionCounts: {} }
)
).toEqual({
total: 0,
queues: {
reported: 0,
pending: 0,
unmoderated: 0,
},
});
});
it("allows transition from REJECTED to ACCEPTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.REJECTED, actionCounts: {} },
{ status: GQLCOMMENT_STATUS.ACCEPTED, 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,
},
});
});
@@ -0,0 +1,58 @@
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action/comment";
import { Comment } from "talk-server/models/comment";
import { CommentModerationQueueCounts } from "talk-server/models/story";
export const UNMODERATED_STATUSES = [
GQLCOMMENT_STATUS.NONE,
GQLCOMMENT_STATUS.PREMOD,
GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
];
export const isUnmoderated = (comment: Pick<Comment, "status">) =>
UNMODERATED_STATUSES.includes(comment.status);
export const PENDING_STATUS = [
GQLCOMMENT_STATUS.PREMOD,
GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
];
export const isPending = (comment: Pick<Comment, "status">) =>
PENDING_STATUS.includes(comment.status);
export const REPORTED_STATUS = [GQLCOMMENT_STATUS.NONE];
export const isReported = (comment: Pick<Comment, "status" | "actionCounts">) =>
decodeActionCounts(comment.actionCounts).flag.total > 0 &&
REPORTED_STATUS.includes(comment.status);
export const isQueued = (comment: Pick<Comment, "status" | "actionCounts">) =>
isUnmoderated(comment) || isPending(comment) || isReported(comment);
export const calculateCounts = (
comment: Pick<Comment, "status" | "actionCounts">
): CommentModerationQueueCounts => ({
total: isQueued(comment) ? 1 : 0,
queues: {
reported: isReported(comment) ? 1 : 0,
pending: isPending(comment) ? 1 : 0,
unmoderated: isUnmoderated(comment) ? 1 : 0,
},
});
export const calculateCountsDiff = (
oldComment: Pick<Comment, "status" | "actionCounts">,
newComment: Pick<Comment, "status" | "actionCounts">
): CommentModerationQueueCounts => {
const oldCounts = calculateCounts(oldComment);
const newCounts = calculateCounts(newComment);
return {
total: newCounts.total - oldCounts.total,
queues: {
reported: newCounts.queues.reported - oldCounts.queues.reported,
pending: newCounts.queues.pending - oldCounts.queues.pending,
unmoderated: newCounts.queues.unmoderated - oldCounts.queues.unmoderated,
},
};
};
@@ -1,108 +1,194 @@
import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
compose,
ModerationPhaseContext,
} from "talk-server/services/comments/moderation";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { calculateCountsDiff } from "./counts";
describe("compose", () => {
it("handles when a phase throws an error", async () => {
const err = new Error("this is an error");
const enhanced = compose([
() => {
throw err;
},
]);
it("allows transition from NONE to ACCEPTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.NONE, actionCounts: {} },
{ status: GQLCOMMENT_STATUS.ACCEPTED, actionCounts: {} }
)
).toEqual({
total: -1,
queues: {
reported: 0,
pending: 0,
unmoderated: -1,
},
});
});
await expect(enhanced({} as ModerationPhaseContext)).rejects.toEqual(err);
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("handles when it returns a status", async () => {
const status = GQLCOMMENT_STATUS.ACCEPTED;
const enhanced = compose([() => ({ status })]);
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,
},
});
});
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
status,
metadata: {},
actions: [],
});
it("allows transition from FLAGGED* to ACCEPTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.NONE, actionCounts: { FLAG: 1 } },
{ status: GQLCOMMENT_STATUS.ACCEPTED, actionCounts: { FLAG: 1 } }
)
).toEqual({
total: -1,
queues: {
reported: -1,
pending: 0,
unmoderated: -1,
},
});
});
it("merges the metadata", async () => {
const status = GQLCOMMENT_STATUS.ACCEPTED;
const enhanced = compose([
() => ({ metadata: { first: true } }),
() => ({ status, metadata: { second: true } }),
() => ({ metadata: { third: true } }),
]);
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,
},
});
});
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
status,
metadata: { first: true, second: true },
actions: [],
});
it("allows transition from PREMOD to ACCEPTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.PREMOD, actionCounts: {} },
{ status: GQLCOMMENT_STATUS.ACCEPTED, actionCounts: {} }
)
).toEqual({
total: -1,
queues: {
reported: 0,
pending: -1,
unmoderated: -1,
},
});
});
it("merges actions", async () => {
const status = GQLCOMMENT_STATUS.ACCEPTED;
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,
},
});
});
const flags = [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
];
it("allows transition from SYSTEM_WITHHELD to ACCEPTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, actionCounts: {} },
{ status: GQLCOMMENT_STATUS.ACCEPTED, actionCounts: {} }
)
).toEqual({
total: -1,
queues: {
reported: 0,
pending: -1,
unmoderated: -1,
},
});
});
const enhanced = compose([
() => ({
actions: [flags[0]],
}),
() => ({
status,
actions: [flags[1]],
}),
() => ({
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
},
],
}),
]);
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,
},
});
});
const final = await enhanced({} as ModerationPhaseContext);
it("allows transition from ACCEPTED to REJECTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.ACCEPTED, actionCounts: {} },
{ status: GQLCOMMENT_STATUS.REJECTED, actionCounts: {} }
)
).toEqual({
total: 0,
queues: {
reported: 0,
pending: 0,
unmoderated: 0,
},
});
});
for (const flag of flags) {
expect(final.actions).toContainEqual(flag);
}
expect(final.actions).not.toContainEqual({
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
});
it("allows transition from REJECTED to ACCEPTED", () => {
expect(
calculateCountsDiff(
{ status: GQLCOMMENT_STATUS.REJECTED, actionCounts: {} },
{ status: GQLCOMMENT_STATUS.ACCEPTED, actionCounts: {} }
)
).toEqual({
total: 0,
queues: {
reported: 0,
pending: 0,
unmoderated: 0,
},
});
it("handles when it does not return a status", async () => {
const enhanced = compose([
() => ({ metadata: { first: true } }),
() => ({ metadata: { second: true } }),
]);
});
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
status: GQLCOMMENT_STATUS.NONE,
metadata: { first: true, second: true },
actions: [],
});
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,
},
});
});
@@ -1,91 +1,103 @@
import { Omit, Promiseable } from "talk-common/types";
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { CreateActionInput } from "talk-server/models/action/comment";
import { EditCommentInput } from "talk-server/models/comment";
import { Story } from "talk-server/models/story";
import logger from "talk-server/logger";
import {
createCommentModerationAction,
CreateCommentModerationActionInput,
} from "talk-server/models/action/moderation/comment";
import { updateCommentStatus } from "talk-server/models/comment";
import { updateStoryCounts } from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
import { AugmentedRedis } from "talk-server/services/redis";
import { calculateCountsDiff } from "./counts";
import { moderationPhases } from "./phases";
export type Moderate = Omit<CreateCommentModerationActionInput, "status">;
export type ModerationAction = Omit<
CreateActionInput,
"commentID" | "commentRevisionID"
>;
const moderate = (
status: GQLCOMMENT_STATUS.ACCEPTED | GQLCOMMENT_STATUS.REJECTED
) => async (
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
input: Moderate
) => {
// TODO: wrap these operations in a transaction?
export interface PhaseResult {
actions: ModerationAction[];
status: GQLCOMMENT_STATUS;
metadata: Record<string, any>;
}
// Create the logger.
const log = logger.child({
...input,
tenantID: tenant.id,
newStatus: status,
});
export interface ModerationPhaseContext {
story: Story;
tenant: Tenant;
comment: Partial<EditCommentInput>;
author: User;
req?: Request;
}
export type ModerationPhase = (
context: ModerationPhaseContext
) => Promiseable<PhaseResult>;
export type IntermediatePhaseResult = Partial<PhaseResult> | void;
export type IntermediateModerationPhase = (
context: ModerationPhaseContext
) => Promiseable<IntermediatePhaseResult>;
/**
* compose will create a moderation pipeline for which is executable with the
* passed actions.
*/
export const compose = (
phases: IntermediateModerationPhase[]
): ModerationPhase => async context => {
const final: PhaseResult = {
status: GQLCOMMENT_STATUS.NONE,
actions: [],
metadata: {},
};
// Loop over all the moderation phases and see if we've resolved the status.
for (const phase of phases) {
const result = await phase(context);
if (result) {
// If this result contained actions, then we should push it into the
// other actions.
const { actions } = result;
if (actions) {
final.actions.push(...actions);
}
// If this result contained metadata, then we should merge it into the
// other metadata.
const { metadata } = result;
if (metadata) {
final.metadata = {
...final.metadata,
...metadata,
};
}
// If this result contained a status, then we've finished resolving
// phases!
const { status } = result;
if (status) {
final.status = status;
break;
}
}
// Update the Comment's status.
const result = await updateCommentStatus(
mongo,
tenant.id,
input.commentID,
input.commentRevisionID,
status
);
if (!result) {
// TODO: wrap in better error?
throw new Error("specified comment not found");
}
return final;
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"
);
// 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,
},
// Compute the queue difference as a result of the old status and the new
// status.
moderationQueue: calculateCountsDiff(
{
status: result.oldStatus,
actionCounts: result.comment.actionCounts,
},
{
status,
actionCounts: result.comment.actionCounts,
}
),
}
);
if (!story) {
// TODO: wrap in better error?
throw new Error("specified story not found");
}
log.trace({ oldStatus: result.oldStatus }, "adjusted story comment counts");
return result.comment;
};
/**
* process the comment and return moderation details.
*/
export const processForModeration: ModerationPhase = compose(moderationPhases);
export const accept = moderate(GQLCOMMENT_STATUS.ACCEPTED);
export const reject = moderate(GQLCOMMENT_STATUS.REJECTED);
@@ -0,0 +1,108 @@
import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
compose,
ModerationPhaseContext,
} from "talk-server/services/comments/pipeline";
describe("compose", () => {
it("handles when a phase throws an error", async () => {
const err = new Error("this is an error");
const enhanced = compose([
() => {
throw err;
},
]);
await expect(enhanced({} as ModerationPhaseContext)).rejects.toEqual(err);
});
it("handles when it returns a status", async () => {
const status = GQLCOMMENT_STATUS.ACCEPTED;
const enhanced = compose([() => ({ status })]);
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
status,
metadata: {},
actions: [],
});
});
it("merges the metadata", async () => {
const status = GQLCOMMENT_STATUS.ACCEPTED;
const enhanced = compose([
() => ({ metadata: { first: true } }),
() => ({ status, metadata: { second: true } }),
() => ({ metadata: { third: true } }),
]);
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
status,
metadata: { first: true, second: true },
actions: [],
});
});
it("merges actions", async () => {
const status = GQLCOMMENT_STATUS.ACCEPTED;
const flags = [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
];
const enhanced = compose([
() => ({
actions: [flags[0]],
}),
() => ({
status,
actions: [flags[1]],
}),
() => ({
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
},
],
}),
]);
const final = await enhanced({} as ModerationPhaseContext);
for (const flag of flags) {
expect(final.actions).toContainEqual(flag);
}
expect(final.actions).not.toContainEqual({
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
});
});
it("handles when it does not return a status", async () => {
const enhanced = compose([
() => ({ metadata: { first: true } }),
() => ({ metadata: { second: true } }),
]);
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
status: GQLCOMMENT_STATUS.NONE,
metadata: { first: true, second: true },
actions: [],
});
});
});
@@ -0,0 +1,91 @@
import { Omit, Promiseable } from "talk-common/types";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { CreateActionInput } from "talk-server/models/action/comment";
import { EditCommentInput } from "talk-server/models/comment";
import { Story } from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
import { moderationPhases } from "./phases";
export type ModerationAction = Omit<
CreateActionInput,
"commentID" | "commentRevisionID" | "storyID"
>;
export interface PhaseResult {
actions: ModerationAction[];
status: GQLCOMMENT_STATUS;
metadata: Record<string, any>;
}
export interface ModerationPhaseContext {
story: Story;
tenant: Tenant;
comment: Partial<EditCommentInput>;
author: User;
req?: Request;
}
export type ModerationPhase = (
context: ModerationPhaseContext
) => Promiseable<PhaseResult>;
export type IntermediatePhaseResult = Partial<PhaseResult> | void;
export type IntermediateModerationPhase = (
context: ModerationPhaseContext
) => Promiseable<IntermediatePhaseResult>;
/**
* compose will create a moderation pipeline for which is executable with the
* passed actions.
*/
export const compose = (
phases: IntermediateModerationPhase[]
): ModerationPhase => async context => {
const final: PhaseResult = {
status: GQLCOMMENT_STATUS.NONE,
actions: [],
metadata: {},
};
// Loop over all the moderation phases and see if we've resolved the status.
for (const phase of phases) {
const result = await phase(context);
if (result) {
// If this result contained actions, then we should push it into the
// other actions.
const { actions } = result;
if (actions) {
final.actions.push(...actions);
}
// If this result contained metadata, then we should merge it into the
// other metadata.
const { metadata } = result;
if (metadata) {
final.metadata = {
...final.metadata,
...metadata,
};
}
// If this result contained a status, then we've finished resolving
// phases!
const { status } = result;
if (status) {
final.status = status;
break;
}
}
}
return final;
};
/**
* process the comment and return moderation details.
*/
export const processForModeration: ModerationPhase = compose(moderationPhases);
@@ -10,7 +10,7 @@ import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
} from "talk-server/services/comments/pipeline";
const testCharCount = (
settings: Partial<ModerationSettings>,
@@ -2,7 +2,7 @@ import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
} from "talk-server/services/comments/pipeline";
const testDisabledCommenting = (settings: Partial<ModerationSettings>) =>
settings.disableCommenting;
@@ -1,4 +1,4 @@
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
import { IntermediateModerationPhase } from "talk-server/services/comments/pipeline";
import { commentingDisabled } from "./commentingDisabled";
import { commentLength } from "./commentLength";
@@ -6,7 +6,7 @@ import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
} from "talk-server/services/comments/pipeline";
import {
getCommentTrustScore,
isReliableCommenter,
@@ -10,7 +10,7 @@ import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
} from "talk-server/services/comments/pipeline";
/**
* The preloaded linkify instance with common tlds.
@@ -6,7 +6,7 @@ import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
} from "talk-server/services/comments/pipeline";
const testModerationMode = (settings: Partial<ModerationSettings>) =>
settings.moderation === GQLMODERATION_MODE.PRE;
@@ -9,7 +9,7 @@ import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
} from "talk-server/services/comments/pipeline";
export const spam: IntermediateModerationPhase = async ({
story,
@@ -5,7 +5,7 @@ import {
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
} from "talk-server/services/comments/pipeline";
// If a given user is a staff member, always approve their comment.
export const staff: IntermediateModerationPhase = ({
@@ -4,7 +4,7 @@ import { Comment } from "talk-server/models/comment";
import { Story } from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { storyClosed } from "talk-server/services/comments/moderation/phases/storyClosed";
import { storyClosed } from "talk-server/services/comments/pipeline/phases/storyClosed";
describe("storyClosed", () => {
it("throws an error when the story is closed", () => {
@@ -1,7 +1,7 @@
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
} from "talk-server/services/comments/pipeline";
// This phase checks to see if the story being processed is closed or not.
export const storyClosed: IntermediateModerationPhase = ({
@@ -13,7 +13,7 @@ import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
} from "talk-server/services/comments/pipeline";
export const toxic: IntermediateModerationPhase = async ({
tenant,
@@ -6,8 +6,8 @@ import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "talk-server/services/comments/moderation";
import { containsMatchingPhraseMemoized } from "talk-server/services/comments/moderation/wordList";
} from "talk-server/services/comments/pipeline";
import { containsMatchingPhraseMemoized } from "talk-server/services/comments/pipeline/wordList";
// This phase checks the comment against the wordList.
export const wordList: IntermediateModerationPhase = ({
@@ -1,4 +1,4 @@
import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordList";
import { containsMatchingPhrase } from "talk-server/services/comments/pipeline/wordList";
const phrases = [
"cookies",
@@ -22,8 +22,7 @@ export function generateRegExp(phrases: string[]) {
.join('[\\s"?!.]+')
)
.join("|");
return new RegExp(`(^|[^\\w])(${inner})(?=[^\\w]|$)`, "iu");
return new RegExp(`(^|[^\\w])(${inner})(?=[^\\w]|$)`, "gmiu");
}
export const generateRegExpMemoized = memoize(generateRegExp);
@@ -1,81 +0,0 @@
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import {
createCommentModerationAction,
CreateCommentModerationActionInput,
} from "talk-server/models/action/moderation/comment";
import { updateCommentStatus } from "talk-server/models/comment";
import { updateCommentStatusCount } from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
export type Moderate = Omit<CreateCommentModerationActionInput, "status">;
const moderate = (status: GQLCOMMENT_STATUS) => async (
mongo: Db,
tenant: Tenant,
input: Moderate
) => {
// TODO: wrap these operations in a transaction?
// Create the logger.
const log = logger.child({
...input,
tenantID: tenant.id,
newStatus: status,
});
// Update the Comment's status.
const result = await updateCommentStatus(
mongo,
tenant.id,
input.commentID,
input.commentRevisionID,
status
);
if (!result) {
// TODO: wrap in better error?
throw new Error("specified comment not found");
}
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"
);
// Update the story comment counts.
const story = await updateCommentStatusCount(
mongo,
tenant.id,
result.comment.storyID,
{
[result.oldStatus]: -1,
[status]: 1,
}
);
if (!story) {
// TODO: wrap in better error?
throw new Error("specified story not found");
}
log.trace({ oldStatus: result.oldStatus }, "adjusted story comment counts");
return result.comment;
};
export const accept = moderate(GQLCOMMENT_STATUS.ACCEPTED);
export const reject = moderate(GQLCOMMENT_STATUS.REJECTED);
+33 -3
View File
@@ -1,11 +1,41 @@
import RedisClient, { Redis } from "ioredis";
import RedisClient, { Pipeline, Redis } from "ioredis";
import { Config } from "talk-common/config";
import { Omit } from "talk-common/types";
export interface AugmentedRedisCommands {
mhincrby(key: string, ...args: any[]): Promise<void>;
}
export type AugmentedPipeline = Pipeline & AugmentedRedisCommands;
export type AugmentedRedis = Omit<Redis, "pipeline"> &
AugmentedRedisCommands & {
pipeline(commands?: string[][]): AugmentedPipeline;
};
function configureRedisClient(redis: Redis) {
// mhincrby will increment many hash values.
redis.defineCommand("mhincrby", {
numberOfKeys: 1,
lua: `
for i = 1, #ARGV, 2 do
redis.call('HINCRBY', KEYS[1], ARGV[i], ARGV[i + 1])
end
`,
});
}
/**
* create will connect to the Redis instance identified in the configuration.
*
* @param config application configuration.
*/
export function createRedisClient(config: Config): Redis {
return new RedisClient(config.get("redis"), {});
export function createRedisClient(config: Config): AugmentedRedis {
const redis = new RedisClient(config.get("redis"), {});
// Configure the redis client for use with the custom commands.
configureRedisClient(redis);
return redis as AugmentedRedis;
}
+11 -8
View File
@@ -30,9 +30,9 @@ import {
retrieveManyStories,
retrieveStory,
Story,
updateCommentStatusCount,
updateStory,
updateStoryActionCounts,
updateStoryCommentStatusCount,
UpdateStoryInput,
} from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
@@ -40,6 +40,8 @@ import Task from "talk-server/queue/Task";
import { ScraperData } from "talk-server/queue/tasks/scraper";
import { scrape } from "talk-server/services/stories/scraper";
import { AugmentedRedis } from "../redis";
export type FindOrCreateStory = FindOrCreateStoryInput;
export async function findOrCreate(
@@ -155,7 +157,7 @@ export async function remove(
);
log.debug({ removedComments }, "removed comments while deleting story");
} else if (calculateTotalCommentCount(story.commentCounts) > 0) {
} else if (calculateTotalCommentCount(story.commentCounts.status) > 0) {
log.warn(
"attempted to remove story that has linked comments without consent for deleting comments"
);
@@ -227,6 +229,7 @@ export async function update(
export async function merge(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
destinationID: string,
sourceIDs: string[]
@@ -288,27 +291,27 @@ export async function merge(
// Merge the comment and action counts for all the source stories.
const [, ...sourceStories] = stories;
let destinationStory = await updateCommentStatusCount(
let destinationStory = await updateStoryCommentStatusCount(
mongo,
redis,
tenant.id,
destinationID,
mergeCommentStatusCount(
// We perform the type assertion here because above, we already verified
// that none of the stories are null.
(sourceStories as Story[]).map(({ commentCounts }) => commentCounts)
(sourceStories as Story[]).map(({ commentCounts: { status } }) => status)
)
);
const mergedActionCounts = mergeCommentActionCounts(
// We perform the type assertion here because above, we already verified
// that none of the stories are null.
(sourceStories as Story[]).map(
({ commentActionCounts }) => commentActionCounts
)
...(sourceStories as Story[]).map(({ commentCounts: { action } }) => action)
);
if (countTotalActionCounts(mergedActionCounts) > 0) {
destinationStory = await updateStoryActionCounts(
mongo,
redis,
tenant.id,
destinationID,
mergedActionCounts
@@ -321,7 +324,7 @@ export async function merge(
}
log.debug(
{ commentCounts: destinationStory.commentCounts },
{ commentCounts: destinationStory.commentCounts.status },
"updated destination story with new comment counts"
);