[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
@@ -2,8 +2,10 @@ import { GQLCOMMENT_FLAG_REASON } from "talk-server/graph/tenant/schema/__genera
import {
ACTION_TYPE,
CommentAction,
CreateActionInput,
decodeActionCounts,
encodeActionCounts,
filterDuplicateActions,
validateAction,
} from "talk-server/models/action/comment";
@@ -119,3 +121,36 @@ describe("#validateAction", () => {
}
});
});
describe("#filterDuplicateActions", () => {
it("removes duplicate action items", () => {
const actions: CreateActionInput[] = [
{
storyID: "1",
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
commentID: "1",
commentRevisionID: "1",
userID: null,
},
{
storyID: "1",
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
commentID: "1",
commentRevisionID: "1",
userID: null,
},
{
storyID: "1",
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
commentID: "1",
commentRevisionID: "1",
userID: null,
},
];
expect(filterDuplicateActions(actions)).toHaveLength(2);
});
});
+15 -9
View File
@@ -1,5 +1,5 @@
import Joi from "joi";
import { camelCase, pick } from "lodash";
import { camelCase, isEqual, omit, pick, uniqWith } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid";
@@ -76,11 +76,9 @@ export interface CommentAction extends TenantResource {
reason?: FLAG_REASON;
/**
* storyID represents the identifier for the item's associated item. In
* the case of a REACTION left on a Comment, this ID would be the Stories ID.
* In the case of a FLAG left on a User, this ID would be null.
* storyID represents the ID of the Story where the comment was left on.
*/
storyID?: string;
storyID: string;
/**
* userID is the ID of the User that left this Action. In the event that the
@@ -159,6 +157,12 @@ export interface CreateActionResultObject {
wasUpserted: boolean;
}
export function filterDuplicateActions<T extends {}>(actions: T[]): T[] {
return uniqWith(actions, (first, second) =>
isEqual(omit(first, "metadata"), omit(second, "metadata"))
);
}
export async function createAction(
mongo: Db,
tenantID: string,
@@ -351,7 +355,7 @@ export const ACTION_COUNT_JOIN_CHAR = "__";
* @param actions list of actions to generate the action counts from
*/
export function encodeActionCounts(
...actions: CommentAction[]
...actions: Array<Pick<CommentAction, "actionType" | "reason">>
): EncodedCommentActionCounts {
const actionCounts: EncodedCommentActionCounts = {};
@@ -394,7 +398,9 @@ export function invertEncodedActionCounts(
* encodeActionCountKeys encodes the action into string keys which represents
* the groupings as seen in `EncodedActionCounts`.
*/
function encodeActionCountKeys(action: CommentAction): string[] {
function encodeActionCountKeys(
action: Pick<CommentAction, "actionType" | "reason">
): string[] {
const keys = [action.actionType as string];
if (action.reason) {
keys.push(
@@ -495,7 +501,7 @@ function createEmptyActionCounts(): GQLActionCounts {
}
export function mergeCommentActionCounts(
actionCounts: EncodedCommentActionCounts[]
...actionCounts: EncodedCommentActionCounts[]
): EncodedCommentActionCounts {
const mergedActionCounts: EncodedCommentActionCounts = {};
@@ -504,7 +510,7 @@ export function mergeCommentActionCounts(
if (key in mergedActionCounts) {
mergedActionCounts[key] += count;
} else {
mergedActionCounts[key] += 1;
mergedActionCounts[key] = count;
}
}
}
@@ -5,7 +5,7 @@ import { Omit, Sub } from "talk-common/types";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import {
Connection,
Cursor,
ConnectionInput,
getPageInfo,
nodesToEdges,
} from "talk-server/models/connection";
@@ -107,16 +107,14 @@ export async function retrieveCommentModerationActions(
return result.toArray();
}
export interface ConnectionInput {
first: number;
after?: Cursor;
filter?: CommentModerationActionFilter;
}
export type CommentModerationActionConnectionInput = ConnectionInput<
CommentModerationAction
>;
export async function retrieveCommentModerationActionConnection(
mongo: Db,
tenantID: string,
input: ConnectionInput
input: CommentModerationActionConnectionInput
): Promise<Readonly<Connection<Readonly<CommentModerationAction>>>> {
// Create the query.
const query = new Query(collection(mongo)).where({ tenantID });
@@ -130,7 +128,7 @@ export async function retrieveCommentModerationActionConnection(
}
async function retrieveConnection(
input: ConnectionInput,
input: CommentModerationActionConnectionInput,
query: Query<CommentModerationAction>
): Promise<Readonly<Connection<Readonly<CommentModerationAction>>>> {
// Apply the cursor to the query. Currently only supporting sorting by the
+205 -112
View File
@@ -1,3 +1,4 @@
import { isEmpty, merge } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid";
@@ -7,20 +8,23 @@ import {
GQLCOMMENT_SORT,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { EncodedCommentActionCounts } from "talk-server/models/action/comment";
import {
EncodedCommentActionCounts,
mergeCommentActionCounts,
} from "talk-server/models/action/comment";
import {
Connection,
createConnection,
Cursor,
getPageInfo,
nodesToEdges,
NodeToCursorTransformer,
OrderedConnectionInput,
} from "talk-server/models/connection";
import Query from "talk-server/models/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
return db.collection<Readonly<Comment>>("comments");
function collection(mongo: Db) {
return mongo.collection<Readonly<Comment>>("comments");
}
/**
@@ -143,23 +147,24 @@ export type CreateCommentInput = Omit<
| "actionCounts"
| "revisions"
> &
Required<Pick<Revision, "body">>;
Required<Pick<Revision, "body">> &
Partial<Pick<Comment, "actionCounts">>;
export async function createComment(
db: Db,
mongo: Db,
tenantID: string,
input: CreateCommentInput
) {
const createdAt = new Date();
// Pull out some useful properties from the input.
const { body, ...rest } = input;
const { body, actionCounts = {}, ...rest } = input;
// Generate the revision.
const revision: Revision = {
id: uuid.v4(),
body,
actionCounts: {},
actionCounts,
createdAt,
};
@@ -168,21 +173,24 @@ export async function createComment(
const defaults: Sub<Comment, CreateCommentInput> = {
id: uuid.v4(),
tenantID,
createdAt,
replyIDs: [],
replyCount: 0,
actionCounts: {},
revisions: [revision],
createdAt,
};
// Merge the defaults and the input together.
const comment: Readonly<Comment> = {
// Defaults for things that always stay the same, or are computed.
...defaults,
// Rest for things that are passed in and are not actionCounts.
...rest,
// ActionCounts because they may be passed in!
actionCounts,
};
// Insert it into the database.
await collection(db).insertOne(comment);
await collection(mongo).insertOne(comment);
return comment;
}
@@ -222,22 +230,70 @@ export type EditCommentInput = Pick<
* `editCommentWindowLength` property.
*/
lastEditableCommentCreatedAt: Date;
} & Required<Pick<Revision, "body">>;
} & Required<Pick<Revision, "body">> &
Partial<Pick<Comment, "actionCounts">>;
// Only comments with the following status's can be edited.
const EDITABLE_STATUSES = [
GQLCOMMENT_STATUS.NONE,
GQLCOMMENT_STATUS.PREMOD,
GQLCOMMENT_STATUS.ACCEPTED,
];
export function validateEditable(
comment: Comment,
{
authorID,
lastEditableCommentCreatedAt,
}: Pick<EditCommentInput, "authorID" | "lastEditableCommentCreatedAt">
) {
if (comment.authorID !== authorID) {
// TODO: (wyattjoh) return better error
throw new Error("comment author mismatch");
}
// Check to see if the comment had a status that was editable.
if (!EDITABLE_STATUSES.includes(comment.status)) {
// TODO: (wyattjoh) return better error
throw new Error("comment status is not editable");
}
// Check to see if the edit window expired.
if (comment.createdAt <= lastEditableCommentCreatedAt) {
// TODO: (wyattjoh) return better error
throw new Error("edit window expired");
}
}
export interface EditComment {
/**
* oldComment is the Comment that was previously set.
*/
oldComment: Comment;
/**
* editedComment is the Comment after the edit was performed.
*/
editedComment: Comment;
/**
* newRevision returns the new revision that was created in the Comment.
*/
newRevision: Revision;
}
/**
* editComment will edit a comment if it's within the time allotment.
*
* @param mongo MongoDB database handle
* @param tenantID ID for the Tenant where the Comment exists
* @param input input for editing the comment
*/
export async function editComment(
db: Db,
mongo: Db,
tenantID: string,
input: EditCommentInput
) {
// TODO: (wyattjoh) now that we have revisions, do we really have this restriction?
// Only comments with the following status's can be edited.
const EDITABLE_STATUSES = [
GQLCOMMENT_STATUS.NONE,
GQLCOMMENT_STATUS.PREMOD,
GQLCOMMENT_STATUS.ACCEPTED,
];
): Promise<EditComment> {
const createdAt = new Date();
const {
@@ -247,17 +303,35 @@ export async function editComment(
status,
authorID,
metadata,
actionCounts = {},
} = input;
// Generate the revision.
const revision: Revision = {
id: uuid.v4(),
body,
actionCounts: {},
actionCounts,
createdAt,
};
const result = await collection(db).findOneAndUpdate(
const update: Record<string, any> = {
$set: {
status,
// Embed all the metadata properties, this may override the existing
// metadata, but we won't replace metadata that has been recalculated.
// TODO: (wyattjoh) consider if we want to replace the metadata for edited comments instead of supplementing it
...dotize({ metadata }),
},
$push: {
revisions: revision,
},
};
if (!isEmpty(actionCounts)) {
// Action counts are being provided! Increment the base action counts too!
update.$inc = dotize({ actionCounts });
}
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
@@ -270,64 +344,59 @@ export async function editComment(
$gt: lastEditableCommentCreatedAt,
},
},
update,
{
$set: {
status,
// Embed all the metadata properties, this may override the existing
// metadata, but we won't replace metadata that has been recalculated.
// TODO: (wyattjoh) consider if we want to replace the metadata for edited comments instead of supplementing it
...dotize({ metadata }),
},
$push: {
revisions: revision,
},
},
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
// True to return the original document instead of the updated document.
returnOriginal: true,
}
);
if (!result.value) {
// Try to get the comment.
const comment = await retrieveComment(db, tenantID, id);
const comment = await retrieveComment(mongo, tenantID, id);
if (!comment) {
// TODO: (wyattjoh) return better error
throw new Error("comment not found");
}
if (comment.authorID !== authorID) {
// TODO: (wyattjoh) return better error
throw new Error("comment author mismatch");
}
// Check to see if the comment had a status that was editable.
if (!EDITABLE_STATUSES.includes(comment.status)) {
// TODO: (wyattjoh) return better error
throw new Error("comment status is not editable");
}
// Check to see if the edit window expired.
if (comment.createdAt <= lastEditableCommentCreatedAt) {
// TODO: (wyattjoh) return better error
throw new Error("edit window expired");
}
// Validate and potentially return with a more useful error.
validateEditable(comment, input);
// TODO: (wyattjoh) return better error
throw new Error("comment edit failed for an unexpected reason");
}
return result.value;
// Create a new "editedComment" 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.
status,
metadata,
// Merge the actionCounts from the old Comment with the new actionCounts.
actionCounts: mergeCommentActionCounts(
result.value.actionCounts,
actionCounts
),
// Add in the $push operations.
revisions: [...result.value.revisions, revision],
});
return {
oldComment: result.value,
editedComment,
newRevision: revision,
};
}
export async function retrieveComment(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ id, tenantID });
export async function retrieveComment(mongo: Db, tenantID: string, id: string) {
return collection(mongo).findOne({ id, tenantID });
}
export async function retrieveManyComments(
db: Db,
mongo: Db,
tenantID: string,
ids: string[]
) {
const cursor = await collection(db).find({
const cursor = await collection(mongo).find({
id: {
$in: ids,
},
@@ -339,14 +408,13 @@ export async function retrieveManyComments(
return ids.map(id => comments.find(comment => comment.id === id) || null);
}
export interface ConnectionInput {
first: number;
orderBy: GQLCOMMENT_SORT;
after?: Cursor;
}
export type CommentConnectionInput = OrderedConnectionInput<
Comment,
GQLCOMMENT_SORT
>;
function cursorGetterFactory(
input: Pick<ConnectionInput, "orderBy" | "after">
input: Pick<CommentConnectionInput, "orderBy" | "after">
): NodeToCursorTransformer<Comment> {
switch (input.orderBy) {
case GQLCOMMENT_SORT.CREATED_AT_DESC:
@@ -363,28 +431,25 @@ function cursorGetterFactory(
* retrieveRepliesConnection returns a Connection<Comment> for a given comments
* replies.
*
* @param db database connection
* @param mongo database connection
* @param parentID the parent id for the comment to retrieve
* @param input connection configuration
*/
export async function retrieveCommentRepliesConnection(
db: Db,
export const retrieveCommentRepliesConnection = (
mongo: Db,
tenantID: string,
storyID: string,
parentID: string,
input: ConnectionInput
) {
// Create the query.
const query = new Query(collection(db)).where({
tenantID,
storyID,
parentID,
input: CommentConnectionInput
) =>
retrieveCommentConnection(mongo, tenantID, {
...input,
filter: {
storyID,
parentID,
},
});
// Return a connection for the comments query.
return retrieveConnection(input, query);
}
/**
* retrieveCommentParentsConnection will return a comment connection used to
* represent the parents of a given comment.
@@ -485,54 +550,64 @@ export async function retrieveCommentParentsConnection(
* retrieveStoryConnection returns a Connection<Comment> for a given Stories
* comments.
*
* @param db database connection
* @param mongo database connection
* @param storyID the Story id for the comment to retrieve
* @param input connection configuration
*/
export async function retrieveCommentStoryConnection(
db: Db,
export const retrieveCommentStoryConnection = (
mongo: Db,
tenantID: string,
storyID: string,
input: ConnectionInput
) {
// Create the query.
const query = new Query(collection(db)).where({
tenantID,
storyID,
// Only get Comments that are top level. If the client wants to load another
// layer, they can request another nested connection.
parentID: null,
// Only get Comment's that are visible.
status: {
$in: [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.ACCEPTED],
input: CommentConnectionInput
) =>
retrieveCommentConnection(mongo, tenantID, {
...input,
filter: {
storyID,
// Only get Comments that are top level. If the client wants to load another
// layer, they can request another nested connection.
parentID: null,
// Only get Comment's that are visible.
status: {
$in: [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.ACCEPTED],
},
},
});
// Return a connection for the comments query.
return retrieveConnection(input, query);
}
/**
* retrieveCommentUserConnection returns a Connection<Comment> for a given User's
* comments.
*
* @param db database connection
* @param mongo database connection
* @param userID the User id for the comment to retrieve
* @param input connection configuration
*/
export async function retrieveCommentUserConnection(
db: Db,
export const retrieveCommentUserConnection = (
mongo: Db,
tenantID: string,
userID: string,
input: ConnectionInput
) {
// Create the query.
const query = new Query(collection(db)).where({
tenantID,
authorID: userID,
input: CommentConnectionInput
) =>
retrieveCommentConnection(mongo, tenantID, {
...input,
filter: {
authorID: userID,
},
});
// Return a connection for the comments query.
export async function retrieveCommentConnection(
mongo: Db,
tenantID: string,
input: CommentConnectionInput
): Promise<Readonly<Connection<Readonly<Comment>>>> {
// Create the query.
const query = new Query(collection(mongo)).where({ tenantID });
// If a filter is being applied, filter it as well.
if (input.filter) {
query.where(input.filter);
}
return retrieveConnection(input, query);
}
@@ -545,7 +620,7 @@ export async function retrieveCommentUserConnection(
* configuration applied
*/
async function retrieveConnection(
input: ConnectionInput,
input: CommentConnectionInput,
query: Query<Comment>
): Promise<Readonly<Connection<Readonly<Comment>>>> {
// Apply some sorting options.
@@ -562,6 +637,14 @@ async function retrieveConnection(
// Get the comments from the cursor.
const nodes = await cursor.toArray();
// Return a connection.
return convertNodesToConnection(input, nodes);
}
export function convertNodesToConnection(
input: CommentConnectionInput,
nodes: Array<Readonly<Comment>>
) {
// Convert the nodes to edges (which will include the extra edge we don't need
// if there is more results).
const edges = nodesToEdges(nodes, cursorGetterFactory(input));
@@ -583,7 +666,10 @@ async function retrieveConnection(
};
}
function applyInputToQuery(input: ConnectionInput, query: Query<Comment>) {
function applyInputToQuery(
input: CommentConnectionInput,
query: Query<Comment>
) {
switch (input.orderBy) {
case GQLCOMMENT_SORT.CREATED_AT_DESC:
query.orderBy({ createdAt: -1 });
@@ -613,7 +699,14 @@ function applyInputToQuery(input: ConnectionInput, query: Query<Comment>) {
}
export interface UpdateCommentStatus {
/**
* comment is the updated Comment with the new status associated with it.
*/
comment: Readonly<Comment>;
/**
* oldStatus is the previous status that the given Comment had.
*/
oldStatus: GQLCOMMENT_STATUS;
}
+26
View File
@@ -1,4 +1,5 @@
import { merge } from "lodash";
import { FilterQuery } from "./query";
export type Cursor = Date | number | string | null;
@@ -19,6 +20,31 @@ export interface Connection<T> {
pageInfo: PageInfo;
}
export interface ConnectionInput<T> {
/**
* first is the number of items to load for the connection. The returned
* amount of items may be less.
*/
first: number;
/**
* after is an optional cursor that can be used to paginate the result set.
*/
after?: Cursor;
/**
* filter is an optional query that can be used to constrain the result set.
*/
filter?: FilterQuery<T>;
}
export interface OrderedConnectionInput<T, U> extends ConnectionInput<T> {
/**
* orderBy allows ordering of the returned connection.
*/
orderBy: U;
}
/**
* createConnection will create a base Connection that can be used to satisfy
* the Connection<T> interface.
@@ -0,0 +1,24 @@
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { CommentModerationQueueCounts, CommentStatusCounts } from ".";
export function createEmptyCommentModerationQueueCounts(): CommentModerationQueueCounts {
return {
total: 0,
queues: {
unmoderated: 0,
reported: 0,
pending: 0,
},
};
}
export function createEmptyCommentStatusCounts(): CommentStatusCounts {
return {
[GQLCOMMENT_STATUS.ACCEPTED]: 0,
[GQLCOMMENT_STATUS.NONE]: 0,
[GQLCOMMENT_STATUS.PREMOD]: 0,
[GQLCOMMENT_STATUS.REJECTED]: 0,
[GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: 0,
};
}
@@ -0,0 +1,246 @@
export * from "./empty";
export * from "./shared";
import { Db } from "mongodb";
import { identity, isEmpty, pickBy } from "lodash";
import { DeepPartial } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { EncodedCommentActionCounts } from "talk-server/models/action/comment";
import { AugmentedRedis } from "talk-server/services/redis";
import { retrieveStory, Story } from "..";
import { createEmptyCommentStatusCounts } from "./empty";
import { updateSharedCommentCounts } from "./shared";
/**
* collection provides a reference to the stories collection used by the
* counting system.
*/
function collection<T = Story>(db: Db) {
return db.collection<Readonly<T>>("stories");
}
// TODO: (wyattjoh) write a test to verify that this set of counts is always in sync with GQLCOMMENT_STATUS.
/**
* CommentStatusCounts stores the count of Comments that have the particular
* statuses.
*/
export interface CommentStatusCounts {
[GQLCOMMENT_STATUS.ACCEPTED]: number;
[GQLCOMMENT_STATUS.NONE]: number;
[GQLCOMMENT_STATUS.PREMOD]: number;
[GQLCOMMENT_STATUS.REJECTED]: number;
[GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: number;
}
/**
* CommentModerationCountsPerQueue stores the number of Comments that exist in
* each of the Moderation Queues.
*/
export interface CommentModerationCountsPerQueue {
/**
* unmoderated is the number of Comment's that have not been moderated. This
* includes all Comment's that are NONE, PREMOD, SYSTEM_WITHHELD.
*/
unmoderated: number;
/**
* pending is the number of Comment's that are not published and are pending
* moderation.
*/
pending: number;
/**
* reported is the number of Comment's that have not been moderated but have
* been flagged.
*/
reported: number;
}
/**
* CommentModerationQueueCounts stores the number of Comments that exist in each
* of the ModerationQueue's on this Story.
*/
export interface CommentModerationQueueCounts {
/**
* total is the number of Comment's that exist in the below moderation queues.
*/
total: number;
/**
* queues contains all the queue specific counts.
*/
queues: CommentModerationCountsPerQueue;
}
/**
* StoryCommentCounts stores all the Comment Counts that will be stored on each
* Story.
*/
export interface StoryCommentCounts {
/**
* actionCounts stores all the action counts for all Comment's on this Story.
*/
action: EncodedCommentActionCounts;
/**
* commentCounts stores the different counts for each comment on the Story
* according to their statuses.
*/
status: CommentStatusCounts;
/**
* moderationQueue stores the number of Comments that exist in
* each of the ModerationQueue's on this Story.
*/
moderationQueue: CommentModerationQueueCounts;
}
/**
* updateStoryCommentStatusCount will update a given Story's status counts.
*
* @param mongo database handle
* @param redis redis database handle
* @param tenantID ID of the Tenant where this Story is
* @param id ID of the Story where we're updating the counts
* @param status the set of counts that we will update
*/
export const updateStoryCommentStatusCount = (
mongo: Db,
redis: AugmentedRedis,
tenantID: string,
id: string,
status: Partial<CommentStatusCounts>
) => updateStoryCounts(mongo, redis, tenantID, id, { status });
/**
* updateStoryCommentModerationQueueCounts will update the moderation queue
* counts on a Story.
*
* @param mongo mongo database handle
* @param redis redis database handle
* @param tenantID ID of the Tenant where this Story is
* @param id ID of the Story where we're updating the counts
* @param moderationQueue the set of counts that we will update
*/
export const updateStoryCommentModerationQueueCounts = (
mongo: Db,
redis: AugmentedRedis,
tenantID: string,
id: string,
moderationQueue: DeepPartial<CommentModerationQueueCounts>
) => updateStoryCounts(mongo, redis, tenantID, id, { moderationQueue });
export const updateStoryActionCounts = (
mongo: Db,
redis: AugmentedRedis,
tenantID: string,
id: string,
action: EncodedCommentActionCounts
) => updateStoryCounts(mongo, redis, tenantID, id, { action });
export type StoryCounts = DeepPartial<StoryCommentCounts>;
/**
* updateStoryCounts will update the comment counts for the story indicated.
*
* @param mongo mongodb database handle
* @param redis redis database handle
* @param tenantID ID of the Tenant where the Story is on
* @param id the ID of the Story that we are updating counts on
* @param commentCounts the counts that we are updating
*/
export async function updateStoryCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string,
id: string,
commentCounts: StoryCounts
) {
// Update all the specific comment moderation queue counts.
const update: DeepPartial<Story> = { commentCounts };
const $inc = pickBy(dotize(update), identity);
if (isEmpty($inc)) {
return retrieveStory(mongo, tenantID, id);
}
logger.trace({ update: { $inc } }, "incrementing story counts");
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
{ $inc },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
// Update the shared counts.
await updateSharedCommentCounts(redis, tenantID, commentCounts);
return result.value || null;
}
/**
* mergeCommentStatusCount will merge an array of commentStatusCount's into one.
*/
export function mergeCommentStatusCount(
statusCounts: CommentStatusCounts[]
): CommentStatusCounts {
const mergedStatusCounts = createEmptyCommentStatusCounts();
for (const commentCounts of statusCounts) {
for (const status in commentCounts) {
if (!commentCounts.hasOwnProperty(status)) {
continue;
}
// Because the CommentStatusCounts are not indexable, it should be accessed
// by walking the structure.
switch (status) {
case GQLCOMMENT_STATUS.ACCEPTED:
case GQLCOMMENT_STATUS.NONE:
case GQLCOMMENT_STATUS.PREMOD:
case GQLCOMMENT_STATUS.REJECTED:
case GQLCOMMENT_STATUS.SYSTEM_WITHHELD:
mergedStatusCounts[status] += commentCounts[status];
break;
default:
throw new Error("unrecognized status");
}
}
}
return mergedStatusCounts;
}
/**
* calculateTotalCommentCount will compute the total amount of comments left on
* an Asset by parsing the `CommentStatusCounts`.
*/
export function calculateTotalCommentCount(
commentCounts: CommentStatusCounts
): number {
let count = 0;
for (const status in commentCounts) {
if (!commentCounts.hasOwnProperty(status)) {
continue;
}
// Because the CommentStatusCounts are not indexable, it should be accessed
// by walking the structure.
switch (status) {
case GQLCOMMENT_STATUS.ACCEPTED:
case GQLCOMMENT_STATUS.NONE:
case GQLCOMMENT_STATUS.PREMOD:
case GQLCOMMENT_STATUS.REJECTED:
case GQLCOMMENT_STATUS.SYSTEM_WITHHELD:
count += commentCounts[status];
break;
default:
throw new Error("unrecognized status");
}
}
return count;
}
@@ -0,0 +1,554 @@
import { flattenDeep, identity, isEmpty, pickBy } from "lodash";
import { Db } from "mongodb";
import ms from "ms";
import logger from "talk-server/logger";
import { EncodedCommentActionCounts } from "talk-server/models/action/comment";
import { AugmentedPipeline, AugmentedRedis } from "talk-server/services/redis";
import {
CommentModerationCountsPerQueue,
CommentStatusCounts,
StoryCounts,
} from ".";
import { Story } from "..";
import {
createEmptyCommentModerationQueueCounts,
createEmptyCommentStatusCounts,
} from "./empty";
/**
* COUNT_FRESHNESS_EXPIRY will expire the :fresh key for cached counts that will
* trigger a recalculation of the cached count values after the time that it
* last computed it plus the below time in seconds elapsed.
*/
const COUNT_FRESHNESS_EXPIRY_SECONDS = Math.floor(ms("24h") / 1000);
/**
* shared keys
*/
const freshenKey = (key: string) => `${key}:fresh`;
const commentCountsActionKey = (tenantID: string) =>
`${tenantID}:commentCounts:action`;
const commentCountsStatusKey = (tenantID: string) =>
`${tenantID}:commentCounts:status`;
const commentCountsModerationQueueTotalKey = (tenantID: string) =>
`${tenantID}:commentCounts:moderationQueue:total`;
const commentCountsModerationQueueQueuesKey = (tenantID: string) =>
`${tenantID}:commentCounts:moderationQueue:queues`;
/**
* collection provides a reference to the stories collection used by the
* counting system.
*/
function collection<T = Story>(db: Db) {
return db.collection<Readonly<T>>("stories");
}
/**
* recalculateSharedModerationQueueQueueCounts will reset the counts stored for
* this Tenant.
*
* @param mongo mongodb database handle
* @param redis redis database handle
* @param tenantID the tenant ID that we are resetting the counts for
*/
export async function recalculateSharedModerationQueueQueueCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
const now = new Date();
const key = commentCountsModerationQueueQueuesKey(tenantID);
const freshKey = freshenKey(key);
// Clear the existing cached queues.
await redis.del(key, freshKey);
// Fetch all the moderation queue counts.
const queueResults = await collection<{
_id: string;
total: number;
}>(mongo).aggregate([
{
$match: { tenantID, createdAt: { $lt: now } },
},
{
$project: {
moderationQueue: {
$objectToArray: "$commentCounts.moderationQueue.queues",
},
},
},
{
$unwind: "$moderationQueue",
},
{
$group: {
_id: "$moderationQueue.k",
total: {
$sum: "$moderationQueue.v",
},
},
},
]);
// Convert the cursor from the results into an array.
const queues = await queueResults.toArray();
// Increment the hash key values.
const pipeline = redis.pipeline();
// Mark the key as fresh, and update the values!
pipeline.mhincrby(
key,
...queues.reduce((acc, queue) => [...acc, queue._id, queue.total], [])
);
pipeline.set(freshKey, now.getTime(), "EX", COUNT_FRESHNESS_EXPIRY_SECONDS);
await pipeline.exec();
const queueCounts: CommentModerationCountsPerQueue = queues.reduce(
(acc, queue) => ({
...acc,
[queue._id]: queue.total,
}),
createEmptyCommentModerationQueueCounts().queues
);
return queueCounts;
}
/**
* recalculateSharedModerationQueueTotalCounts will reset the counts stored for
* this Tenant.
*
* @param mongo mongodb database handle
* @param redis redis database handle
* @param tenantID the tenant ID that we are resetting the counts for
*/
export async function recalculateSharedModerationQueueTotalCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
const now = new Date();
const key = commentCountsModerationQueueTotalKey(tenantID);
const freshKey = freshenKey(key);
// Clear the existing cached queues.
await redis.del(key, freshKey);
// Fetch all the totals for the moderation queues.
const totalResults = await collection<{
total: number;
}>(mongo).aggregate([
{
$match: { tenantID, createdAt: { $lt: now } },
},
{
$group: {
_id: "total",
total: {
$sum: "$commentCounts.moderationQueue.total",
},
},
},
]);
const totals = await totalResults.toArray();
if (totals.length !== 1) {
throw new Error("total results returned incorrect");
}
const [{ total }] = totals;
// Mark the key as fresh, and update the values!
const pipeline = redis.pipeline();
pipeline.incrby(key, total);
pipeline.set(freshKey, now.getTime(), "EX", COUNT_FRESHNESS_EXPIRY_SECONDS);
await pipeline.exec();
return total;
}
/**
* recalculateSharedStatusCommentCounts will reset the counts stored for this
* Tenant.
*
* @param mongo mongodb database handle
* @param redis redis database handle
* @param tenantID the tenant ID that we are resetting the counts for
*/
export async function recalculateSharedStatusCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
const now = new Date();
const key = commentCountsStatusKey(tenantID);
const freshKey = freshenKey(key);
// Clear the existing cached queues.
await redis.del(key, freshKey);
// Fetch all the comments of each status.
const statusResults = await collection<{
_id: string;
total: number;
}>(mongo).aggregate([
{
$match: { tenantID, createdAt: { $lt: now } },
},
{
$project: {
status: {
$objectToArray: "$commentCounts.status",
},
},
},
{
$unwind: "$status",
},
{
$group: {
_id: "$status.k",
total: {
$sum: "$status.v",
},
},
},
]);
// Convert the cursor from the results into an array.
const statuses = await statusResults.toArray();
// Mark the key as fresh, and update the values!
const pipeline = redis.pipeline();
pipeline.mhincrby(
key,
...statuses.reduce((acc, status) => [...acc, status._id, status.total], [])
);
pipeline.set(freshKey, now.getTime(), "EX", COUNT_FRESHNESS_EXPIRY_SECONDS);
await pipeline.exec();
// Now, reconstruct the status counts so we can return it.
const statusCounts: CommentStatusCounts = statuses.reduce(
(acc, status) => ({
...acc,
[status._id]: status.total,
}),
createEmptyCommentStatusCounts()
);
return statusCounts;
}
/**
* recalculateSharedActionCommentCounts will reset the counts stored for this
* Tenant.
*
* @param mongo mongodb database handle
* @param redis redis database handle
* @param tenantID the tenant ID that we are resetting the counts for
*/
export async function recalculateSharedActionCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
const now = new Date();
const key = commentCountsActionKey(tenantID);
const freshKey = freshenKey(key);
// Clear the existing cached queues.
await redis.del(key, freshKey);
// Fetch all the comments of each status.
const actionResults = await collection<{
_id: string;
total: number;
}>(mongo).aggregate([
{
$match: { tenantID, createdAt: { $lt: now } },
},
{
$project: {
status: {
$objectToArray: "$commentCounts.status",
},
},
},
{
$unwind: "$status",
},
{
$group: {
_id: "$status.k",
total: {
$sum: "$status.v",
},
},
},
]);
// Convert the cursor from the results into an array.
const actions = await actionResults.toArray();
// Mark the key as fresh, and update the values!
const pipeline = redis.pipeline();
pipeline.mhincrby(
key,
...actions.reduce((acc, action) => [...acc, action._id, action.total], [])
);
pipeline.set(freshKey, now.getTime(), "EX", COUNT_FRESHNESS_EXPIRY_SECONDS);
await pipeline.exec();
// Now, compile the action counts into EncodedActionCounts.
const actionCounts: EncodedCommentActionCounts = actions.reduce(
(acc, action) => ({
...acc,
[action._id]: action.total,
}),
{}
);
return actionCounts;
}
/**
* recalculateSharedCommentCounts will reset the counts stored for this
* Tenant.
*
* @param mongo mongodb database handle
* @param redis redis database handle
* @param tenantID the tenant ID that we are resetting the counts for
*/
export async function recalculateSharedCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
await Promise.all([
recalculateSharedModerationQueueQueueCounts(mongo, redis, tenantID),
recalculateSharedModerationQueueTotalCounts(mongo, redis, tenantID),
recalculateSharedStatusCommentCounts(mongo, redis, tenantID),
recalculateSharedActionCommentCounts(mongo, redis, tenantID),
]);
}
/**
* stringObjectToNumber will convert an object that has string keys and string
* values into string keys and number values.
*/
function stringObjectToNumber<T>(
input: Record<string, string>,
defaultValue: number = 0
): T {
return Object.entries(input).reduce(
(acc, [key, value]) => ({
...acc,
[key]: parseInt(value, 10) || defaultValue,
}),
{}
) as T;
}
/**
* retrieveSharedActionCommentCounts will retrieve the comment counts based on
* actions for this Tenant.
*
* @param mongo mongodb database handle
* @param redis redis database handle
* @param tenantID the ID of the Tenant that we are getting the shared action
* counts from
*/
export async function retrieveSharedActionCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
const key = commentCountsActionKey(tenantID);
const freshKey = freshenKey(key);
// Get the values, and the freshness key.
const [[, actions], [, fresh]]: [
[Error | undefined, Record<string, string> | null],
[Error | undefined, string | null]
] = await redis
.pipeline()
.hgetall(key)
.get(freshKey)
.exec();
if (!fresh || !actions) {
return recalculateSharedActionCommentCounts(mongo, redis, tenantID);
}
return stringObjectToNumber<EncodedCommentActionCounts>(actions);
}
/**
* retrieveSharedStatusCommentCounts will retrieve the comment counts based on
* the status for this Tenant.
*
* @param mongo mongodb database handle
* @param redis redis database handle
* @param tenantID the ID of the Tenant that we are getting the shared status
* counts from
*/
export async function retrieveSharedStatusCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
const key = commentCountsStatusKey(tenantID);
const freshKey = freshenKey(key);
// Get the values, and the freshness key.
const [[, statuses], [, fresh]]: [
[Error | undefined, Record<string, string> | null],
[Error | undefined, string | null]
] = await redis
.pipeline()
.hgetall(key)
.get(freshKey)
.exec();
if (!fresh || !statuses) {
return recalculateSharedStatusCommentCounts(mongo, redis, tenantID);
}
return stringObjectToNumber<CommentStatusCounts>(statuses);
}
/**
* retrieveSharedModerationQueueTotal will retrieve the count of comments in
* this Tenant that are in need of moderation.
*
* @param mongo mongodb database handle
* @param redis redis database handle
* @param tenantID the ID of the Tenant that we are getting the shared
* moderation counts from
*/
export async function retrieveSharedModerationQueueTotal(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
const key = commentCountsModerationQueueTotalKey(tenantID);
const freshKey = freshenKey(key);
// Get the values, and the freshness key.
const [total, fresh]: [string | null, string | null] = await redis.mget(
key,
freshKey
);
if (fresh === null || total === null) {
return recalculateSharedModerationQueueTotalCounts(mongo, redis, tenantID);
}
return parseInt(total, 10) || 0;
}
/**
* retrieveSharedModerationQueueQueuesCounts will retrieve the count of comments
* in each moderation queue for this Tenant.
*
* @param mongo mongodb database handle
* @param redis redis database handle
* @param tenantID the ID of the Tenant that we are getting the shared
* moderation queue counts from
*/
export async function retrieveSharedModerationQueueQueuesCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
const key = commentCountsModerationQueueQueuesKey(tenantID);
const freshKey = freshenKey(key);
// Get the values, and the freshness key.
const [[, queues], [, fresh]]: [
[Error | undefined, Record<string, string> | null],
[Error | undefined, string | null]
] = await redis
.pipeline()
.hgetall(key)
.get(freshKey)
.exec();
if (!fresh || !queues) {
logger.debug({ tenantID }, "comment moderation counts were not cached");
return recalculateSharedModerationQueueQueueCounts(mongo, redis, tenantID);
}
logger.debug({ tenantID }, "comment moderation counts were cached");
return stringObjectToNumber<CommentModerationCountsPerQueue>(queues);
}
export async function updateSharedCommentCounts(
redis: AugmentedRedis,
tenantID: string,
commentCounts: StoryCounts
) {
const pipeline: AugmentedPipeline = redis.pipeline();
// HASH ${tenantID}:commentCounts:action
const action = pickBy(commentCounts.action || {}, identity);
if (!isEmpty(action)) {
// Determine the arguments that we will increment.
const args = flattenDeep(Object.entries(action));
// Add the command to the pipeline.
pipeline.mhincrby(commentCountsActionKey(tenantID), ...args);
}
// HASH ${tenantID}:commentCounts:status
const status = pickBy(commentCounts.status || {}, identity);
if (!isEmpty(status)) {
// Determine the arguments that we will increment.
const args = flattenDeep(Object.entries(status));
// Add the command to the pipeline.
pipeline.mhincrby(commentCountsStatusKey(tenantID), ...args);
}
// HASH ${tenantID}:commentCounts:moderationQueue:total
const moderationQueue = commentCounts.moderationQueue || {};
const moderationQueueTotal =
typeof moderationQueue.total === "number" ? moderationQueue.total : 0;
if (moderationQueueTotal !== 0) {
// Add the command to the pipeline.
pipeline.incrby(
commentCountsModerationQueueTotalKey(tenantID),
moderationQueueTotal
);
}
// HASH ${tenantID}:commentCounts:moderationQueue:queues
const moderationQueueQueues = pickBy(moderationQueue.queues || {}, identity);
if (!isEmpty(moderationQueueQueues)) {
// Determine the arguments that we will increment.
const args = flattenDeep(Object.entries(moderationQueueQueues));
// Add the command to the pipeline.
pipeline.mhincrby(commentCountsModerationQueueQueuesKey(tenantID), ...args);
}
// Execute the pipeline.
await pipeline.exec();
}
@@ -3,25 +3,20 @@ import uuid from "uuid";
import { Omit } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import {
GQLCOMMENT_STATUS,
GQLStoryMetadata,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { EncodedCommentActionCounts } from "talk-server/models/action/comment";
import { GQLStoryMetadata } from "talk-server/graph/tenant/schema/__generated__/types";
import { ModerationSettings } from "talk-server/models/settings";
import { TenantResource } from "talk-server/models/tenant";
import {
createEmptyCommentModerationQueueCounts,
createEmptyCommentStatusCounts,
StoryCommentCounts,
} from "./counts";
function collection(db: Db) {
return db.collection<Readonly<Story>>("stories");
}
// Export everything under counts.
export * from "./counts";
// TODO: (wyattjoh) write a test to verify that this set of counts is always in sync with GQLCOMMENT_STATUS.
export interface CommentStatusCounts {
[GQLCOMMENT_STATUS.ACCEPTED]: number;
[GQLCOMMENT_STATUS.NONE]: number;
[GQLCOMMENT_STATUS.PREMOD]: number;
[GQLCOMMENT_STATUS.REJECTED]: number;
[GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: number;
function collection<T = Story>(db: Db) {
return db.collection<Readonly<T>>("stories");
}
export interface Story extends TenantResource {
@@ -43,15 +38,9 @@ export interface Story extends TenantResource {
scrapedAt?: Date;
/**
* actionCounts stores all the action counts for all Comment's on this Story.
* commentCounts stores all the comment counters.
*/
commentActionCounts: EncodedCommentActionCounts;
/**
* commentCounts stores the different counts for each comment on the Story
* according to their statuses.
*/
commentCounts: CommentStatusCounts;
commentCounts: StoryCommentCounts;
/**
* settings provides a point where the settings can be overridden for a
@@ -91,8 +80,11 @@ export async function upsertStory(
url,
tenantID,
createdAt: now,
commentActionCounts: {},
commentCounts: createEmptyCommentCounts(),
commentCounts: {
action: {},
status: createEmptyCommentStatusCounts(),
moderationQueue: createEmptyCommentModerationQueueCounts(),
},
},
};
@@ -117,79 +109,6 @@ export async function upsertStory(
return result.value || null;
}
/**
* updateCommentStatusCount increments the number of status counts for the
* given Story ID.
*
* @param mongo the database handle
* @param tenantID the tenant that the Story is on.
* @param id the ID of the Story.
* @param commentStatusCounts the update document that contains a positive or
* negative number of comments to increment on the given Story.
*/
export async function updateCommentStatusCount(
mongo: Db,
tenantID: string,
id: string,
commentStatusCounts: Partial<CommentStatusCounts>
) {
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
},
// Update all the specific comment status counts that are associated with
// each of the counts.
{ $inc: dotize({ commentCounts: commentStatusCounts }) },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
}
/**
* mergeCommentStatusCount will merge an array of commentStatusCount's into one.
*/
export function mergeCommentStatusCount(
commentStatusCounts: CommentStatusCounts[]
): CommentStatusCounts {
const statusCounts = createEmptyCommentCounts();
for (const commentCounts of commentStatusCounts) {
for (const status in commentCounts) {
if (!commentCounts.hasOwnProperty(status)) {
continue;
}
// Because the CommentStatusCounts are not indexable, it should be accessed
// by walking the structure.
switch (status) {
case GQLCOMMENT_STATUS.ACCEPTED:
case GQLCOMMENT_STATUS.NONE:
case GQLCOMMENT_STATUS.PREMOD:
case GQLCOMMENT_STATUS.REJECTED:
case GQLCOMMENT_STATUS.SYSTEM_WITHHELD:
statusCounts[status] += commentCounts[status];
break;
default:
throw new Error("unrecognized status");
}
}
}
return statusCounts;
}
function createEmptyCommentCounts(): CommentStatusCounts {
return {
[GQLCOMMENT_STATUS.ACCEPTED]: 0,
[GQLCOMMENT_STATUS.NONE]: 0,
[GQLCOMMENT_STATUS.PREMOD]: 0,
[GQLCOMMENT_STATUS.REJECTED]: 0,
[GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: 0,
};
}
export interface FindOrCreateStoryInput {
id?: string;
url?: string;
@@ -240,8 +159,11 @@ export async function createStory(
url,
tenantID,
createdAt: now,
commentActionCounts: {},
commentCounts: createEmptyCommentCounts(),
commentCounts: {
action: {},
moderationQueue: createEmptyCommentModerationQueueCounts(),
status: createEmptyCommentStatusCounts(),
},
};
try {
@@ -346,34 +268,6 @@ export async function updateStory(
}
}
/**
* updateStoryActionCounts will update the given comment's action counts on
* the Story.
*
* @param mongo the database handle
* @param tenantID the id of the Tenant
* @param id the id of the Story being updated
* @param actionCounts the action counts to merge into the Story
*/
export async function updateStoryActionCounts(
mongo: Db,
tenantID: string,
id: string,
actionCounts: EncodedCommentActionCounts
) {
const result = await collection(mongo).findOneAndUpdate(
{ id, tenantID },
// Update all the specific action counts that are associated with each of
// the counts.
{ $inc: dotize({ actionCounts }) },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
}
export async function removeStory(mongo: Db, tenantID: string, id: string) {
const result = await collection(mongo).findOneAndDelete({
id,
@@ -398,33 +292,3 @@ export async function removeStories(
},
});
}
/**
* calculateTotalCommentCount will compute the total amount of comments left on
* an Asset by parsing the `CommentStatusCounts`.
*/
export function calculateTotalCommentCount(
commentCounts: CommentStatusCounts
): number {
let count = 0;
for (const status in commentCounts) {
if (!commentCounts.hasOwnProperty(status)) {
continue;
}
// Because the CommentStatusCounts are not indexable, it should be accessed
// by walking the structure.
switch (status) {
case GQLCOMMENT_STATUS.ACCEPTED:
case GQLCOMMENT_STATUS.NONE:
case GQLCOMMENT_STATUS.PREMOD:
case GQLCOMMENT_STATUS.REJECTED:
case GQLCOMMENT_STATUS.SYSTEM_WITHHELD:
count += commentCounts[status];
break;
default:
throw new Error("unrecognized status");
}
}
return count;
}