[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
+74 -43
View File
@@ -3,7 +3,6 @@ import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { GQLCOMMENT_FLAG_REPORTED_REASON } from "talk-server/graph/tenant/schema/__generated__/types";
import {
ACTION_ITEM_TYPE,
ACTION_TYPE,
CreateActionInput,
createActions,
@@ -11,8 +10,10 @@ import {
invertEncodedActionCounts,
removeAction,
RemoveActionInput,
} from "talk-server/models/action";
retrieveUserAction,
} from "talk-server/models/action/comment";
import {
getLatestRevision,
retrieveComment,
updateCommentActionCounts,
} from "talk-server/models/comment";
@@ -21,8 +22,8 @@ import { updateStoryActionCounts } from "talk-server/models/story";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
export type CreateAction = Omit<CreateActionInput, "root_item_id"> &
Required<Pick<CreateActionInput, "root_item_id">>;
export type CreateAction = Omit<CreateActionInput, "storyID"> &
Required<Pick<CreateActionInput, "storyID">>;
export async function addCommentActions(
mongo: Db,
@@ -43,11 +44,15 @@ export async function addCommentActions(
// Compute the action counts.
const actionCounts = encodeActionCounts(...upsertedActions);
// Grab the last revision (the most recent).
const revision = getLatestRevision(comment);
// Update the comment action counts here.
const updatedComment = await updateCommentActionCounts(
mongo,
tenant.id,
comment.id,
revision.id,
actionCounts
);
@@ -55,7 +60,7 @@ export async function addCommentActions(
await updateStoryActionCounts(
mongo,
tenant.id,
comment.story_id,
comment.storyID,
actionCounts
);
@@ -77,17 +82,17 @@ async function addCommentAction(
tenant: Tenant,
input: CreateActionInput
): Promise<Readonly<Comment>> {
const comment = await retrieveComment(mongo, tenant.id, input.item_id);
const comment = await retrieveComment(mongo, tenant.id, input.commentID);
if (!comment) {
// 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.root_item_id = comment.story_id;
input.storyID = comment.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 `root_item_id`
// 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]);
}
@@ -95,17 +100,36 @@ async function addCommentAction(
export async function removeCommentAction(
mongo: Db,
tenant: Tenant,
input: RemoveActionInput
input: Omit<RemoveActionInput, "commentRevisionID">
): Promise<Readonly<Comment>> {
// Get the Comment that we are leaving the Action on.
const comment = await retrieveComment(mongo, tenant.id, input.item_id);
const comment = await retrieveComment(mongo, tenant.id, input.commentID);
if (!comment) {
// TODO: replace to match error returned by the models/comments.ts
throw new Error("comment not found");
}
// Get the revision for the specific action being removed.
const action = await retrieveUserAction(
mongo,
tenant.id,
input.userID,
input.commentID,
input.actionType
);
if (!action) {
// The action that is trying to get removed does not exist!
return comment;
}
// Grab the revision ID out of the action.
const { commentID, commentRevisionID } = action;
// Create each of the actions, returning each of the action results.
const { wasRemoved, action } = await removeAction(mongo, tenant.id, input);
const { wasRemoved } = await removeAction(mongo, tenant.id, {
...input,
commentRevisionID,
});
if (wasRemoved) {
// Compute the action counts, and invert them (because we're deleting an
// action).
@@ -115,7 +139,8 @@ export async function removeCommentAction(
const updatedComment = await updateCommentActionCounts(
mongo,
tenant.id,
comment.id,
commentID,
commentRevisionID,
actionCounts
);
@@ -123,7 +148,7 @@ export async function removeCommentAction(
await updateStoryActionCounts(
mongo,
tenant.id,
comment.story_id,
comment.storyID,
actionCounts
);
@@ -139,7 +164,10 @@ export async function removeCommentAction(
return comment;
}
export type CreateCommentReaction = Pick<CreateActionInput, "item_id">;
export type CreateCommentReaction = Pick<
CreateActionInput,
"commentID" | "commentRevisionID"
>;
export async function createReaction(
mongo: Db,
@@ -148,14 +176,14 @@ export async function createReaction(
input: CreateCommentReaction
) {
return addCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.REACTION,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
actionType: ACTION_TYPE.REACTION,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
userID: author.id,
});
}
export type RemoveCommentReaction = Pick<RemoveActionInput, "item_id">;
export type RemoveCommentReaction = Pick<RemoveActionInput, "commentID">;
export async function removeReaction(
mongo: Db,
@@ -164,14 +192,16 @@ export async function removeReaction(
input: RemoveCommentReaction
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.REACTION,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
actionType: ACTION_TYPE.REACTION,
commentID: input.commentID,
userID: author.id,
});
}
export type CreateCommentDontAgree = Pick<CreateActionInput, "item_id">;
export type CreateCommentDontAgree = Pick<
CreateActionInput,
"commentID" | "commentRevisionID"
>;
export async function createDontAgree(
mongo: Db,
@@ -180,14 +210,14 @@ export async function createDontAgree(
input: CreateCommentDontAgree
) {
return addCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.DONT_AGREE,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
actionType: ACTION_TYPE.DONT_AGREE,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
userID: author.id,
});
}
export type RemoveCommentDontAgree = Pick<RemoveActionInput, "item_id">;
export type RemoveCommentDontAgree = Pick<RemoveActionInput, "commentID">;
export async function removeDontAgree(
mongo: Db,
@@ -196,14 +226,16 @@ export async function removeDontAgree(
input: RemoveCommentDontAgree
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.DONT_AGREE,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
actionType: ACTION_TYPE.DONT_AGREE,
commentID: input.commentID,
userID: author.id,
});
}
export type CreateCommentFlag = Pick<CreateActionInput, "item_id"> & {
export type CreateCommentFlag = Pick<
CreateActionInput,
"commentID" | "commentRevisionID"
> & {
reason: GQLCOMMENT_FLAG_REPORTED_REASON;
};
@@ -214,15 +246,15 @@ export async function createFlag(
input: CreateCommentFlag
) {
return addCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: input.reason,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
userID: author.id,
});
}
export type RemoveCommentFlag = Pick<RemoveActionInput, "item_id">;
export type RemoveCommentFlag = Pick<RemoveActionInput, "commentID">;
export async function removeFlag(
mongo: Db,
@@ -231,9 +263,8 @@ export async function removeFlag(
input: RemoveCommentFlag
) {
return removeCommentAction(mongo, tenant, {
action_type: ACTION_TYPE.FLAG,
item_type: ACTION_ITEM_TYPE.COMMENTS,
item_id: input.item_id,
user_id: author.id,
actionType: ACTION_TYPE.FLAG,
commentID: input.commentID,
userID: author.id,
});
}
+22 -23
View File
@@ -1,12 +1,12 @@
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { ACTION_ITEM_TYPE } from "talk-server/models/action";
import {
createComment,
CreateCommentInput,
editComment,
EditCommentInput,
getLatestRevision,
pushChildCommentIDOntoParent,
retrieveComment,
} from "talk-server/models/comment";
@@ -25,7 +25,7 @@ import { Request } from "talk-server/types/express";
export type CreateComment = Omit<
CreateCommentInput,
"status" | "action_counts" | "metadata" | "grandparent_ids"
"status" | "metadata" | "grandparentIDs"
>;
export async function create(
@@ -36,7 +36,7 @@ export async function create(
req?: Request
) {
// Grab the story that we'll use to check moderation pieces with.
const story = await retrieveStory(mongo, tenant.id, input.story_id);
const story = await retrieveStory(mongo, tenant.id, input.storyID);
if (!story) {
// TODO: (wyattjoh) return better error.
throw new Error("story referenced does not exist");
@@ -45,9 +45,9 @@ export async function create(
// TODO: (wyattjoh) Check that the story was visible.
const grandparentIDs: string[] = [];
if (input.parent_id) {
if (input.parentID) {
// Check to see that the reference parent ID exists.
const parent = await retrieveComment(mongo, tenant.id, input.parent_id);
const parent = await retrieveComment(mongo, tenant.id, input.parentID);
if (!parent) {
// TODO: (wyattjoh) return better error.
throw new Error("parent comment referenced does not exist");
@@ -56,10 +56,10 @@ export async function create(
// TODO: (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.grandparent_ids);
if (parent.parent_id) {
grandparentIDs.push(...parent.grandparentIDs);
if (parent.parentID) {
// If this parent has a parent, push it down as well.
grandparentIDs.push(parent.parent_id);
grandparentIDs.push(parent.parentID);
}
}
@@ -76,23 +76,22 @@ export async function create(
let comment = await createComment(mongo, tenant.id, {
...input,
status,
action_counts: {},
grandparent_ids: grandparentIDs,
grandparentIDs,
metadata,
});
if (actions.length > 0) {
// The actions coming from the moderation phases didn't know the item_id
// 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,
item_id: comment.id,
item_type: ACTION_ITEM_TYPE.COMMENTS,
commentID: comment.id,
commentRevisionID: getLatestRevision(comment!).id,
// Store the Story ID on the action.
root_item_id: story.id,
storyID: story.id,
})
);
@@ -100,12 +99,12 @@ export async function create(
comment = await addCommentActions(mongo, tenant, comment, inputs);
}
if (input.parent_id) {
if (input.parentID) {
// Push the child's ID onto the parent.
await pushChildCommentIDOntoParent(
mongo,
tenant.id,
input.parent_id,
input.parentID,
comment.id
);
}
@@ -120,7 +119,7 @@ export async function create(
export type EditComment = Omit<
EditCommentInput,
"status" | "author_id" | "lastEditableCommentCreatedAt"
"status" | "authorID" | "lastEditableCommentCreatedAt"
>;
export async function edit(
@@ -138,7 +137,7 @@ export async function edit(
}
// Grab the story that we'll use to check moderation pieces with.
const story = await retrieveStory(mongo, tenant.id, comment.story_id);
const story = await retrieveStory(mongo, tenant.id, comment.storyID);
if (!story) {
// TODO: (wyattjoh) return better error.
throw new Error("story referenced does not exist");
@@ -155,7 +154,7 @@ export async function edit(
let editedComment = await editComment(mongo, tenant.id, {
id: input.id,
author_id: author.id,
authorID: author.id,
body: input.body,
status,
metadata,
@@ -173,7 +172,7 @@ export async function edit(
}
if (actions.length > 0) {
// The actions coming from the moderation phases didn't know the item_id
// 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(
@@ -181,11 +180,11 @@ export async function edit(
...action,
// Strict null check seems to have failed here... Null checking was done
// above where we errored if the comment was falsely.
item_id: comment!.id,
item_type: ACTION_ITEM_TYPE.COMMENTS,
commentID: comment!.id,
commentRevisionID: getLatestRevision(comment!).id,
// Store the Story ID on the action.
root_item_id: story.id,
storyID: story.id,
})
);
@@ -2,7 +2,7 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
compose,
ModerationPhaseContext,
@@ -51,11 +51,13 @@ describe("compose", () => {
const flags = [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
];
@@ -71,7 +73,8 @@ describe("compose", () => {
() => ({
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
},
],
@@ -85,7 +88,7 @@ describe("compose", () => {
}
expect(final.actions).not.toContainEqual({
action_type: ACTION_TYPE.FLAG,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
});
});
@@ -1,7 +1,7 @@
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";
import { Comment } from "talk-server/models/comment";
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";
@@ -9,7 +9,10 @@ import { Request } from "talk-server/types/express";
import { moderationPhases } from "./phases";
export type ModerationAction = Omit<CreateActionInput, "item_id" | "item_type">;
export type ModerationAction = Omit<
CreateActionInput,
"commentID" | "commentRevisionID"
>;
export interface PhaseResult {
actions: ModerationAction[];
@@ -20,7 +23,7 @@ export interface PhaseResult {
export interface ModerationPhaseContext {
story: Story;
tenant: Tenant;
comment: Partial<Comment>;
comment: Partial<EditCommentInput>;
author: User;
req?: Request;
}
@@ -5,7 +5,7 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
@@ -50,7 +50,8 @@ export const commentLength: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.REJECTED,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BODY_COUNT,
metadata: {
count: length,
@@ -2,7 +2,7 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -33,7 +33,8 @@ export const karma: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
metadata: {
trust: getCommentTrustScore(author),
@@ -5,7 +5,7 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
@@ -39,7 +39,8 @@ export const links: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
metadata: {
links: comment.body,
@@ -5,7 +5,7 @@ import {
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -20,29 +20,31 @@ export const spam: IntermediateModerationPhase = async ({
}): Promise<IntermediatePhaseResult | void> => {
const integration = tenant.integrations.akismet;
const log = logger.child({
tenantID: tenant.id,
});
// We can only check for spam if this comment originated from a graphql
// request via an HTTP call.
if (!req) {
logger.debug({ tenant_id: tenant.id }, "request was not available");
log.debug("request was not available");
return;
}
if (!integration.enabled) {
logger.debug({ tenant_id: tenant.id }, "akismet integration was disabled");
log.debug("akismet integration was disabled");
return;
}
if (!integration.key) {
logger.error(
{ tenant_id: tenant.id },
log.error(
"akismet integration was enabled but the key configuration was missing"
);
return;
}
if (!integration.site) {
logger.error(
{ tenant_id: tenant.id },
log.error(
"akismet integration was enabled but the site configuration was missing"
);
return;
@@ -62,33 +64,24 @@ export const spam: IntermediateModerationPhase = async ({
// Grab the properties we need.
const userIP = req.ip;
if (!userIP) {
logger.debug(
{ tenant_id: tenant.id },
"request did not contain ip address, aborting spam check"
);
log.debug("request did not contain ip address, aborting spam check");
return;
}
const userAgent = req.get("User-Agent");
if (!userAgent || userAgent.length === 0) {
logger.debug(
{ tenant_id: tenant.id },
"request did not contain User-Agent header, aborting spam check"
);
log.debug("request did not contain User-Agent header, aborting spam check");
return;
}
const referrer = req.get("Referrer");
if (!referrer || referrer.length === 0) {
logger.debug(
{ tenant_id: tenant.id },
"request did not contain Referrer header, aborting spam check"
);
log.debug("request did not contain Referrer header, aborting spam check");
return;
}
try {
logger.trace({ tenant_id: tenant.id }, "checking comment for spam");
log.trace("checking comment for spam");
// Check the comment for spam.
const isSpam = await client.checkSpam({
@@ -102,15 +95,13 @@ export const spam: IntermediateModerationPhase = async ({
is_test: false,
});
if (isSpam) {
logger.trace(
{ tenant_id: tenant.id, is_spam: isSpam },
"comment contained spam"
);
log.trace({ isSpam }, "comment contained spam");
return {
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
],
@@ -121,14 +112,8 @@ export const spam: IntermediateModerationPhase = async ({
};
}
logger.trace(
{ tenant_id: tenant.id, is_spam: isSpam },
"comment did not contain spam"
);
log.trace({ isSpam }, "comment did not contain spam");
} catch (err) {
logger.error(
{ tenant_id: tenant.id, err },
"could not determine if comment contained spam"
);
log.error({ err }, "could not determine if comment contained spam");
}
};
@@ -26,7 +26,7 @@ describe("storyClosed", () => {
expect(() =>
storyClosed({
story: { created_at: new Date() } as Story,
story: { createdAt: new Date() } as Story,
tenant: { autoCloseStream: true, closedTimeout: -6000 } as Tenant,
comment: {} as Comment,
author: {} as User,
@@ -17,7 +17,7 @@ export const storyClosed: IntermediateModerationPhase = ({
story.closedAt !== false &&
tenant.autoCloseStream &&
tenant.closedTimeout &&
story.created_at.valueOf() + tenant.closedTimeout <= Date.now()
story.createdAt.valueOf() + tenant.closedTimeout <= Date.now()
) {
// TODO: (wyattjoh) return better error.
throw new Error("story is currently closed for commenting");
@@ -9,7 +9,7 @@ import {
GQLPerspectiveExternalIntegration,
} from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -23,21 +23,19 @@ export const toxic: IntermediateModerationPhase = async ({
return;
}
const log = logger.child({ tenantID: tenant.id });
const integration = tenant.integrations.perspective;
if (!integration.enabled) {
// The Toxic comment plugin is not enabled.
logger.debug(
{ tenant_id: tenant.id },
"perspective integration was disabled"
);
log.debug("perspective integration was disabled");
return;
}
if (!integration.key) {
// The Toxic comment requires a key in order to communicate with the API.
logger.error(
{ tenant_id: tenant.id },
log.error(
"perspective integration was enabled but the key configuration was missing"
);
return;
@@ -48,8 +46,8 @@ export const toxic: IntermediateModerationPhase = async ({
// TODO: (wyattjoh) replace hardcoded default with config.
endpoint = "https://commentanalyzer.googleapis.com/v1alpha1";
logger.trace(
{ tenant_id: tenant.id, endpoint },
log.trace(
{ endpoint },
"endpoint missing in integration settings, using defaults"
);
}
@@ -59,8 +57,8 @@ export const toxic: IntermediateModerationPhase = async ({
// TODO: (wyattjoh) replace hardcoded default with config.
threshold = 0.8;
logger.trace(
{ tenant_id: tenant.id, threshold },
log.trace(
{ threshold },
"threshold missing in integration settings, using defaults"
);
}
@@ -69,8 +67,8 @@ export const toxic: IntermediateModerationPhase = async ({
if (isNil(doNotStore)) {
doNotStore = true;
logger.trace(
{ tenant_id: tenant.id, do_not_store: doNotStore },
log.trace(
{ doNotStore },
"doNotStore missing in integration settings, using defaults"
);
}
@@ -79,7 +77,7 @@ export const toxic: IntermediateModerationPhase = async ({
const timeout = ms("300ms");
try {
logger.trace({ tenant_id: tenant.id }, "checking comment toxicity");
logger.trace("checking comment toxicity");
// Call into the Toxic comment API.
const scores = await getScores(
@@ -95,15 +93,13 @@ export const toxic: IntermediateModerationPhase = async ({
const score = scores.SEVERE_TOXICITY.summaryScore;
const isToxic = score > threshold;
if (isToxic) {
logger.trace(
{ tenant_id: tenant.id, score, is_toxic: isToxic, threshold },
"comment was toxic"
);
log.trace({ score, isToxic, threshold }, "comment was toxic");
return {
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
],
@@ -114,15 +110,9 @@ export const toxic: IntermediateModerationPhase = async ({
};
}
logger.trace(
{ tenant_id: tenant.id, score, is_toxic: isToxic, threshold },
"comment was not toxic"
);
log.trace({ score, isToxic, threshold }, "comment was not toxic");
} catch (err) {
logger.error(
{ tenant_id: tenant.id, err },
"could not determine comment toxicity"
);
log.error({ err }, "could not determine comment toxicity");
}
};
@@ -2,7 +2,7 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "talk-server/models/action";
import { ACTION_TYPE } from "talk-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -29,7 +29,8 @@ export const wordList: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.REJECTED,
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
],
@@ -46,7 +47,8 @@ export const wordList: IntermediateModerationPhase = ({
return {
actions: [
{
action_type: ACTION_TYPE.FLAG,
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
},
],
@@ -1,8 +1,7 @@
import { memoize } from "lodash";
// TODO: reintroduce this when we have https://github.com/DefinitelyTyped/DefinitelyTyped/pull/30035 merged
// // Replace `memoize.Cache`.
// memoize.Cache = WeakMap;
// Replace `memoize.Cache`.
memoize.Cache = WeakMap;
/**
* Escape string for special regular expression characters.
@@ -0,0 +1,81 @@
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);
+31 -45
View File
@@ -10,10 +10,10 @@ import {
import logger from "talk-server/logger";
import {
countTotalActionCounts,
mergeActionCounts,
mergeManyRootActions,
removeRootActions,
} from "talk-server/models/action";
mergeCommentActionCounts,
mergeManyStoryActions,
removeStoryActions,
} from "talk-server/models/action/comment";
import {
mergeManyCommentStories,
removeStoryComments,
@@ -123,8 +123,8 @@ export async function remove(
) {
// Create a logger for this function.
const log = logger.child({
story_id: storyID,
include_comments: includeComments,
storyID,
includeComments,
});
log.debug("starting to remove story");
@@ -138,32 +138,24 @@ export async function remove(
}
if (includeComments) {
let removedCount: number | undefined;
// Remove the actions associated with the comments we just removed.
({ deletedCount: removedCount } = await removeRootActions(
const { deletedCount: removedActions } = await removeStoryActions(
mongo,
tenant.id,
story.id
));
log.debug(
{ removed_actions: removedCount },
"removed actions while deleting story"
);
log.debug({ removedActions }, "removed actions while deleting story");
// Remove the comments for the story.
({ deletedCount: removedCount } = await removeStoryComments(
const { deletedCount: removedComments } = await removeStoryComments(
mongo,
tenant.id,
story.id
));
log.debug(
{ removed_comments: removedCount },
"removed comments while deleting story"
);
} else if (calculateTotalCommentCount(story.comment_counts) > 0) {
log.debug({ removedComments }, "removed comments while deleting story");
} else if (calculateTotalCommentCount(story.commentCounts) > 0) {
log.warn(
"attempted to remove story that has linked comments without consent for deleting comments"
);
@@ -196,7 +188,7 @@ export async function create(
// Ensure that the given URL is allowed.
if (!isURLPermitted(tenant, storyURL)) {
logger.warn(
{ story_url: storyURL, tenant_domains: tenant.domains },
{ storyURL, tenantDomains: tenant.domains },
"provided story url was not in the list of permitted tenant domains, story not created"
);
return null;
@@ -224,7 +216,7 @@ export async function update(
// Ensure that the given URL is allowed.
if (input.url && !isURLPermitted(tenant, input.url)) {
logger.warn(
{ story_url: input.url, tenant_domains: tenant.domains },
{ storyURL: input.url, tenantDomains: tenant.domains },
"provided story url was not in the list of permitted tenant domains, story not updated"
);
return null;
@@ -241,8 +233,8 @@ export async function merge(
) {
// Create a logger for this operation.
const log = logger.child({
destination_id: destinationID,
source_ids: sourceIDs,
destinationID,
sourceIDs,
});
if (sourceIDs.length === 0) {
@@ -259,7 +251,7 @@ export async function merge(
zip(storyIDs, stories).some(([storyID, story]) => {
if (!story) {
log.warn(
{ story_id: storyID },
{ storyID },
"story that was going to be merged was not found"
);
return true;
@@ -271,36 +263,28 @@ export async function merge(
return null;
}
let updatedCount: number | undefined;
// Move all the comment's from the source stories over to the destination
// story.
({ modifiedCount: updatedCount } = await mergeManyCommentStories(
const { modifiedCount: updatedComments } = await mergeManyCommentStories(
mongo,
tenant.id,
destinationID,
sourceIDs
));
log.debug(
{ updated_comments: updatedCount },
"updated comments while merging stories"
);
log.debug({ updatedComments }, "updated comments while merging stories");
// Update all the action's that referenced the old story to reference the new
// story.
({ modifiedCount: updatedCount } = await mergeManyRootActions(
const { modifiedCount: updatedActions } = await mergeManyStoryActions(
mongo,
tenant.id,
destinationID,
sourceIDs
));
log.debug(
{ updated_actions: updatedCount },
"updated actions while merging stories"
);
log.debug({ updatedActions }, "updated actions while merging stories");
// Merge the comment and action counts for all the source stories.
const [, ...sourceStories] = stories;
@@ -311,14 +295,16 @@ export async function merge(
mergeCommentStatusCount(
// We perform the type assertion here because above, we already verified
// that none of the stories are null.
(sourceStories as Story[]).map(({ comment_counts }) => comment_counts)
(sourceStories as Story[]).map(({ commentCounts }) => commentCounts)
)
);
const mergedActionCounts = mergeActionCounts(
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(({ action_counts }) => action_counts)
(sourceStories as Story[]).map(
({ commentActionCounts }) => commentActionCounts
)
);
if (countTotalActionCounts(mergedActionCounts) > 0) {
destinationStory = await updateStoryActionCounts(
@@ -335,13 +321,13 @@ export async function merge(
}
log.debug(
{ comment_counts: destinationStory.comment_counts },
{ commentCounts: destinationStory.commentCounts },
"updated destination story with new comment counts"
);
const { deletedCount } = await removeStories(mongo, tenant.id, sourceIDs);
log.debug({ deleted_stories: deletedCount }, "deleted source stories");
log.debug({ deletedStories: deletedCount }, "deleted source stories");
// Return the story that had the other stories merged into.
return destinationStory;
+2 -2
View File
@@ -195,7 +195,7 @@ export default class TenantCache {
return;
}
logger.debug({ tenant_id: tenant.id }, "received updated tenant");
logger.debug({ tenantID: tenant.id }, "received updated tenant");
// Update the tenant cache.
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
@@ -261,7 +261,7 @@ export default class TenantCache {
JSON.stringify(message)
);
logger.debug({ tenant_id: tenant.id, subscribers }, "updated tenant");
logger.debug({ tenantID: tenant.id, subscribers }, "updated tenant");
// Publish the event for the connected listeners.
this.emitter.emit(EMITTER_EVENT_NAME, tenant);
+1 -1
View File
@@ -64,7 +64,7 @@ export async function install(
await cache.update(redis, tenant);
logger.info(
{ tenant_id: tenant.id, tenant_domain: tenant.domain },
{ tenantID: tenant.id, tenantDomain: tenant.domain },
"a tenant has been installed"
);