[CORL-664] Threshold moderate new commenters (#2752)

* add new commenters config

* fix specs and fixtures, add translation strings

* save whether a commenter is new

* fix specs and snaps

* add admin role to new config options

* Update copy

* remvoe unused ref

* feat: initial impl

* Create preliminary comment moderation slices

CORL-688

* Move slices logic into stacks

CORL-688

* Create user comment counts

CORL-688

* Create naive mutation that initializes user comment counts

CORL-688

* Use bulk updates in user counts migration

CORL-688

* fix: review

* fix: fixed issue with aggregation

* Migrate creating comment into stacks

CORL-688

* Migrate editing a comment to the stacks

CORL-688

* Break publishing comment status out of updateAllCounts

CORL-688

* review: removed variable scoping in favor of export

* revert: feb8e8196cd448f5cd24f1ca2eb0b91fe9bd43c7

* review: simplification of stacks implementation

This simplifies the stacks implementation to better reuse code related
to count management and event publishing. This can be used to great
effect with the upcomming events PR #2738.

* Remove un-necessary isNew flags on users

CORL-664

* review: removed variable scoping in favor of export

* revert: feb8e8196cd448f5cd24f1ca2eb0b91fe9bd43c7

* review: simplification of stacks implementation

This simplifies the stacks implementation to better reuse code related
to count management and event publishing. This can be used to great
effect with the upcomming events PR #2738.

* fix: check if authorID is null before update user counts

CORL-688

* fix: addressed bug in shared count retrival

Co-authored-by: Tessa Thornton <tessathornton@gmail.com>
Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
This commit is contained in:
Nick Funk
2020-01-07 14:38:50 -07:00
committed by GitHub
parent e3e2e0f52e
commit d26e331a4f
20 changed files with 437 additions and 2 deletions
+1 -1
View File
@@ -25477,7 +25477,7 @@
"dependencies": {
"async": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
"dev": true
}
@@ -28,6 +28,7 @@ it("renders all markers", () => {
COMMENT_DETECTED_SUSPECT_WORD: 1,
COMMENT_REPORTED_OFFENSIVE: 2,
COMMENT_REPORTED_SPAM: 3,
COMMENT_DETECTED_NEW_COMMENTER: 0,
COMMENT_DETECTED_REPEAT_POST: 1,
},
},
@@ -72,6 +73,7 @@ it("renders some markers", () => {
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_REPORTED_OFFENSIVE: 2,
COMMENT_REPORTED_SPAM: 0,
COMMENT_DETECTED_NEW_COMMENTER: 0,
COMMENT_DETECTED_REPEAT_POST: 0,
},
},
@@ -120,6 +120,14 @@ const markers: Array<
</Marker>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_NEW_COMMENTER && (
<Localized id="moderate-marker-newCommenter" key={keyCounter++}>
<Marker color="reported">New commenter</Marker>
</Localized>
)) ||
null,
];
export class MarkersContainer extends React.Component<MarkersContainerProps> {
@@ -170,6 +178,7 @@ const enhanced = withFragmentContainer<MarkersContainerProps>({
COMMENT_DETECTED_SUSPECT_WORD
COMMENT_REPORTED_OFFENSIVE
COMMENT_REPORTED_SPAM
COMMENT_DETECTED_NEW_COMMENTER
COMMENT_DETECTED_REPEAT_POST
}
}
@@ -15,6 +15,7 @@ exports[`renders all markers 1`] = `
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 1,
"COMMENT_DETECTED_NEW_COMMENTER": 0,
"COMMENT_DETECTED_RECENT_HISTORY": 1,
"COMMENT_DETECTED_REPEAT_POST": 1,
"COMMENT_DETECTED_SPAM": 1,
@@ -170,6 +171,7 @@ exports[`renders some markers 1`] = `
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_NEW_COMMENTER": 0,
"COMMENT_DETECTED_RECENT_HISTORY": 1,
"COMMENT_DETECTED_REPEAT_POST": 0,
"COMMENT_DETECTED_SPAM": 0,
@@ -11,6 +11,7 @@ import { HorizontalGutter } from "coral-ui/components";
import { ModerationConfigContainer_settings as SettingsData } from "coral-admin/__generated__/ModerationConfigContainer_settings.graphql";
import AkismetConfig from "./AkismetConfig";
import NewCommentersConfig from "./NewCommentersConfig";
import PerspectiveConfig from "./PerspectiveConfig";
import PreModerationConfig from "./PreModerationConfig";
import RecentCommentHistoryConfig from "./RecentCommentHistoryConfig";
@@ -29,6 +30,7 @@ export const ModerationConfigContainer: React.FunctionComponent<Props> = ({
return (
<HorizontalGutter size="double" data-testid="configure-moderationContainer">
<PreModerationConfig disabled={submitting} />
<NewCommentersConfig disabled={submitting} />
<RecentCommentHistoryConfig disabled={submitting} />
<PerspectiveConfig disabled={submitting} />
<AkismetConfig disabled={submitting} />
@@ -43,6 +45,7 @@ const enhanced = withFragmentContainer<Props>({
...PerspectiveConfig_formValues @relay(mask: false)
...PreModerationConfig_formValues @relay(mask: false)
...RecentCommentHistoryConfig_formValues @relay(mask: false)
...NewCommentersConfigContainer_settings @relay(mask: false)
}
`,
})(ModerationConfigContainer);
@@ -0,0 +1,3 @@
.thresholdTextField {
width: calc(6 * var(--mini-unit));
}
@@ -0,0 +1,100 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { Field } from "react-final-form";
import { graphql } from "react-relay";
import { ValidationMessage } from "coral-framework/lib/form";
import {
composeValidators,
required,
validateWholeNumberGreaterThan,
} from "coral-framework/lib/validation";
import {
FieldSet,
FormField,
FormFieldDescription,
Label,
TextField,
} from "coral-ui/components/v2";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import OnOffField from "../../OnOffField";
import styles from "./NewCommentersConfig.css";
interface Props {
disabled: boolean;
}
// eslint-disable-next-line no-unused-expressions
graphql`
fragment NewCommentersConfigContainer_settings on Settings {
newCommenters {
premodEnabled
approvedCommentsThreshold
}
}
`;
const NewCommentersConfig: FunctionComponent<Props> = ({ disabled }) => {
return (
<ConfigBox
title={
<Localized id="configure-moderation-newCommenters-title">
<Header container="legend">New commenter approval</Header>
</Localized>
}
>
<Localized id="configure-moderation-newCommenters-description">
<FormFieldDescription>
When this is active, initial comments by a new commenter will be sent
to Pending for moderator approval before publication.
</FormFieldDescription>
</Localized>
<FormField container={<FieldSet />}>
<Localized id="configure-moderation-newCommenters-enable">
<Label component="legend">Enable new commenter approval</Label>
</Localized>
<OnOffField name="newCommenters.premodEnabled" disabled={disabled} />
</FormField>
<FormField>
<Localized id="configure-moderation-newCommenters-approvedCommentsThreshold">
<Label>Number of first comments sent for approval</Label>
</Localized>
<Field
name="newCommenters.approvedCommentsThreshold"
validate={composeValidators(
required,
validateWholeNumberGreaterThan(1)
)}
>
{({ input, meta }) => (
<>
<TextField
classes={{
input: styles.thresholdTextField,
}}
disabled={disabled}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
textAlignCenter
adornment={
<Localized id="configure-moderation-newCommenters-comments">
comments
</Localized>
}
{...input}
/>
<ValidationMessage meta={meta} />
</>
)}
</Field>
</FormField>
</ConfigBox>
);
};
export default NewCommentersConfig;
@@ -275,6 +275,128 @@ approved by a moderator.
</fieldset>
</div>
</fieldset>
<div
className="Box-root ConfigBox-root"
>
<div
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
>
<div>
<legend
className="Header-root"
>
New commenter approval
</legend>
</div>
<div />
</div>
<div
className="ConfigBox-content"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<p
className="FormFieldDescription-root"
>
When this is active, initial comments by a new commenter will be sent to Pending
for moderator approval before publication.
</p>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<legend
className="Label-root"
>
Enable new commenter approval
</legend>
<div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={false}
id="newCommenters.premodEnabled-true"
name="newCommenters.premodEnabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="true"
/>
<label
className="RadioButton-label"
htmlFor="newCommenters.premodEnabled-true"
>
<span>
On
</span>
</label>
</div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={true}
className="RadioButton-input"
disabled={false}
id="newCommenters.premodEnabled-false"
name="newCommenters.premodEnabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value="false"
/>
<label
className="RadioButton-label"
htmlFor="newCommenters.premodEnabled-false"
>
<span>
Off
</span>
</label>
</div>
</div>
</fieldset>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<label
className="Label-root"
>
Number of first comments sent for approval
</label>
<div
className="TextField-root"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="TextField-input NewCommentersConfig-thresholdTextField TextField-colorRegular TextField-textAlignCenter"
disabled={false}
name="newCommenters.approvedCommentsThreshold"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder=""
spellCheck={false}
type="text"
value={2}
/>
<div
className="TextField-adornment"
>
comments
</div>
</div>
</div>
</div>
</div>
</div>
<fieldset
className="FieldSet-root Box-root ConfigBox-root"
>
+5
View File
@@ -161,6 +161,10 @@ export const settings = createFixture<GQLSettings>({
changeUsername: true,
deleteAccount: true,
},
newCommenters: {
premodEnabled: false,
approvedCommentsThreshold: 2,
},
slack: {
channels: [],
},
@@ -532,6 +536,7 @@ export const baseComment = createFixture<GQLComment>({
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_REPORTED_OFFENSIVE: 0,
COMMENT_REPORTED_SPAM: 0,
COMMENT_DETECTED_NEW_COMMENTER: 0,
COMMENT_DETECTED_REPEAT_POST: 0,
},
},
+6
View File
@@ -100,6 +100,7 @@ export function createComment(author?: GQLUser) {
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_REPORTED_OFFENSIVE: 0,
COMMENT_REPORTED_SPAM: 0,
COMMENT_DETECTED_NEW_COMMENTER: 0,
COMMENT_DETECTED_REPEAT_POST: 0,
},
},
@@ -148,6 +149,7 @@ export function createComment(author?: GQLUser) {
COMMENT_DETECTED_BANNED_WORD: 0,
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_DETECTED_PREMOD_USER: 0,
COMMENT_DETECTED_NEW_COMMENTER: 0,
COMMENT_DETECTED_REPEAT_POST: 0,
},
},
@@ -286,6 +288,10 @@ export function createSettings() {
enabled: false,
},
},
newCommenters: {
premodEnabled: false,
approvedCommentsThreshold: 2,
},
auth: {
integrations: {
local: {
@@ -179,6 +179,7 @@ enum COMMENT_FLAG_REASON {
COMMENT_DETECTED_SUSPECT_WORD
COMMENT_DETECTED_RECENT_HISTORY
COMMENT_DETECTED_PREMOD_USER
COMMENT_DETECTED_NEW_COMMENTER
COMMENT_DETECTED_REPEAT_POST
}
@@ -215,6 +216,7 @@ type FlagReasonActionCounts {
COMMENT_DETECTED_SUSPECT_WORD: Int!
COMMENT_DETECTED_RECENT_HISTORY: Int!
COMMENT_DETECTED_PREMOD_USER: Int!
COMMENT_DETECTED_NEW_COMMENTER: Int!
COMMENT_DETECTED_REPEAT_POST: Int!
}
@@ -1194,6 +1196,23 @@ type StaffConfiguration {
label: String!
}
"""
NewCommenterConfiguration specifies the features that apply to new commenters
"""
type NewCommentersConfiguration {
"""
premodEnabled ensures that new commenters' comments are pre-moderated until they have
enough approved comments
"""
premodEnabled: Boolean!
"""
approvedCommentsThreshold is the number of comments a user must have approved before their
comments do not require premoderation
"""
approvedCommentsThreshold: Int!
}
"""
Settings stores the global settings for a given Tenant.
"""
@@ -1339,6 +1358,11 @@ type Settings {
createdAt is the time that the Settings was created at.
"""
createdAt: Time! @auth(roles: [ADMIN])
"""
newCommenters is the configuration for how new commenters comments are treated.
"""
newCommenters: NewCommentersConfiguration! @auth(roles: [ADMIN])
}
################################################################################
@@ -3525,6 +3549,23 @@ input SlackConfigurationInput {
channels: [SlackChannelConfigurationInput!]
}
"""
NewCommenterConfigurationInput specifies the features that apply to new commenters
"""
input NewCommentersConfigurationInput {
"""
premodEnabled ensures that new commenters' comments are pre-moderated until they have
enough approved comments
"""
premodEnabled: Boolean
"""
approvedCommentsThreshold is the number of comments a user must have approved before their
comments do not require premoderation
"""
approvedCommentsThreshold: Int
}
"""
SettingsInput is the partial type of the Settings type for performing mutations.
"""
@@ -3644,6 +3685,11 @@ input SettingsInput {
locale specifies the locale for this Tenant.
"""
locale: Locale
"""
newCommenters is the configuration for how new commenters comments are treated.
"""
newCommenters: NewCommentersConfigurationInput
}
"""
@@ -19,6 +19,7 @@ Object {
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_NEW_COMMENTER": 0,
"COMMENT_DETECTED_PREMOD_USER": 0,
"COMMENT_DETECTED_RECENT_HISTORY": 0,
"COMMENT_DETECTED_REPEAT_POST": 0,
+13
View File
@@ -95,6 +95,14 @@ export interface AccountFeatures {
downloadComments: boolean;
}
/**
* NewCommentersConfiguration is the configuration for how new commenters comments are treated.
*/
export interface NewCommentersConfiguration {
premodEnabled: boolean;
approvedCommentsThreshold: number;
}
/**
* Auth is the set of configured authentication integrations.
*/
@@ -165,4 +173,9 @@ export type Settings = GlobalModerationSettings &
* AccountFeatures are features enabled for commenter accounts
*/
accountFeatures: AccountFeatures;
/**
* newCommenters is the configuration for how new commenters comments are treated.
*/
newCommenters: NewCommentersConfiguration;
};
@@ -157,8 +157,8 @@ export async function retrieveSharedModerationQueueQueuesCounts(
const [[, fresh], [, queues]] = await redis
.pipeline()
.hgetall(key)
.get(freshKey)
.hgetall(key)
.exec();
if (!fresh || !queues) {
logger.debug({ tenantID }, "comment moderation counts were not cached");
+4
View File
@@ -197,6 +197,10 @@ export async function createTenant(
deleteAccount: false,
downloadComments: false,
},
newCommenters: {
premodEnabled: false,
approvedCommentsThreshold: 2,
},
createdAt: now,
slack: {
channels: [],
+4
View File
@@ -472,6 +472,10 @@ export interface User extends TenantResource {
*/
deletedAt?: Date;
/**
* commentCounts are the tallies of all comment statuses for this
* user.
*/
commentCounts: UserCommentCounts;
}
@@ -6,6 +6,7 @@ import { detectLinks } from "./detectLinks";
import { linkify } from "./linkify";
import { preModerate } from "./preModerate";
import { premodUser } from "./preModerateUser";
import { premodNewCommenter } from "./premodNewCommenter";
import { purify } from "./purify";
import { recentCommentHistory } from "./recentCommentHistory";
import { repeatPost } from "./repeatPost";
@@ -33,4 +34,5 @@ export const moderationPhases: IntermediateModerationPhase[] = [
detectLinks,
preModerate,
premodUser,
premodNewCommenter,
];
@@ -0,0 +1,43 @@
import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "coral-server/models/action/comment";
import {
IntermediatePhaseResult,
ModerationPhaseContext,
} from "coral-server/services/comments/pipeline";
export const premodNewCommenter = async ({
tenant,
author,
mongo,
now,
}: Pick<
ModerationPhaseContext,
"author" | "tenant" | "now" | "mongo"
>): Promise<IntermediatePhaseResult | void> => {
// Ensure this mode is enabled.
if (!tenant.newCommenters.premodEnabled) {
return;
}
if (
author.commentCounts.status.APPROVED <
tenant.newCommenters.approvedCommentsThreshold
) {
return {
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_NEW_COMMENTER,
metadata: {
count: author.commentCounts.status.APPROVED,
},
},
],
};
}
};
@@ -0,0 +1,56 @@
import { Db } from "mongodb";
// Use the following collections reference to interact with specific
// collections.
import collections from "coral-server/services/mongodb/collections";
import Migration from "coral-server/services/migrate/migration";
export default class extends Migration {
public async up(mongo: Db, tenantID: string) {
const result = await collections.tenants(mongo).updateOne(
{
id: tenantID,
newCommenters: null,
},
{
$set: {
newCommenters: {
premodEnabled: false,
approvedCommentsThreshold: 2,
},
},
}
);
this.log(tenantID).warn(
{
matchedCount: result.matchedCount,
modifiedCount: result.matchedCount,
},
"added new commenters config"
);
}
public async down(mongo: Db, tenantID: string) {
const result = await collections.tenants(mongo).updateOne(
{
id: tenantID,
newCommenters: {
$exists: true,
},
},
{
$unset: {
newCommenters: "",
},
}
);
this.log(tenantID).warn(
{
matchedCount: result.matchedCount,
modifiedCount: result.matchedCount,
},
"removed new commenters config"
);
}
}
+14
View File
@@ -323,6 +323,19 @@ configure-moderation-perspective-accountNote =
For additional information on how to set up the Perspective Toxic Comment Filter please visit:
<externalLink>https://github.com/conversationai/perspectiveapi#readme</externalLink>
configure-moderation-newCommenters-title = New commenter approval
configure-moderation-newCommenters-enable = Enable new commenter approval
configure-moderation-newCommenters-description =
When this is active, initial comments by a new commenter will be sent to Pending
for moderator approval before publication.
configure-moderation-newCommenters-enable-description = Enable pre-moderation for new commenters
configure-moderation-newCommenters-approvedCommentsThreshold = Number of first comments sent for approval
configure-moderation-newCommenters-approvedCommentsThreshold-description =
The number of comments a user must have approved before they do
not have to be premoderated
configure-moderation-newCommenters-comments = comments
#### Banned Words Configuration
configure-wordList-banned-bannedWordsAndPhrases = Banned words and phrases
configure-wordList-banned-explanation =
@@ -426,6 +439,7 @@ moderate-marker-toxic = Toxic
moderate-marker-recentHistory = Recent history
moderate-marker-bodyCount = Body count
moderate-marker-offensive = Offensive
moderate-marker-newCommenter = New commenter
moderate-marker-repeatPost = Repeat comment
moderate-markers-details = Details