[next] Comment Moderation Actions (#2068)

* fix: renamed snake case to camel case

* fix: changed case for mutators

* fix: renamed all snake case to camel case for db

* feat: added support for comment revisions + split comment actions

* fix: updated tests

* feat: implemented CommentModerationAction

* fix: fixed case issues

* feat: enabled WeakMap for wordList processsing

* chore: npm audit
This commit is contained in:
Wyatt Johnson
2018-11-21 17:42:47 +01:00
committed by Kiwi
parent 13147c4ba4
commit 21e1a5cbef
66 changed files with 2323 additions and 2224 deletions
@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#decodeActionCounts parses the action counts correctly 1`] = `
Object {
"DONT_AGREE": 1,
"FLAG": 2,
"FLAG__COMMENT_DETECTED_BANNED_WORD": 1,
"FLAG__COMMENT_DETECTED_BODY_COUNT": 1,
"REACTION": 3,
}
`;
exports[`#decodeActionCounts parses the action counts correctly 2`] = `
Object {
"dontAgree": Object {
"total": 1,
},
"flag": Object {
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_BODY_COUNT": 1,
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_SPAM": 0,
"COMMENT_DETECTED_SUSPECT_WORD": 0,
"COMMENT_DETECTED_TOXIC": 0,
"COMMENT_DETECTED_TRUST": 0,
"COMMENT_REPORTED_OFFENSIVE": 0,
"COMMENT_REPORTED_SPAM": 0,
},
"total": 2,
},
"reaction": Object {
"total": 3,
},
}
`;
exports[`#encodeActionCounts generates the action counts correctly 1`] = `
Object {
"DONT_AGREE": 1,
"FLAG": 2,
"FLAG__COMMENT_DETECTED_BANNED_WORD": 1,
"FLAG__COMMENT_DETECTED_BODY_COUNT": 1,
}
`;
@@ -0,0 +1,121 @@
import { GQLCOMMENT_FLAG_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
import {
ACTION_TYPE,
CommentAction,
decodeActionCounts,
encodeActionCounts,
validateAction,
} from "talk-server/models/action/comment";
describe("#encodeActionCounts", () => {
it("generates the action counts correctly", () => {
const actions: Array<Partial<CommentAction>> = [
{ actionType: ACTION_TYPE.DONT_AGREE },
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
];
const actionCounts = encodeActionCounts(...(actions as CommentAction[]));
expect(actionCounts).toMatchSnapshot();
});
});
describe("#decodeActionCounts", () => {
it("parses the action counts correctly", () => {
const actions: Array<Partial<CommentAction>> = [
{ actionType: ACTION_TYPE.REACTION },
{ actionType: ACTION_TYPE.REACTION },
{ actionType: ACTION_TYPE.REACTION },
{ actionType: ACTION_TYPE.DONT_AGREE },
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
];
const modelActionCounts = encodeActionCounts(
...(actions as CommentAction[])
);
expect(modelActionCounts).toMatchSnapshot();
const actionCounts = decodeActionCounts(modelActionCounts);
expect(actionCounts).toMatchSnapshot();
});
});
describe("#validateAction", () => {
it("allows a valid action", () => {
const actions: Array<Partial<CommentAction>> = [
{
actionType: ACTION_TYPE.REACTION,
},
{
actionType: ACTION_TYPE.DONT_AGREE,
},
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TRUST,
},
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
},
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
},
];
for (const action of actions) {
validateAction(action as CommentAction);
}
});
it("does not allow an invalid action", () => {
const actions: Array<Partial<CommentAction>> = [
{
actionType: ACTION_TYPE.DONT_AGREE,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
{
actionType: ACTION_TYPE.DONT_AGREE,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
},
{
actionType: ACTION_TYPE.FLAG,
},
];
for (const action of actions) {
expect(() => validateAction(action as CommentAction)).toThrow();
}
});
});
+614
View File
@@ -0,0 +1,614 @@
import Joi from "joi";
import { camelCase, pick } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid";
import { Omit, Sub } from "talk-common/types";
import {
GQLActionCounts,
GQLActionPresence,
GQLCOMMENT_FLAG_DETECTED_REASON,
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_FLAG_REPORTED_REASON,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { FilterQuery } from "talk-server/models/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
return db.collection<Readonly<CommentAction>>("commentActions");
}
export enum ACTION_TYPE {
/**
* REACTION corresponds to a reaction to a comment from a user.
*/
REACTION = "REACTION",
/**
* DONT_AGREE corresponds to when a user marks a given comment that they don't
* agree with.
*/
DONT_AGREE = "DONT_AGREE",
/**
* FLAG corresponds to a flag action that indicates that the given resource needs
* moderator attention.
*/
FLAG = "FLAG",
}
export type EncodedCommentActionCounts = Record<string, number>;
/**
* FLAG_REASON is the reason that a given Flag has been created.
*/
export type FLAG_REASON =
| GQLCOMMENT_FLAG_DETECTED_REASON
| GQLCOMMENT_FLAG_REPORTED_REASON
| GQLCOMMENT_FLAG_REASON;
export interface CommentAction extends TenantResource {
/**
* id is the identifier for this specific Action.
*/
readonly id: string;
/**
* actionType is the type of Action that this represents.
*/
actionType: ACTION_TYPE;
/**
* commentID is the ID of the specific item that this Action is associated with.
*/
commentID: string;
/**
* commentRevisionID is the ID of the specific comment text that the Action
* is relating to.
*/
commentRevisionID: string;
/**
* reason is the reason or secondary grouping identifier for why this
* particular action was left.
*/
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?: string;
/**
* userID is the ID of the User that left this Action. In the event that the
* Action was left by Talk, it will be null.
*/
userID: string | null;
/**
* createdAt is the date that this particular Action was created at.
*/
createdAt: Date;
/**
* metadata is arbitrary information stored for this Action.
*/
metadata?: Record<string, any>;
}
const ActionSchema = [
// Flags
{
actionType: ACTION_TYPE.FLAG,
// Only reasons for the flag action will be allowed here, and it must be
// specified.
reason: Object.keys(GQLCOMMENT_FLAG_REASON),
},
// Don't Agree
{
actionType: ACTION_TYPE.DONT_AGREE,
},
// Reaction
{
actionType: ACTION_TYPE.REACTION,
},
];
/**
* validateAction is used to validate that a specific action conforms to the
* expected schema, `ActionSchema`.
*/
export function validateAction(
action: Pick<CommentAction, "actionType" | "reason">
) {
const { error } = Joi.validate(
// In typescript, this isn't an issue, but when this is transpiled to
// javascript, it will contain additional elements.
pick(action, ["actionType", "reason"]),
ActionSchema,
{
presence: "required",
abortEarly: false,
}
);
if (error) {
// TODO: wrap error?
throw error;
}
}
export type CreateActionInput = Omit<
CommentAction,
"id" | "tenantID" | "createdAt"
>;
export interface CreateActionResultObject {
/**
* action contains the resultant action that was created.
*/
action: CommentAction;
/**
* wasUpserted when true, indicates that this action was just newly created.
* When false, it indicates that this action was just looked up, and had
* existed prior to the `createAction` call.
*/
wasUpserted: boolean;
}
export async function createAction(
mongo: Db,
tenantID: string,
input: CreateActionInput
): Promise<CreateActionResultObject> {
const { metadata, ...filter } = input;
// Create a new ID for the action.
const id = uuid.v4();
// defaults are the properties set by the application when a new action is
// created.
const defaults: Sub<CommentAction, CreateActionInput> = {
id,
tenantID,
createdAt: new Date(),
};
// Merge the defaults with the input.
const action: Readonly<CommentAction> = {
...defaults,
...input,
};
// Create the upsert/update operation.
const update: { $setOnInsert: Readonly<CommentAction> } = {
$setOnInsert: action,
};
// Insert the action into the database using an upsert operation.
const result = await collection(mongo).findOneAndUpdate(filter, update, {
// We are using this to create a action, so we need to upsert it.
upsert: true,
// False to return the updated document instead of the original document.
// This lets us detect if the document was updated or not.
returnOriginal: false,
});
// Check to see if this was a new action that was upserted, or one was found
// that matched existing records. We are sure here that the record exists
// because we're returning the updated document and performing an upsert
// operation.
// Because it's relevant that we know that the action was just created, or
// was just looked up, we need to return the action with an object that
// indicates if it was upserted.
const wasUpserted = result.value!.id === id;
// Return the action that was created/found with a boolean indicating if this
// action was just upserted (and therefore was newly created).
return {
action: result.value!,
wasUpserted,
};
}
export async function createActions(
mongo: Db,
tenantID: string,
inputs: CreateActionInput[]
): Promise<CreateActionResultObject[]> {
// TODO: (wyattjoh) replace with a batch write.
return Promise.all(inputs.map(input => createAction(mongo, tenantID, input)));
}
export async function retrieveUserAction(
mongo: Db,
tenantID: string,
userID: string | null,
commentID: string,
actionType: ACTION_TYPE
) {
return collection(mongo).findOne({
tenantID,
commentID,
userID,
actionType,
});
}
/**
* retrieveManyUserActionPresence returns the action presence for a specific
* user.
*/
export async function retrieveManyUserActionPresence(
mongo: Db,
tenantID: string,
userID: string | null,
commentIDs: string[]
): Promise<GQLActionPresence[]> {
const cursor = await collection(mongo).find(
{
tenantID,
userID,
commentID: { $in: commentIDs },
},
{
// We only need the commentID and actionType from the database.
projection: {
commentID: 1,
actionType: 1,
},
}
);
const actions = await cursor.toArray();
// For each of the actions returned by the query, group the actions by the
// item id. Then compute the action presence for each of the actions.
return commentIDs
.map(commentID => actions.filter(action => action.commentID === commentID))
.map(itemActions =>
itemActions.reduce(
(actionPresence, { actionType }) => ({
...actionPresence,
[camelCase(actionType)]: true,
}),
{
reaction: false,
dontAgree: false,
flag: false,
}
)
);
}
export type RemoveActionInput = Pick<
CommentAction,
"actionType" | "commentID" | "commentRevisionID" | "reason" | "userID"
>;
/**
* The result returned by `removeAction`.
*/
export interface RemovedActionResultObject {
/**
* action is the action that was deleted.
*/
action?: CommentAction;
/**
* wasRemoved is true when the action that was supposed to be deleted was
* actually deleted.
*/
wasRemoved: boolean;
}
/**
* removeAction will delete the action based on the form of the action rather
* than a specific action by ID.
*/
export async function removeAction(
mongo: Db,
tenantID: string,
input: RemoveActionInput
): Promise<RemovedActionResultObject> {
const { reason, ...rest } = input;
// Extract the filter parameters.
const filter: FilterQuery<CommentAction> = {
tenantID,
...rest,
};
// Only add the reason to the filter if it's been specified, otherwise we'll
// never match a Flag that has an unspecified reason.
if (reason) {
filter.reason = reason;
}
// Remove the action from the database, returning the action that was deleted.
const result = await collection(mongo).findOneAndDelete(filter);
return {
action: result.value,
wasRemoved: Boolean(result.ok && result.value),
};
}
/**
* ACTION_COUNT_JOIN_CHAR is the character that is used to separate the reason
* from the action type when storing the action counts in the models.
*/
export const ACTION_COUNT_JOIN_CHAR = "__";
/**
* encodeActionCounts will take a list of actions, and generate action counts
* from it.
*
* @param actions list of actions to generate the action counts from
*/
export function encodeActionCounts(
...actions: CommentAction[]
): EncodedCommentActionCounts {
const actionCounts: EncodedCommentActionCounts = {};
// Loop over the actions, and increment them.
for (const action of actions) {
for (const key of encodeActionCountKeys(action)) {
if (key in actionCounts) {
actionCounts[key]++;
} else {
actionCounts[key] = 1;
}
}
}
return actionCounts;
}
/**
* invertEncodedActionCounts will allow inverting of the action count object.
*
* @param actionCounts the encoded action counts to invert
*/
export function invertEncodedActionCounts(
actionCounts: EncodedCommentActionCounts
): EncodedCommentActionCounts {
for (const key in actionCounts) {
if (!actionCounts.hasOwnProperty(key)) {
continue;
}
if (actionCounts[key] > 0) {
actionCounts[key] = -actionCounts[key];
}
}
return actionCounts;
}
/**
* encodeActionCountKeys encodes the action into string keys which represents
* the groupings as seen in `EncodedActionCounts`.
*/
function encodeActionCountKeys(action: CommentAction): string[] {
const keys = [action.actionType as string];
if (action.reason) {
keys.push(
[action.actionType as string, action.reason as string].join(
ACTION_COUNT_JOIN_CHAR
)
);
}
return keys;
}
interface DecodedActionCountKey {
/**
* actionType stores the action type referenced by the key.
*/
actionType: ACTION_TYPE;
/**
* reason stores the reason referenced by the key if the actionType is FLAG.
*/
reason?: GQLCOMMENT_FLAG_REASON;
}
/**
* decodeActionCountGroup will unpack the key as it is encoded into the separate
* actionType and reason.
*/
function decodeActionCountKey(key: string): DecodedActionCountKey {
let actionType: string = "";
let reason: string = "";
if (key.indexOf(ACTION_COUNT_JOIN_CHAR) >= 0) {
const keys = key.split(ACTION_COUNT_JOIN_CHAR);
if (keys.length !== 2) {
throw new Error(
"invalid action count contained more than two components"
);
}
actionType = keys[0];
reason = keys[1];
// Validate that the action type is flag.
if (actionType !== ACTION_TYPE.FLAG) {
throw new Error("invalid action type, expected only flag to have reason");
}
// Validate that the reason is valid.
if (!reason || !(reason in GQLCOMMENT_FLAG_REASON)) {
throw new Error("expected flag to have a reason that was valid");
}
} else {
actionType = key;
}
// Validate that the action type is valid.
if (!actionType || !(actionType in ACTION_TYPE)) {
throw new Error("expected action to have an action type that was valid");
}
const result: DecodedActionCountKey = {
actionType: actionType as ACTION_TYPE,
};
// Merge in the reason if it's provided. If we got here, we know that the
// reason is a GQLCOMMENT_FLAG_REASON.
if (reason) {
result.reason = reason as GQLCOMMENT_FLAG_REASON;
}
return result;
}
/**
* createEmptyActionCounts creates a default/empty set of action counts.
*/
function createEmptyActionCounts(): GQLActionCounts {
return {
reaction: {
total: 0,
},
dontAgree: {
total: 0,
},
flag: {
total: 0,
// Note that this isn't type checked.. We force it because TS can't
// understand the reduce.
reasons: Object.keys(GQLCOMMENT_FLAG_REASON).reduce(
(reasons, reason) => ({
...reasons,
[reason]: 0,
}),
{}
) as Record<GQLCOMMENT_FLAG_REASON, number>,
},
};
}
export function mergeCommentActionCounts(
actionCounts: EncodedCommentActionCounts[]
): EncodedCommentActionCounts {
const mergedActionCounts: EncodedCommentActionCounts = {};
for (const counts of actionCounts) {
for (const [key, count] of Object.entries(counts)) {
if (key in mergedActionCounts) {
mergedActionCounts[key] += count;
} else {
mergedActionCounts[key] += 1;
}
}
}
return mergedActionCounts;
}
export function countTotalActionCounts(
actionCounts: EncodedCommentActionCounts
): number {
return Object.values(actionCounts).reduce((total, count) => total + count, 0);
}
/**
* decodeActionCounts will take the encoded action counts and decode them into
* a useable format.
*
* @param encodedActionCounts the action counts to decode
*/
export function decodeActionCounts(
encodedActionCounts: EncodedCommentActionCounts
): GQLActionCounts {
// Default all the action counts to zero.
const actionCounts: GQLActionCounts = createEmptyActionCounts();
// Loop over all the encoded action counts to extract each of the action
// counts as they are encoded.
Object.entries(encodedActionCounts).forEach(([key, count]) => {
// Pull out the action type and the reason from the key.
const { actionType, reason } = decodeActionCountKey(key);
// Handle the different types and reasons.
incrementActionCounts(actionCounts, actionType, reason, count);
});
return actionCounts;
}
function incrementActionCounts(
actionCounts: GQLActionCounts,
actionType: ACTION_TYPE,
reason: GQLCOMMENT_FLAG_REASON | undefined,
count: number = 1
) {
switch (actionType) {
case ACTION_TYPE.REACTION:
actionCounts.reaction.total += count;
break;
case ACTION_TYPE.DONT_AGREE:
actionCounts.dontAgree.total += count;
break;
case ACTION_TYPE.FLAG:
// When we have a reason, we are incrementing for that particular reason
// rather than incrementing for the total. If we don't have a reason, we
// just got the updated reason.
if (reason) {
actionCounts.flag.reasons[reason] += count;
} else {
actionCounts.flag.total += count;
}
break;
default:
throw new Error("unexpected action type");
}
return actionCounts;
}
/**
* removeRootActions will remove all the Action's associated with a given root
* identifier.
*/
export async function removeStoryActions(
mongo: Db,
tenantID: string,
storyID: string
) {
return collection(mongo).deleteMany({
tenantID,
storyID,
});
}
/**
* mergeManyRootActions will update many Action `storyID`'s from one to
* another.
*/
export async function mergeManyStoryActions(
mongo: Db,
tenantID: string,
newStoryID: string,
oldStoryIDs: string[]
) {
return collection(mongo).updateMany(
{
tenantID,
storyID: {
$in: oldStoryIDs,
},
},
{
$set: {
storyID: newStoryID,
},
}
);
}
@@ -0,0 +1,173 @@
import { Db } from "mongodb";
import uuid from "uuid";
import { Omit, Sub } from "talk-common/types";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import {
Connection,
Cursor,
getPageInfo,
nodesToEdges,
} 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<CommentModerationAction>>(
"commentModerationActions"
);
}
/**
* CommentModerationAction stores information around a moderation action that
* was created for a given Comment Revision.
*/
export interface CommentModerationAction extends TenantResource {
readonly id: string;
/**
* commentID is the ID of the Comment that the moderation action is based on.
*/
commentID: string;
/**
* commentRevisionID is the ID of the Revision that the moderation action is
* based on.
*/
commentRevisionID: string;
/**
* status is the GQLCOMMENT_STATUS assigned by the moderator for this
* moderation action.
*/
status: GQLCOMMENT_STATUS;
/**
* moderatorID is the ID of the User that created the moderation action. If
* null, it indicates that it was created by the system rather than a User.
*/
moderatorID: string | null;
/**
* createdAt is the time that the moderation action was created on.
*/
createdAt: Date;
}
export type CreateCommentModerationActionInput = Omit<
CommentModerationAction,
"tenantID" | "id" | "createdAt"
>;
export async function createCommentModerationAction(
mongo: Db,
tenantID: string,
input: CreateCommentModerationActionInput
) {
// default are the properties set by the application when a new comment
// moderation action is created.
const defaults: Sub<
CommentModerationAction,
CreateCommentModerationActionInput
> = {
id: uuid.v4(),
tenantID,
createdAt: new Date(),
};
// Merge the defaults and the input together.
const action: Readonly<CommentModerationAction> = {
...defaults,
...input,
};
// Insert it into the database.
await collection(mongo).insertOne(action);
return action;
}
export type CommentModerationActionFilter = Partial<
Pick<
CommentModerationAction,
"commentID" | "commentRevisionID" | "moderatorID" | "status"
>
>;
export async function retrieveCommentModerationActions(
mongo: Db,
tenantID: string,
filter: CommentModerationActionFilter
) {
const result = await collection(mongo).find({
tenantID,
...filter,
});
return result.toArray();
}
export interface ConnectionInput {
first: number;
after?: Cursor;
filter?: CommentModerationActionFilter;
}
export async function retrieveCommentModerationActionConnection(
mongo: Db,
tenantID: string,
input: ConnectionInput
): Promise<Readonly<Connection<Readonly<CommentModerationAction>>>> {
// 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);
}
async function retrieveConnection(
input: ConnectionInput,
query: Query<CommentModerationAction>
): Promise<Readonly<Connection<Readonly<CommentModerationAction>>>> {
// Apply the cursor to the query. Currently only supporting sorting by the
// newest first.
query.orderBy({ createdAt: -1 });
if (input.after) {
query.where({ createdAt: { $lt: input.after as Date } });
}
// We load one more than the limit so we can determine if there is
// another page of entries. This gets trimmed off below after we've checked to
// see if this constitutes another page of edges.
query.first(input.first + 1);
// Get the cursor.
const cursor = await query.exec();
// Get the comments from the cursor.
const nodes = await cursor.toArray();
// Convert the nodes to edges (which will include the extra edge we don't need
// if there is more results).
const edges = nodesToEdges(nodes, a => a.createdAt);
// Get the pageInfo for the connection. We will use this to also determine if
// we need to trim off the extra edge that we requested by comparing its
// hasNextPage parameter.
const pageInfo = getPageInfo(input, edges);
if (pageInfo.hasNextPage) {
// Because this means that we got one more than expected, we should trim off
// the extra edge that was retrieved.
edges.splice(input.first, 1);
}
// Return the connection.
return {
edges,
pageInfo,
};
}