diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..54ad9dd96 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "ms-vscode.vscode-typescript-tslint-plugin", + "kumar-harsh.graphql-for-vscode", + "editorconfig.editorconfig", + "ms-azuretools.vscode-cosmosdb" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 122abc2a0..fb53602ee 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,4 @@ { - "editor.formatOnSave": true, "files.associations": { "*.css": "postcss" }, @@ -12,9 +11,11 @@ ".vs": true }, "tslint.exclude": "**/node_modules/**", - "tslint.autoFixOnSave": true, "tslint.jsEnable": true, - "tslint.nodePath": "node_modules/.bin/tslint", - "typescript.tsdk": "./node_modules/typescript/lib", - "postcss.validate": false + "typescript.tsdk": "node_modules/typescript/lib", + "postcss.validate": false, + "javascript.preferences.importModuleSpecifier": "non-relative", + "editor.codeActionsOnSave": { + "source.fixAll.tslint": true + } } diff --git a/package-lock.json b/package-lock.json index e664bbed3..552032efe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23643,7 +23643,7 @@ "dependencies": { "async": { "version": "1.5.2", - "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true } diff --git a/scripts/generateSchemaTypes.js b/scripts/generateSchemaTypes.js index 4c3b3b2ba..d5a743b11 100644 --- a/scripts/generateSchemaTypes.js +++ b/scripts/generateSchemaTypes.js @@ -19,7 +19,7 @@ async function main() { contextType: "TenantContext", importStatements: [ 'import TenantContext from "coral-server/graph/tenant/context";', - 'import { Cursor } from "coral-server/models/helpers/connection";', + 'import { Cursor } from "coral-server/models/helpers";', ], customScalarType: { Cursor: "Cursor", Time: "Date" }, }, diff --git a/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx b/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx index c9f5bf9b1..607df53d2 100644 --- a/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx +++ b/src/core/client/admin/components/ModerateCard/MarkersContainer.spec.tsx @@ -19,7 +19,7 @@ it("renders all markers", () => { reasons: { COMMENT_DETECTED_TOXIC: 1, COMMENT_DETECTED_SPAM: 1, - COMMENT_DETECTED_TRUST: 1, + COMMENT_DETECTED_RECENT_HISTORY: 1, COMMENT_DETECTED_LINKS: 1, COMMENT_DETECTED_BANNED_WORD: 1, COMMENT_DETECTED_SUSPECT_WORD: 1, @@ -59,7 +59,7 @@ it("renders some markers", () => { reasons: { COMMENT_DETECTED_TOXIC: 1, COMMENT_DETECTED_SPAM: 0, - COMMENT_DETECTED_TRUST: 1, + COMMENT_DETECTED_RECENT_HISTORY: 1, COMMENT_DETECTED_LINKS: 0, COMMENT_DETECTED_BANNED_WORD: 1, COMMENT_DETECTED_SUSPECT_WORD: 0, diff --git a/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx b/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx index aea8e4344..df04a87ce 100644 --- a/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx +++ b/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx @@ -72,9 +72,9 @@ const markers: Array< )) || null, c => - (c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TRUST && ( - - Karma + (c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && ( + + Recent History )) || null, @@ -141,7 +141,7 @@ const enhanced = withFragmentContainer({ reasons { COMMENT_DETECTED_TOXIC COMMENT_DETECTED_SPAM - COMMENT_DETECTED_TRUST + COMMENT_DETECTED_RECENT_HISTORY COMMENT_DETECTED_LINKS COMMENT_DETECTED_BANNED_WORD COMMENT_DETECTED_SUSPECT_WORD diff --git a/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx b/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx index cb0c5c738..25c4af8f4 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx @@ -139,7 +139,11 @@ const ModerateCardContainer: FunctionComponent = ({ - Karma + Recent History - Karma + Recent History = ({ + triggered, + timeFrame, + rejectionRate, + submitted, +}) => { + const { scaled, unit } = reduceSeconds(timeFrame, [UNIT.DAYS]); + + return ( + + + + Recent comment history + + + + + Calculated over the last {scaled} {unit} + + + + + + {Math.round(rejectionRate * 100)}% + + + + + Rejected + + + + How is this calculated? + + } + body={ + + + Rejected comments divided by the sum of rejected and + published comments, during the recent comment history time + frame. + + + } + button={({ toggleVisibility, ref }) => ( + + + + )} + /> + + + + + {submitted} + + + + Submitted + + + + + + ); +}; + +export default RecentHistory; diff --git a/src/core/client/admin/components/UserHistoryDrawer/RecentHistoryContainer.tsx b/src/core/client/admin/components/UserHistoryDrawer/RecentHistoryContainer.tsx new file mode 100644 index 000000000..33c5522ad --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/RecentHistoryContainer.tsx @@ -0,0 +1,82 @@ +import React, { FunctionComponent, useMemo } from "react"; + +import { RecentHistoryContainer_settings } from "coral-admin/__generated__/RecentHistoryContainer_settings.graphql"; +import { RecentHistoryContainer_user } from "coral-admin/__generated__/RecentHistoryContainer_user.graphql"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; +import { GQLCOMMENT_STATUS } from "coral-framework/schema"; +import RecentHistory from "./RecentHistory"; + +const PUBLISHED_STATUSES = [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.APPROVED]; + +interface Props { + user: RecentHistoryContainer_user; + settings: RecentHistoryContainer_settings; +} + +const RecentHistoryContainer: FunctionComponent = ({ + user, + settings, +}) => { + const submitted = useMemo( + () => + Object.keys(user.recentCommentHistory.statuses).reduce( + (acc, key: keyof typeof user.recentCommentHistory.statuses) => + user.recentCommentHistory.statuses[key] + acc, + 0 + ), + [user] + ); + const rejectionRate = useMemo(() => { + const published = PUBLISHED_STATUSES.reduce( + (acc, status) => user.recentCommentHistory.statuses[status] + acc, + 0 + ); + const rejected = + user.recentCommentHistory.statuses[GQLCOMMENT_STATUS.REJECTED]; + const total = published + rejected; + if (total === 0) { + return 0; + } + + return rejected / total; + }, [user]); + const triggered = + settings.recentCommentHistory.enabled && + rejectionRate >= settings.recentCommentHistory.triggerRejectionRate; + + return ( + + ); +}; + +const enhanced = withFragmentContainer({ + user: graphql` + fragment RecentHistoryContainer_user on User { + recentCommentHistory { + statuses { + NONE + APPROVED + REJECTED + PREMOD + SYSTEM_WITHHELD + } + } + } + `, + settings: graphql` + fragment RecentHistoryContainer_settings on Settings { + recentCommentHistory { + enabled + timeFrame + triggerRejectionRate + } + } + `, +})(RecentHistoryContainer); + +export default enhanced; diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawer.css b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawer.css new file mode 100644 index 000000000..cde216310 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawer.css @@ -0,0 +1,14 @@ +.root { + position: fixed; + top: 0; + right: 0; + + width: 624px; + height: 100%; + + display: flex; + flex-flow: column nowrap; + flex-direction: column; + + background-color: var(--palette-common-white); +} diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawer.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawer.tsx new file mode 100644 index 000000000..c8806915a --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawer.tsx @@ -0,0 +1,38 @@ +import React, { FunctionComponent } from "react"; + +import { Card, Modal } from "coral-ui/components"; + +import UserHistoryDrawerQuery from "./UserHistoryDrawerQuery"; + +import styles from "./UserHistoryDrawer.css"; + +interface UserHistoryDrawerProps { + open: boolean; + onClose: () => void; + userID?: string; +} + +const UserHistoryDrawer: FunctionComponent = ({ + open, + onClose, + userID, +}) => { + return ( + + {({ firstFocusableRef, lastFocusableRef }) => ( + + {userID && ( + + )} + + )} + + ); +}; + +export default UserHistoryDrawer; diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.css b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.css index 9a86fc42f..2bcc983d0 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.css +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.css @@ -1,14 +1,95 @@ -.root { - position: fixed; - top:0; - right: 0; +.comments { + flex: auto; + overflow: hidden; +} - width: 624px; - height: 100%; +.close { + position: absolute; + width: 40px; + height: 40px; + left: -40px; - display: flex; - flex-flow: column nowrap; - flex-direction: column; + padding: 0px; background-color: var(--palette-common-white); -} \ No newline at end of file + + border-width: 2px; + border-right-width: 0px; + border-style: solid; + border-color: var(--palette-grey-main); + border-radius: 4px 0px 0px 4px; +} + +.username { + font-family: var(--font-family-serif); + font-size: calc(24rem / var(--rem-base)); + font-weight: 500; + font-family: var(--font-family-serif); + line-height: calc(36em / 24); + letter-spacing: calc(0.2em / 24); + margin-bottom: var(--spacing-1); +} + +.userDetail { + margin-bottom: var(--spacing-1); +} + +.userDetailValue { + margin-right: var(--spacing-2); +} + +.icon { + margin-right: var(--spacing-2); +} + +.copy { + border: 1px solid var(--palette-primary-main); + background-color: transparent; + color: var(--palette-primary-main); + padding-top: 2px; + padding-bottom: 2px; + + &:active { + background-color: var(--palette-primary-lightest); + border-color: var(--palette-primary-main); + color: var(--palette-primary-main); + } +} + +hr { + border: 0; + border-bottom: 1px solid var(--palette-divider); + padding-top: 1px; + width: 100%; +} + +.divider { + border-bottom: 1px solid var(--palette-grey-lighter); +} + +.userStatus { + margin-top: var(--spacing-1); + margin-bottom: var(--spacing-1); +} + +.userStatusLabel { + display: inline-block; + + margin-right: var(--spacing-1); + + font-family: var(--font-family-sans-serif); + font-style: normal; + font-weight: 600; + font-size: 16px; + line-height: 14px; +} + +.userStatusChange { + display: inline-block; + border-style: solid; + border-color: var(--palette-grey-lighter); + border-width: 1px; + border-radius: var(--round-corners); + padding-left: var(--spacing-2); + padding-right: var(--spacing-1); +} diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx index cb577e3ae..46c70ee23 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx @@ -1,35 +1,153 @@ +import { Localized } from "fluent-react/compat"; import React, { FunctionComponent } from "react"; -import { Card, Modal } from "coral-ui/components"; +import { UserHistoryDrawerContainer_settings } from "coral-admin/__generated__/UserHistoryDrawerContainer_settings.graphql"; +import { UserHistoryDrawerContainer_user } from "coral-admin/__generated__/UserHistoryDrawerContainer_user.graphql"; +import { UserStatusChangeContainer } from "coral-admin/components/UserStatus"; +import { CopyButton } from "coral-framework/components"; +import { useCoralContext } from "coral-framework/lib/bootstrap"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; +import { Button, Flex, Icon, Typography } from "coral-ui/components"; + +import RecentHistoryContainer from "./RecentHistoryContainer"; +import Tabs from "./Tabs"; +import UserStatusDetailsContainer from "./UserStatusDetailsContainer"; import styles from "./UserHistoryDrawerContainer.css"; -import UserHistoryDrawerQuery from "./UserHistoryDrawerQuery"; -interface UserHistoryDrawerContainerProps { - open: boolean; +interface Props { + user: UserHistoryDrawerContainer_user; + settings: UserHistoryDrawerContainer_settings; onClose: () => void; - userID?: string; } -const UserHistoryDrawerContainer: FunctionComponent< - UserHistoryDrawerContainerProps -> = ({ open, onClose, userID }) => { +const UserHistoryDrawerContainer: FunctionComponent = ({ + settings, + user, + onClose, +}) => { + const { locales } = useCoralContext(); + const formatter = new Intl.DateTimeFormat(locales, { + month: "long", + day: "numeric", + year: "numeric", + }); + return ( - - {({ firstFocusableRef, lastFocusableRef }) => ( - - {userID && ( - - )} - - )} - + <> + + + {user.username} + +
+ +
+ + + + Status: + + + +
+
+ +
+ +
+
+
+ + + + mail_outline + + + + {user.email} + + + + + + + date_range + + + + {formatter.format(new Date(user.createdAt))} + + + + + + people_outline + + + + {user.id} + + + + +
+
+
+ +
+ ); }; -export default UserHistoryDrawerContainer; +const enhanced = withFragmentContainer({ + user: graphql` + fragment UserHistoryDrawerContainer_user on User { + ...UserStatusChangeContainer_user + ...UserStatusDetailsContainer_user + ...RecentHistoryContainer_user + id + username + email + createdAt + } + `, + settings: graphql` + fragment UserHistoryDrawerContainer_settings on Settings { + ...RecentHistoryContainer_settings + ...UserStatusChangeContainer_settings + organization { + name + } + } + `, +})(UserHistoryDrawerContainer); + +export default enhanced; diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.css b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.css index 72f05d139..19541723b 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.css +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.css @@ -1,121 +1,6 @@ .root { - position: fixed; - top:0; - right: 0; - - width: 624px; - height: 100%; - - display: flex; - flex-flow: column nowrap; - flex-direction: column; - - background-color: var(--palette-common-white); -} - -.comments { - flex: auto; - overflow: hidden; -} - -.close { - position: absolute; - width: 40px; - height: 40px; - left: -40px; - - padding: 0px; - - background-color: var(--palette-common-white); - - border-width: 2px; - border-right-width: 0px; - border-style: solid; - border-color: var(--palette-grey-main); - border-radius: 4px 0px 0px 4px; -} - -.username { - font-family: var(--font-family-serif); - font-size: calc(24rem / var(--rem-base)); - font-weight: 500; - font-family: var(--font-family-serif); - line-height: calc(36em / 24); - letter-spacing: calc(0.2em / 24); - margin-bottom: var(--spacing-1); -} - -.userDetails { - margin-bottom: var(--spacing-3); -} - -.userDetail { - margin-bottom: var(--spacing-1); -} - -.userDetailValue { - margin-right: var(--spacing-2); -} - -.icon { - margin-right: var(--spacing-2); -} - -.copy { - border: 1px solid var(--palette-primary-main); - background-color: transparent; - color: var(--palette-primary-main); - padding-top: 2px; - padding-bottom: 2px; - - &:active { - background-color: var(--palette-primary-lightest); - border-color: var(--palette-primary-main); - color: var(--palette-primary-main); - } -} - -.callout { width: 100%; font-family: var(--font-family-sans-serif); align-content: center; text-align: center; } - -hr { - border: 0; - border-bottom: 1px solid var(--palette-divider); - padding-top: 1px; - width: 100%; -} - -.divider { - border-bottom: 1px solid var(--palette-grey-lighter); -} - -.userStatus { - margin-top: var(--spacing-1); - margin-bottom: var(--spacing-1); -} - -.userStatusLabel { - display: inline-block; - - margin-right: var(--spacing-1); - - font-family: var(--font-family-sans-serif); - font-style: normal; - font-weight: 600; - font-size: 16px; - line-height: 14px; -} - -.userStatusChange { - display: inline-block; - border-style: solid; - border-color: var(--palette-grey-lighter); - border-width: 1px; - border-radius: var(--round-corners); - padding-left: var(--spacing-2); - padding-right: var(--spacing-1); -} \ No newline at end of file diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx index 31f0615fa..129df3518 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerQuery.tsx @@ -2,23 +2,11 @@ import { Localized } from "fluent-react/compat"; import React, { FunctionComponent } from "react"; import { ReadyState } from "react-relay"; -import { UserStatusChangeContainer } from "coral-admin/components/UserStatus"; -import { CopyButton } from "coral-framework/components"; -import { useCoralContext } from "coral-framework/lib/bootstrap"; -import { graphql, QueryRenderer } from "coral-framework/lib/relay"; - import { UserHistoryDrawerQuery as QueryTypes } from "coral-admin/__generated__/UserHistoryDrawerQuery.graphql"; -import { - Button, - CallOut, - Flex, - Icon, - Spinner, - Typography, -} from "coral-ui/components"; +import { graphql, QueryRenderer } from "coral-framework/lib/relay"; +import { CallOut, Spinner } from "coral-ui/components"; -import Tabs from "./Tabs"; -import UserStatusDetailsContainer from "./UserStatusDetailsContainer"; +import UserHistoryDrawerContainer from "./UserHistoryDrawerContainer"; import styles from "./UserHistoryDrawerQuery.css"; @@ -33,36 +21,21 @@ const UserHistoryDrawerQuery: FunctionComponent = ({ userID, onClose, }) => { - const { locales } = useCoralContext(); - const formatter = new Intl.DateTimeFormat(locales, { - month: "long", - day: "numeric", - year: "numeric", - }); - return ( query={graphql` query UserHistoryDrawerQuery($userID: ID!) { user(id: $userID) { - id - username - email - createdAt - ...UserStatusDetailsContainer_user - ...UserStatusChangeContainer_user + ...UserHistoryDrawerContainer_user } settings { - organization { - name - } - ...UserStatusChangeContainer_settings + ...UserHistoryDrawerContainer_settings } } `} variables={{ userID }} cacheConfig={{ force: true }} - render={({ error, props }: ReadyState) => { + render={({ props }: ReadyState) => { if (!props) { return (
@@ -73,9 +46,9 @@ const UserHistoryDrawerQuery: FunctionComponent = ({ if (!props.user) { return ( -
+
- + User not found. @@ -83,105 +56,12 @@ const UserHistoryDrawerQuery: FunctionComponent = ({ ); } - const { user, settings } = props; - return ( - <> - - - {user.username} - -
- -
- - - - Status: - - - -
-
- -
- -
-
-
- - - - mail_outline - - - - {user.email} - - - - - - - date_range - - - - {formatter.format(new Date(user.createdAt))} - - - - - - people_outline - - - - {user.id} - - - -
-
-
- -
- + ); }} /> diff --git a/src/core/client/admin/components/UserHistoryDrawer/index.ts b/src/core/client/admin/components/UserHistoryDrawer/index.ts new file mode 100644 index 000000000..df71edd90 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/index.ts @@ -0,0 +1 @@ +export { default } from "./UserHistoryDrawer"; diff --git a/src/core/client/admin/mutations/ApproveCommentMutation.ts b/src/core/client/admin/mutations/ApproveCommentMutation.ts index 3bdf2d6b7..c019631ff 100644 --- a/src/core/client/admin/mutations/ApproveCommentMutation.ts +++ b/src/core/client/admin/mutations/ApproveCommentMutation.ts @@ -27,6 +27,18 @@ const ApproveCommentMutation = createMutation( comment { id status + author { + id + recentCommentHistory { + statuses { + NONE + APPROVED + REJECTED + PREMOD + SYSTEM_WITHHELD + } + } + } statusHistory(first: 1) { edges { node { diff --git a/src/core/client/admin/mutations/RejectCommentMutation.ts b/src/core/client/admin/mutations/RejectCommentMutation.ts index 2105aa27b..c78efa270 100644 --- a/src/core/client/admin/mutations/RejectCommentMutation.ts +++ b/src/core/client/admin/mutations/RejectCommentMutation.ts @@ -27,6 +27,18 @@ const RejectCommentMutation = createMutation( comment { id status + author { + id + recentCommentHistory { + statuses { + NONE + APPROVED + REJECTED + PREMOD + SYSTEM_WITHHELD + } + } + } statusHistory(first: 1) { edges { node { diff --git a/src/core/client/admin/routes/Community/UserTable.tsx b/src/core/client/admin/routes/Community/UserTable.tsx index c0749d627..e18cc5133 100644 --- a/src/core/client/admin/routes/Community/UserTable.tsx +++ b/src/core/client/admin/routes/Community/UserTable.tsx @@ -4,7 +4,7 @@ import React, { FunctionComponent, useCallback, useState } from "react"; import { PropTypesOf } from "coral-framework/types"; import AutoLoadMore from "coral-admin/components/AutoLoadMore"; -import UserHistoryDrawerContainer from "coral-admin/components/UserHistoryDrawer/UserHistoryDrawerContainer"; +import UserHistoryDrawer from "coral-admin/components/UserHistoryDrawer"; import { Table, TableBody, @@ -108,7 +108,7 @@ const UserTable: FunctionComponent = ({ /> )} - ["settings"] & PropTypesOf["settings"] & - PropTypesOf["settings"]; + PropTypesOf["settings"] & + PropTypesOf["settings"]; onInitValues: (values: any) => void; } @@ -21,6 +23,11 @@ const ModerationConfig: FunctionComponent = ({ onInitValues, }) => ( + ({ ...AkismetConfigContainer_settings ...PerspectiveConfigContainer_settings ...PreModerationConfigContainer_settings + ...RecentCommentHistoryConfigContainer_settings } `, })(ModerationConfigContainer); diff --git a/src/core/client/admin/routes/Configure/sections/Moderation/PerspectiveConfig.css b/src/core/client/admin/routes/Configure/sections/Moderation/PerspectiveConfig.css index 54cc0f1d0..3648e55cb 100644 --- a/src/core/client/admin/routes/Configure/sections/Moderation/PerspectiveConfig.css +++ b/src/core/client/admin/routes/Configure/sections/Moderation/PerspectiveConfig.css @@ -1,3 +1,3 @@ .thresholdTextField { - width: 60px; + width: calc(6 * var(--mini-unit)); } diff --git a/src/core/client/admin/routes/Configure/sections/Moderation/RecentCommentHistoryConfig.css b/src/core/client/admin/routes/Configure/sections/Moderation/RecentCommentHistoryConfig.css new file mode 100644 index 000000000..3648e55cb --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Moderation/RecentCommentHistoryConfig.css @@ -0,0 +1,3 @@ +.thresholdTextField { + width: calc(6 * var(--mini-unit)); +} diff --git a/src/core/client/admin/routes/Configure/sections/Moderation/RecentCommentHistoryConfig.tsx b/src/core/client/admin/routes/Configure/sections/Moderation/RecentCommentHistoryConfig.tsx new file mode 100644 index 000000000..1cf8a16be --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Moderation/RecentCommentHistoryConfig.tsx @@ -0,0 +1,133 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; +import { Field } from "react-final-form"; + +import { DURATION_UNIT, DurationField } from "coral-framework/components"; +import { + formatPercentage, + parsePercentage, + ValidationMessage, +} from "coral-framework/lib/form"; +import { + composeValidators, + required, + validatePercentage, + validateWholeNumberGreaterThan, +} from "coral-framework/lib/validation"; +import { + FieldSet, + FormField, + HorizontalGutter, + InputDescription, + InputLabel, + TextField, + Typography, +} from "coral-ui/components"; + +import Header from "../../Header"; +import OnOffField from "../../OnOffField"; + +import styles from "./RecentCommentHistoryConfig.css"; + +interface Props { + disabled: boolean; +} + +const RecentCommentHistoryConfig: FunctionComponent = ({ disabled }) => { + return ( + }> + +
Recent comment history
+
+ }> + + + Recent comment history timeframe + + + + + Time period over which a commenter's rejection rate is calcualted + and submitted comments are counted. + + + + {({ input, meta }) => ( + <> + + + + )} + + + }> + + + Recent comment history filter + + + } + > + + Prevents repeat offenders from publishing comments without approval. + After a commenter's rejection rate rises above the defined threshold + below, their next submitted comments are{" "} + sent to Pending for moderator approval. The filter + is removed when their rejection rate falls below the threshold. + + + + + + + Rejection rate threshold + + + + Calculated by the number of rejected comments divided by the sum of + a commenter’s rejected and published comments, over the recent + comment history timeframe (does not include comments pending for + toxicity, spam or pre-moderation.) + + + + {({ input, meta }) => ( + <> + %} + textAlignCenter + {...input} + /> + + + )} + + +
+ ); +}; + +export default RecentCommentHistoryConfig; diff --git a/src/core/client/admin/routes/Configure/sections/Moderation/RecentCommentHistoryConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Moderation/RecentCommentHistoryConfigContainer.tsx new file mode 100644 index 000000000..a136ee232 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Moderation/RecentCommentHistoryConfigContainer.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { graphql } from "react-relay"; + +import { RecentCommentHistoryConfigContainer_settings as SettingsData } from "coral-admin/__generated__/RecentCommentHistoryConfigContainer_settings.graphql"; +import { withFragmentContainer } from "coral-framework/lib/relay"; + +import RecentCommentHistoryConfig from "./RecentCommentHistoryConfig"; + +interface Props { + settings: SettingsData; + onInitValues: (values: SettingsData) => void; + disabled: boolean; +} + +class RecentCommentHistoryConfigContainer extends React.Component { + constructor(props: Props) { + super(props); + props.onInitValues(props.settings); + } + + public render() { + const { disabled } = this.props; + return ; + } +} + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment RecentCommentHistoryConfigContainer_settings on Settings { + recentCommentHistory { + enabled + timeFrame + triggerRejectionRate + } + } + `, +})(RecentCommentHistoryConfigContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Moderation/__snapshots__/ModerationConfig.spec.tsx.snap b/src/core/client/admin/routes/Configure/sections/Moderation/__snapshots__/ModerationConfig.spec.tsx.snap index 7bd009582..4ea3b8256 100644 --- a/src/core/client/admin/routes/Configure/sections/Moderation/__snapshots__/ModerationConfig.spec.tsx.snap +++ b/src/core/client/admin/routes/Configure/sections/Moderation/__snapshots__/ModerationConfig.spec.tsx.snap @@ -5,6 +5,11 @@ exports[`renders correctly 1`] = ` data-testid="configure-moderationContainer" size="double" > + = ({ )} {comments.length === 0 && emptyElement} - - - { routeConfig, renderReady: ({ elements }) => (
- { diff --git a/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap index 996a668f8..33caff810 100644 --- a/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap @@ -116,6 +116,178 @@ exports[`renders configure moderation 1`] = ` className="Box-root HorizontalGutter-root HorizontalGutter-double" data-testid="configure-moderationContainer" > +
+ + Recent comment history + +
+ + Recent comment history timeframe + +

+ Time period over which a commenter's rejection rate is calcualted +and submitted comments are counted. +

+
+
+ +
+

+ Days +

+
+
+
+
+
+ + Recent comment history filter + +

+ Prevents repeat offenders from publishing comments without approval. +After a commenter's rejection rate rises above the defined threshold +below, their next submitted comments are + + sent to Pending for +moderator approval. + + The filter is removed when their rejection rate +falls below the threshold. +

+
+
+ + +
+
+ + +
+
+
+
+ +

+ Calculated by the number of rejected comments divided by the sum of +a commenter’s rejected and published comments, over the recent +comment history timeframe (does not include comments pending for +toxicity, spam or pre-moderation.) +

+
+ +
+

+ % +

+
+
+
+
diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index b5e5a0d91..042bf9518 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -61,6 +61,14 @@ export const settings = createFixture({ url: "https://test.com/", contactEmail: "coral@test.com", }, + recentCommentHistory: { + enabled: false, + // 7 days in seconds. + timeFrame: 604800, + // Rejection rate defaulting to 30%, once exceeded, comments will be + // pre-moderated. + triggerRejectionRate: 0.3, + }, integrations: { akismet: { enabled: false, @@ -292,6 +300,16 @@ export const baseUser = createFixture({ }, }); +const recentCommentHistory = { + statuses: { + APPROVED: 0, + REJECTED: 0, + NONE: 0, + PREMOD: 0, + SYSTEM_WITHHELD: 0, + }, +}; + export const users = { admins: createFixtures( [ @@ -337,6 +355,7 @@ export const users = { email: "isabelle@test.com", role: GQLUSER_ROLE.COMMENTER, ignoreable: true, + recentCommentHistory, }, { id: "user-commenter-1", @@ -344,6 +363,7 @@ export const users = { email: "ngoc@test.com", role: GQLUSER_ROLE.COMMENTER, ignoreable: true, + recentCommentHistory, }, { id: "user-commenter-2", @@ -351,6 +371,7 @@ export const users = { email: "max@test.com", role: GQLUSER_ROLE.COMMENTER, ignoreable: true, + recentCommentHistory, }, ], baseUser @@ -458,7 +479,7 @@ export const baseComment = createFixture({ reasons: { COMMENT_DETECTED_TOXIC: 0, COMMENT_DETECTED_SPAM: 0, - COMMENT_DETECTED_TRUST: 0, + COMMENT_DETECTED_RECENT_HISTORY: 0, COMMENT_DETECTED_LINKS: 0, COMMENT_DETECTED_BANNED_WORD: 0, COMMENT_DETECTED_SUSPECT_WORD: 0, diff --git a/src/core/client/admin/test/moderate/singleComment.spec.tsx b/src/core/client/admin/test/moderate/singleComment.spec.tsx index 3f9457793..c1d057c13 100644 --- a/src/core/client/admin/test/moderate/singleComment.spec.tsx +++ b/src/core/client/admin/test/moderate/singleComment.spec.tsx @@ -149,6 +149,7 @@ it("rejects single comment", async () => { comment: { id: comment.id, status: GQLCOMMENT_STATUS.REJECTED, + author: comment.author, statusHistory: { edges: [ { diff --git a/src/core/client/framework/components/DurationField.tsx b/src/core/client/framework/components/DurationField.tsx index 046e03ecb..584848df8 100644 --- a/src/core/client/framework/components/DurationField.tsx +++ b/src/core/client/framework/components/DurationField.tsx @@ -2,7 +2,13 @@ import { Localized } from "fluent-react/compat"; import React, { ChangeEvent, Component } from "react"; import { UNIT } from "coral-framework/lib/i18n"; -import { Flex, Option, SelectField, TextField } from "coral-ui/components"; +import { + Flex, + Option, + SelectField, + TextField, + Typography, +} from "coral-ui/components"; import styles from "./DurationField.css"; @@ -12,58 +18,12 @@ import styles from "./DurationField.css"; */ export const DURATION_UNIT = UNIT; -type UnitElementCallback = ( - currentValue: UNIT, - unitValue: string -) => React.ReactElement; - -// This is used to render the Option elements to inlcude in the select field. -const unitElementMap: Record = { - [UNIT.SECONDS]: (currentValue, unitValue) => ( - - - - ), - [UNIT.MINUTES]: (currentValue, unitValue) => ( - - - - ), - [UNIT.HOURS]: (currentValue, unitValue) => ( - - - - ), - [UNIT.DAYS]: (currentValue, unitValue) => ( - - - - ), - [UNIT.WEEKS]: (currentValue, unitValue) => ( - - - - ), +const DURATION_UNIT_MAP = { + [DURATION_UNIT.SECONDS]: "second", + [DURATION_UNIT.MINUTES]: "minute", + [DURATION_UNIT.HOURS]: "hour", + [DURATION_UNIT.DAYS]: "day", + [DURATION_UNIT.WEEKS]: "week", }; interface Props { @@ -86,7 +46,7 @@ interface State { * Element callbacks to generate the rendered * Option element for the select field */ - elementCallbacks: ReadonlyArray; + elementCallbacks: ReadonlyArray; } /** @@ -117,7 +77,7 @@ function valueToState(value: string, units: ReadonlyArray, unit?: UNIT) { unit, value, units, - elementCallbacks: units.map(k => unitElementMap[k]), + elementCallbacks: units.map(k => DURATION_UNIT_MAP[k]), }; } @@ -177,6 +137,25 @@ class DurationField extends Component { public render() { const { disabled, name } = this.props; + + if (!this.state.elementCallbacks) { + return null; + } + + let adornment: React.ReactNode = null; + if (this.state.elementCallbacks.length === 1) { + const unit = this.state.elementCallbacks[0]; + adornment = ( + + {unit} + + ); + } + return ( { autoCorrect="off" autoCapitalize="off" spellCheck={false} + adornment={adornment} textAlignCenter aria-label="value" /> - - {this.state.elementCallbacks!.map((cb, i) => - cb(parseInt(this.state.value, 10), this.state.units[i].toString()) - )} - + {!adornment && ( + + {this.state.elementCallbacks.map((unit, i) => { + const value = this.state.units[i]; + return ( + + + + ); + })} + + )} ); } diff --git a/src/core/client/framework/components/__snapshots__/DurationField.spec.tsx.snap b/src/core/client/framework/components/__snapshots__/DurationField.spec.tsx.snap index bd03259d4..3fe60e34b 100644 --- a/src/core/client/framework/components/__snapshots__/DurationField.spec.tsx.snap +++ b/src/core/client/framework/components/__snapshots__/DurationField.spec.tsx.snap @@ -5,6 +5,7 @@ exports[`accepts invalid input 1`] = ` itemGutter={true} > @@ -68,6 +72,7 @@ exports[`renders correctly with default units 1`] = ` itemGutter={true} > @@ -131,6 +139,7 @@ exports[`renders correctly with specified units 1`] = ` itemGutter={true} > @@ -184,6 +195,7 @@ exports[`use best matching unit 1`] = ` itemGutter={true} > @@ -247,6 +262,7 @@ exports[`use initial unit if 0 1`] = ` itemGutter={true} > diff --git a/src/core/client/framework/testHelpers/denormalize.ts b/src/core/client/framework/testHelpers/denormalize.ts index 2e4e87595..78415d064 100644 --- a/src/core/client/framework/testHelpers/denormalize.ts +++ b/src/core/client/framework/testHelpers/denormalize.ts @@ -74,7 +74,7 @@ export function denormalizeStory(story: Fixture) { comments: { edges: commentNodes, pageInfo: commentsPageInfo }, commentCounts: { ...story.commentCounts, - totalVisible: commentNodes.length, + totalPublished: commentNodes.length, tags: { ...(story.commentCounts && story.commentCounts.tags), FEATURED: featuredCommentsCount, diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index a847a08fe..e84ebc7c3 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -11,6 +11,7 @@ import { CommentContainer_comment as CommentData } from "coral-stream/__generate import { CommentContainer_settings as SettingsData } from "coral-stream/__generated__/CommentContainer_settings.graphql"; import { CommentContainer_story as StoryData } from "coral-stream/__generated__/CommentContainer_story.graphql"; import { CommentContainer_viewer as ViewerData } from "coral-stream/__generated__/CommentContainer_viewer.graphql"; +import CLASSES from "coral-stream/classes"; import { SetCommentIDMutation, ShowAuthPopupMutation, @@ -19,8 +20,7 @@ import { } from "coral-stream/mutations"; import { Button, Flex, HorizontalGutter, Tag } from "coral-ui/components"; -import CLASSES from "coral-stream/classes"; -import { isCommentVisible } from "../helpers"; +import { isPublished } from "../helpers"; import ButtonsBar from "./ButtonsBar"; import EditCommentFormContainer from "./EditCommentForm"; import IndentedComment from "./IndentedComment"; @@ -205,15 +205,15 @@ export class CommentContainer extends Component {
); } - // Comment is not visible after viewer rejected it. + // Comment is not published after viewer rejected it. if ( comment.lastViewerAction === "REJECT" && comment.status === "REJECTED" ) { return ; } - // Comment is not visible after edit, so don't render it anymore. - if (comment.lastViewerAction === "EDIT" && !isCommentVisible(comment)) { + // Comment is not published after edit, so don't render it anymore. + if (comment.lastViewerAction === "EDIT" && !isPublished(comment.status)) { return null; } return ( diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts index b91c26e44..b4bbdf60b 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts @@ -22,7 +22,7 @@ import { CreateCommentReplyMutation as MutationTypes } from "coral-stream/__gene import { pick } from "lodash"; import { incrementStoryCommentCounts, - isVisible, + isPublished, prependCommentEdgeToProfile, } from "../../helpers"; @@ -40,8 +40,8 @@ function sharedUpdater( .getLinkedRecord("edge")!; const status = commentEdge.getLinkedRecord("node")!.getValue("status"); - // If comment is not visible, we don't need to add it. - if (!isVisible(status)) { + // If comment is not published, we don't need to add it. + if (!isPublished(status)) { return; } diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx index 9028524b9..555aeeea8 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx @@ -17,7 +17,7 @@ import { ReplyListContainer1_viewer as ViewerData } from "coral-stream/__generat import { ReplyListContainer1PaginationQueryVariables } from "coral-stream/__generated__/ReplyListContainer1PaginationQuery.graphql"; import { ReplyListContainer5_comment as Comment5Data } from "coral-stream/__generated__/ReplyListContainer5_comment.graphql"; -import { isCommentVisible } from "../helpers"; +import { isPublished } from "../helpers"; import CommentReplyCreatedSubscription from "./CommentReplyCreatedSubscription"; import LocalReplyListContainer from "./LocalReplyListContainer"; import ReplyList from "./ReplyList"; @@ -105,7 +105,7 @@ export const ReplyListContainer: React.FunctionComponent = props => { } const comments = // Comment is not visible after a viewer action, so don't render it anymore. - props.comment.lastViewerAction && !isCommentVisible(props.comment) + props.comment.lastViewerAction && !isPublished(props.comment.status) ? [] : props.comment.replies.edges.map(edge => ({ ...edge.node, diff --git a/src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.tsx b/src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.tsx index 5599dcb92..26bbd005e 100644 --- a/src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.tsx @@ -1,17 +1,7 @@ -import cn from "classnames"; +import { Localized } from "fluent-react/compat"; import React, { FunctionComponent } from "react"; -import { - BaseButton, - Box, - ClickOutside, - Icon, - Popover, - Typography, -} from "coral-ui/components"; - -import { Localized } from "fluent-react/compat"; -import styles from "./FeaturedCommentTooltip.css"; +import { Tooltip, TooltipButton } from "coral-ui/components"; interface Props { className?: string; @@ -20,60 +10,33 @@ interface Props { export const FeaturedCommentTooltip: FunctionComponent = props => { return ( - ( - - { - // Don't propagate click events when clicking inside of popover to - // avoid accidently activating the featured comments tab. - evt.stopPropagation(); - }} - > - - - How is a comment featured? - - - - - Comments are hand selected by our team as worth reading. - - - - - )} - placement={"bottom"} - dark - > - {({ toggleVisibility, ref }) => ( + className={props.className} + title={ + + How is a comment featured? + + } + body={ + + Comments are hand selected by our team as worth reading. + + } + button={({ toggleVisibility, ref }) => ( - { - evt.stopPropagation(); - toggleVisibility(); - }} + - info - + /> )} - + /> ); }; diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts index cc4e951af..f80ab9088 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts @@ -21,7 +21,7 @@ import { CreateCommentMutation as MutationTypes } from "coral-stream/__generated import { incrementStoryCommentCounts, - isVisible, + isPublished, prependCommentEdgeToProfile, } from "../../helpers"; @@ -38,7 +38,7 @@ function sharedUpdater( const status = commentEdge.getLinkedRecord("node")!.getValue("status"); // If comment is not visible, we don't need to add it. - if (!isVisible(status)) { + if (!isPublished(status)) { return; } diff --git a/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx index 179913563..9e572aee7 100644 --- a/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx @@ -81,7 +81,7 @@ export const StreamContainer: FunctionComponent = props => { props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED) ); - const allCommentsCount = props.story.commentCounts.totalVisible; + const allCommentsCount = props.story.commentCounts.totalPublished; const featuredCommentsCount = props.story.commentCounts.tags.FEATURED; useEffect(() => { @@ -191,7 +191,7 @@ const enhanced = withFragmentContainer({ ...CreateCommentReplyMutation_story ...CreateCommentMutation_story commentCounts { - totalVisible + totalPublished tags { FEATURED } diff --git a/src/core/client/stream/tabs/Comments/helpers/incrementStoryCommentCounts.ts b/src/core/client/stream/tabs/Comments/helpers/incrementStoryCommentCounts.ts index d25c2c603..49011670e 100644 --- a/src/core/client/stream/tabs/Comments/helpers/incrementStoryCommentCounts.ts +++ b/src/core/client/stream/tabs/Comments/helpers/incrementStoryCommentCounts.ts @@ -10,8 +10,8 @@ export default function incrementStoryCommentCounts( const record = story.getLinkedRecord("commentCounts"); if (record) { // TODO: when we have moderation, we'll need to be careful here. - const currentCount = record.getValue("totalVisible"); - record.setValue(currentCount + 1, "totalVisible"); + const currentCount = record.getValue("totalPublished"); + record.setValue(currentCount + 1, "totalPublished"); } } } diff --git a/src/core/client/stream/tabs/Comments/helpers/index.ts b/src/core/client/stream/tabs/Comments/helpers/index.ts index d3a0357f1..20ff62cb3 100644 --- a/src/core/client/stream/tabs/Comments/helpers/index.ts +++ b/src/core/client/stream/tabs/Comments/helpers/index.ts @@ -8,13 +8,12 @@ export { } from "./shouldTriggerSettingsRefresh"; export { default as getHTMLText } from "./getHTMLText"; export { default as getSubmitStatus, SubmitStatus } from "./getSubmitStatus"; -export { default as isCommentVisible } from "./isCommentVisible"; export { default as incrementStoryCommentCounts, } from "./incrementStoryCommentCounts"; export { default as isInReview } from "./isInReview"; export { default as isRejected } from "./isRejected"; -export { default as isVisible } from "./isVisible"; +export { default as isPublished } from "./isPublished"; export { default as prependCommentEdgeToProfile, } from "./prependCommentEdgeToProfile"; diff --git a/src/core/client/stream/tabs/Comments/helpers/isCommentVisible.ts b/src/core/client/stream/tabs/Comments/helpers/isCommentVisible.ts deleted file mode 100644 index 049168e69..000000000 --- a/src/core/client/stream/tabs/Comments/helpers/isCommentVisible.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { COMMENT_STATUS } from "coral-stream/__generated__/CreateCommentMutation.graphql"; - -const VisibleStatus = ["APPROVED", "NONE"]; - -export default function isCommentVisible(comment: { - status: COMMENT_STATUS; -}): boolean { - return VisibleStatus.includes(comment.status); -} diff --git a/src/core/client/stream/tabs/Comments/helpers/isPublished.ts b/src/core/client/stream/tabs/Comments/helpers/isPublished.ts new file mode 100644 index 000000000..fccd21b8f --- /dev/null +++ b/src/core/client/stream/tabs/Comments/helpers/isPublished.ts @@ -0,0 +1,7 @@ +import { GQLCOMMENT_STATUS } from "coral-framework/schema"; + +const PUBLISHED_STATUSES = [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.APPROVED]; + +export default function isPublished(status: any) { + return PUBLISHED_STATUSES.includes(status); +} diff --git a/src/core/client/stream/tabs/Comments/helpers/isVisible.ts b/src/core/client/stream/tabs/Comments/helpers/isVisible.ts deleted file mode 100644 index 9e11507e1..000000000 --- a/src/core/client/stream/tabs/Comments/helpers/isVisible.ts +++ /dev/null @@ -1,6 +0,0 @@ -// TODO: use generated schema types. -const visibleStatuses = ["NONE", "APPROVED"]; - -export default function isVisible(status: any) { - return visibleStatuses.includes(status); -} diff --git a/src/core/client/stream/test/comments/stream/editComment.spec.tsx b/src/core/client/stream/test/comments/stream/editComment.spec.tsx index 3df911cd4..c8acc98ee 100644 --- a/src/core/client/stream/test/comments/stream/editComment.spec.tsx +++ b/src/core/client/stream/test/comments/stream/editComment.spec.tsx @@ -117,7 +117,7 @@ it("edit a comment", async () => { expect(within(comment).toJSON()).toMatchSnapshot("server response"); }); -it("edit a comment and handle non-visible comment state", async () => { +it("edit a comment and handle non-published comment state", async () => { const testRenderer = createTestRenderer({}, { status: "SYSTEM_WITHHELD" }); const comment = await waitForElement(() => diff --git a/src/core/client/stream/test/comments/stream/postComment.spec.tsx b/src/core/client/stream/test/comments/stream/postComment.spec.tsx index 44e1f6862..538860565 100644 --- a/src/core/client/stream/test/comments/stream/postComment.spec.tsx +++ b/src/core/client/stream/test/comments/stream/postComment.spec.tsx @@ -118,7 +118,7 @@ it("post a comment", async () => { ); }); -const postACommentAndHandleNonVisibleComment = async ( +const postACommentAndHandleNonPublishedComment = async ( dismiss: (form: ReactTestInstance, rte: ReactTestInstance) => void ) => { const { rte, form } = await createTestRenderer({ @@ -161,14 +161,14 @@ const postACommentAndHandleNonVisibleComment = async ( }; it("post a comment and handle non-visible comment state (dismiss by click)", async () => - await postACommentAndHandleNonVisibleComment((form, rte) => { + await postACommentAndHandleNonPublishedComment((form, rte) => { within(form) .getByText("Dismiss") .props.onClick(); })); it("post a comment and handle non-visible comment state (dismiss by typing)", async () => - await postACommentAndHandleNonVisibleComment((form, rte) => { + await postACommentAndHandleNonPublishedComment((form, rte) => { rte.props.onChange({ html: "Typing..." }); })); diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 55c58248f..deca9bf4a 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -375,7 +375,7 @@ export const baseStory = createFixture({ }, }, commentCounts: { - totalVisible: 0, + totalPublished: 0, tags: { FEATURED: 0, }, diff --git a/src/core/client/test/helpers/fixture.ts b/src/core/client/test/helpers/fixture.ts index 0e659c393..fc7c9f1bd 100644 --- a/src/core/client/test/helpers/fixture.ts +++ b/src/core/client/test/helpers/fixture.ts @@ -91,7 +91,7 @@ export function createComment(author?: GQLUser) { reasons: { COMMENT_DETECTED_TOXIC: 0, COMMENT_DETECTED_SPAM: 0, - COMMENT_DETECTED_TRUST: 0, + COMMENT_DETECTED_RECENT_HISTORY: 0, COMMENT_DETECTED_LINKS: 0, COMMENT_DETECTED_BANNED_WORD: 0, COMMENT_DETECTED_SUSPECT_WORD: 0, @@ -139,7 +139,7 @@ export function createComment(author?: GQLUser) { COMMENT_REPORTED_OTHER: 0, COMMENT_DETECTED_TOXIC: 0, COMMENT_DETECTED_SPAM: 0, - COMMENT_DETECTED_TRUST: 0, + COMMENT_DETECTED_RECENT_HISTORY: 0, COMMENT_DETECTED_LINKS: 0, COMMENT_DETECTED_BANNED_WORD: 0, COMMENT_DETECTED_SUSPECT_WORD: 0, @@ -210,7 +210,7 @@ export function createStory() { }, isClosed: false, commentCounts: { - totalVisible: 0, + totalPublished: 0, tags: { FEATURED: 0, }, diff --git a/src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.css b/src/core/client/ui/components/Tooltip/Tooltip.css similarity index 62% rename from src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.css rename to src/core/client/ui/components/Tooltip/Tooltip.css index 0fc736a27..b48b12e0e 100644 --- a/src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.css +++ b/src/core/client/ui/components/Tooltip/Tooltip.css @@ -11,9 +11,3 @@ .title { line-height: 1; } - -.button { - line-height: 0; - color: var(--palette-grey-dark); - padding: 6px; -} diff --git a/src/core/client/ui/components/Tooltip/Tooltip.tsx b/src/core/client/ui/components/Tooltip/Tooltip.tsx new file mode 100644 index 000000000..a1b49ecba --- /dev/null +++ b/src/core/client/ui/components/Tooltip/Tooltip.tsx @@ -0,0 +1,62 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import { Box, ClickOutside, Popover, Typography } from "coral-ui/components"; +import { PropTypesOf } from "coral-ui/types"; + +import styles from "./Tooltip.css"; + +interface Props { + id: string; + className?: string; + title: React.ReactNode; + body: React.ReactNode; + button: PropTypesOf["children"]; +} + +export const Tooltip: FunctionComponent = ({ + id, + className, + title, + body, + button, +}) => { + return ( + ( + + { + // Don't propagate click events when clicking inside of popover to + // avoid accidentally activating other components. + evt.stopPropagation(); + }} + > + + {title} + + + + {body} + + + + )} + placement={"bottom"} + dark + > + {props => button(props)} + + ); +}; + +export default Tooltip; diff --git a/src/core/client/ui/components/Tooltip/TooltipButton.css b/src/core/client/ui/components/Tooltip/TooltipButton.css new file mode 100644 index 000000000..e2777894c --- /dev/null +++ b/src/core/client/ui/components/Tooltip/TooltipButton.css @@ -0,0 +1,5 @@ +.button { + line-height: 0; + color: var(--palette-grey-dark); + padding: 6px; +} diff --git a/src/core/client/ui/components/Tooltip/TooltipButton.tsx b/src/core/client/ui/components/Tooltip/TooltipButton.tsx new file mode 100644 index 000000000..1e674e401 --- /dev/null +++ b/src/core/client/ui/components/Tooltip/TooltipButton.tsx @@ -0,0 +1,39 @@ +import cn from "classnames"; +import React, { ButtonHTMLAttributes, FunctionComponent, Ref } from "react"; + +import { BaseButton, Icon } from "coral-ui/components"; +import { withForwardRef } from "coral-ui/hocs"; +import { PropTypesOf } from "coral-ui/types"; + +import styles from "./TooltipButton.css"; + +interface Props extends ButtonHTMLAttributes { + active?: boolean; + className?: string; + toggleVisibility: () => void; + + /** Internal: Forwarded Ref */ + forwardRef?: Ref; +} + +const TooltipButton: FunctionComponent = ({ + active, + className, + toggleVisibility, + forwardRef, +}) => ( + { + evt.stopPropagation(); + toggleVisibility(); + }} + ref={forwardRef} + > + info + +); + +const enhanced = withForwardRef(TooltipButton); +export type TooltipButtonProps = PropTypesOf; +export default enhanced; diff --git a/src/core/client/ui/components/Tooltip/index.ts b/src/core/client/ui/components/Tooltip/index.ts new file mode 100644 index 000000000..cb15bc14c --- /dev/null +++ b/src/core/client/ui/components/Tooltip/index.ts @@ -0,0 +1,2 @@ +export { default, default as Tooltip } from "./Tooltip"; +export { default as TooltipButton } from "./TooltipButton"; diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts index 62eac0d0e..9b808acab 100644 --- a/src/core/client/ui/components/index.ts +++ b/src/core/client/ui/components/index.ts @@ -31,6 +31,7 @@ export { default as CheckBox } from "./CheckBox"; export { default as RadioButton } from "./RadioButton"; export { default as Delay } from "./Delay"; export { default as Box } from "./Box"; +export { default as Tooltip, TooltipButton } from "./Tooltip"; export { AppBar, Begin as AppBarBegin, diff --git a/src/core/server/graph/common/scalars/cursor.ts b/src/core/server/graph/common/scalars/cursor.ts index 72c07925b..a203d6f4c 100644 --- a/src/core/server/graph/common/scalars/cursor.ts +++ b/src/core/server/graph/common/scalars/cursor.ts @@ -2,7 +2,7 @@ import { GraphQLScalarType } from "graphql"; import { Kind } from "graphql/language"; import { DateTime } from "luxon"; -import { Cursor } from "coral-server/models/helpers/connection"; +import { Cursor } from "coral-server/models/helpers"; function parseIntegerCursor(value: string): number | null { try { diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 1c2a60f1d..92f8e78ac 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -5,6 +5,7 @@ import { createPublisher, Publisher, } from "coral-server/graph/tenant/subscriptions/publisher"; +import logger from "coral-server/logger"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; @@ -37,10 +38,18 @@ export default class TenantContext extends CommonContext { public readonly loaders: ReturnType; public readonly mutators: ReturnType; - constructor(options: TenantContextOptions) { - super({ ...options, lang: options.tenant.locale }); + constructor({ + tenant, + logger: log = logger, + ...options + }: TenantContextOptions) { + super({ + ...options, + lang: tenant.locale, + logger: logger.child({ tenantID: tenant.id }), + }); - this.tenant = options.tenant; + this.tenant = tenant; this.tenantCache = options.tenantCache; this.scraperQueue = options.scraperQueue; this.mailerQueue = options.mailerQueue; diff --git a/src/core/server/graph/tenant/loaders/Comments.ts b/src/core/server/graph/tenant/loaders/Comments.ts index 9612cecfa..3f29c6989 100644 --- a/src/core/server/graph/tenant/loaders/Comments.ts +++ b/src/core/server/graph/tenant/loaders/Comments.ts @@ -1,5 +1,6 @@ import DataLoader from "dataloader"; import { isNil, omitBy } from "lodash"; +import { DateTime } from "luxon"; import Context from "coral-server/graph/tenant/context"; import { @@ -26,11 +27,12 @@ import { retrieveCommentStoryConnection, retrieveCommentUserConnection, retrieveManyComments, + retrieveManyRecentStatusCounts, retrieveRejectedCommentUserConnection, retrieveStoryCommentTagCounts, } from "coral-server/models/comment"; -import { hasVisibleStatus } from "coral-server/models/comment/helpers"; -import { Connection } from "coral-server/models/helpers/connection"; +import { hasPublishedStatus } from "coral-server/models/comment/helpers"; +import { Connection } from "coral-server/models/helpers"; import { retrieveSharedModerationQueueQueuesCounts } from "coral-server/models/story/counts/shared"; import { User } from "coral-server/models/user"; @@ -84,7 +86,7 @@ const mapVisibleComment = (user?: Pick) => { return null; } - if (hasVisibleStatus(comment) || isPrivilegedUser) { + if (hasPublishedStatus(comment) || isPrivilegedUser) { return comment; } @@ -268,4 +270,14 @@ export default (ctx: Context) => ({ tagCounts: new DataLoader((storyIDs: string[]) => retrieveStoryCommentTagCounts(ctx.mongo, ctx.tenant.id, storyIDs) ), + authorStatusCounts: new DataLoader((authorIDs: string[]) => + retrieveManyRecentStatusCounts( + ctx.mongo, + ctx.tenant.id, + DateTime.fromJSDate(ctx.now) + .plus({ seconds: -ctx.tenant.recentCommentHistory.timeFrame }) + .toJSDate(), + authorIDs + ) + ), }); diff --git a/src/core/server/graph/tenant/loaders/Stories.ts b/src/core/server/graph/tenant/loaders/Stories.ts index 2825eef55..c610906a1 100644 --- a/src/core/server/graph/tenant/loaders/Stories.ts +++ b/src/core/server/graph/tenant/loaders/Stories.ts @@ -5,7 +5,7 @@ import { GQLSTORY_STATUS, QueryToStoriesArgs, } from "coral-server/graph/tenant/schema/__generated__/types"; -import { Connection } from "coral-server/models/helpers/connection"; +import { Connection } from "coral-server/models/helpers"; import { retrieveManyStories, retrieveStoryConnection, diff --git a/src/core/server/graph/tenant/loaders/Users.ts b/src/core/server/graph/tenant/loaders/Users.ts index c9418faa5..16a999804 100644 --- a/src/core/server/graph/tenant/loaders/Users.ts +++ b/src/core/server/graph/tenant/loaders/Users.ts @@ -6,7 +6,7 @@ import { GQLUSER_STATUS, QueryToUsersArgs, } from "coral-server/graph/tenant/schema/__generated__/types"; -import { Connection } from "coral-server/models/helpers/connection"; +import { Connection } from "coral-server/models/helpers"; import { retrieveManyUsers, retrieveUserConnection, diff --git a/src/core/server/graph/tenant/resolvers/Comment.ts b/src/core/server/graph/tenant/resolvers/Comment.ts index 0fcf3c973..8320738d6 100644 --- a/src/core/server/graph/tenant/resolvers/Comment.ts +++ b/src/core/server/graph/tenant/resolvers/Comment.ts @@ -1,5 +1,6 @@ import { GraphQLResolveInfo } from "graphql"; +import { StoryNotFoundError } from "coral-server/errors"; import { getRequestedFields } from "coral-server/graph/tenant/resolvers/util"; import { GQLComment, @@ -10,12 +11,13 @@ import { decodeActionCounts, } from "coral-server/models/action/comment"; import * as comment from "coral-server/models/comment"; -import { getLatestRevision } from "coral-server/models/comment"; -import { createConnection } from "coral-server/models/helpers/connection"; +import { + getLatestRevision, + hasAncestors, +} from "coral-server/models/comment/helpers"; +import { createConnection } from "coral-server/models/helpers"; import { getCommentEditableUntilDate } from "coral-server/services/comments"; -import { StoryNotFoundError } from "coral-server/errors"; -import { hasAncestors } from "coral-server/models/comment/helpers"; import TenantContext from "../context"; import { getURLWithCommentID } from "./util"; diff --git a/src/core/server/graph/tenant/resolvers/CommentCounts.ts b/src/core/server/graph/tenant/resolvers/CommentCounts.ts index 051f83a91..b47c7b743 100644 --- a/src/core/server/graph/tenant/resolvers/CommentCounts.ts +++ b/src/core/server/graph/tenant/resolvers/CommentCounts.ts @@ -1,12 +1,11 @@ import { GQLCommentCountsTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; -import { VISIBLE_STATUSES } from "coral-server/models/comment/constants"; +import { PUBLISHED_STATUSES } from "coral-server/models/comment/constants"; import { Story } from "coral-server/models/story"; - export type CommentCountsInput = Pick; export const CommentCounts: GQLCommentCountsTypeResolver = { - totalVisible: ({ commentCounts }) => - VISIBLE_STATUSES.reduce( + totalPublished: ({ commentCounts }) => + PUBLISHED_STATUSES.reduce( (total, status) => total + commentCounts.status[status], 0 ), diff --git a/src/core/server/graph/tenant/resolvers/Flag.ts b/src/core/server/graph/tenant/resolvers/Flag.ts index 8763519c0..ad8b985bd 100644 --- a/src/core/server/graph/tenant/resolvers/Flag.ts +++ b/src/core/server/graph/tenant/resolvers/Flag.ts @@ -1,7 +1,22 @@ -import { GQLFlagTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; +import { + GQLCOMMENT_FLAG_REASON, + GQLFlagTypeResolver, +} from "coral-server/graph/tenant/schema/__generated__/types"; import * as actions from "coral-server/models/action/comment"; export const Flag: GQLFlagTypeResolver = { + reason: ({ id, reason }, args, ctx) => { + if (reason && reason in GQLCOMMENT_FLAG_REASON) { + return reason; + } + + ctx.logger.warn( + { actionID: id, flagReason: reason }, + "found an invalid reason" + ); + + return null; + }, flagger: ({ userID }, args, ctx) => { if (userID) { return ctx.loaders.Users.user.load(userID); diff --git a/src/core/server/graph/tenant/resolvers/ModerationQueues.ts b/src/core/server/graph/tenant/resolvers/ModerationQueues.ts index bb11a6b55..1a246dfa6 100644 --- a/src/core/server/graph/tenant/resolvers/ModerationQueues.ts +++ b/src/core/server/graph/tenant/resolvers/ModerationQueues.ts @@ -5,7 +5,7 @@ import { RejectCommentPayloadToModerationQueuesResolver, } from "coral-server/graph/tenant/schema/__generated__/types"; import { CommentConnectionInput } from "coral-server/models/comment"; -import { FilterQuery } from "coral-server/models/helpers/query"; +import { FilterQuery } from "coral-server/models/helpers"; import { CommentModerationCountsPerQueue, Story, diff --git a/src/core/server/graph/tenant/resolvers/RecentCommentHistory.ts b/src/core/server/graph/tenant/resolvers/RecentCommentHistory.ts new file mode 100644 index 000000000..5e2f8ed74 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/RecentCommentHistory.ts @@ -0,0 +1,12 @@ +import { GQLRecentCommentHistoryTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; + +export interface RecentCommentHistoryInput { + userID: string; +} + +export const RecentCommentHistory: Required< + GQLRecentCommentHistoryTypeResolver +> = { + statuses: ({ userID }, args, ctx) => + ctx.loaders.Comments.authorStatusCounts.load(userID), +}; diff --git a/src/core/server/graph/tenant/resolvers/User.ts b/src/core/server/graph/tenant/resolvers/User.ts index cf67255fd..eef1556c5 100644 --- a/src/core/server/graph/tenant/resolvers/User.ts +++ b/src/core/server/graph/tenant/resolvers/User.ts @@ -8,6 +8,7 @@ import { import * as user from "coral-server/models/user"; import { roleIsStaff } from "coral-server/models/user/helpers"; +import { RecentCommentHistoryInput } from "./RecentCommentHistory"; import { UserStatusInput } from "./UserStatus"; import { getRequestedFields } from "./util"; @@ -47,4 +48,5 @@ export const User: GQLUserTypeResolver = { ignoredUsers: ({ ignoredUsers }, input, ctx, info) => maybeLoadOnlyIgnoredUserID(ctx, info, ignoredUsers), ignoreable: ({ role }) => !roleIsStaff(role), + recentCommentHistory: ({ id }): RecentCommentHistoryInput => ({ userID: id }), }; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index 79b6b7890..3d398e200 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -29,6 +29,7 @@ import { Mutation } from "./Mutation"; import { OIDCAuthIntegration } from "./OIDCAuthIntegration"; import { Profile } from "./Profile"; import { Query } from "./Query"; +import { RecentCommentHistory } from "./RecentCommentHistory"; import { RejectCommentPayload } from "./RejectCommentPayload"; import { Story } from "./Story"; import { StorySettings } from "./StorySettings"; @@ -68,6 +69,7 @@ const Resolvers: GQLResolver = { OIDCAuthIntegration, Profile, Query, + RecentCommentHistory, RejectCommentPayload, Story, StorySettings, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index d736b62a4..94ddb3c01 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -107,12 +107,6 @@ enum COMMENT_FLAG_DETECTED_REASON { """ COMMENT_DETECTED_SPAM - """ - COMMENT_DETECTED_TRUST is used when the Comment being left was done by a User - that has a low karma/trust score. - """ - COMMENT_DETECTED_TRUST - """ COMMENT_DETECTED_LINKS is used when the Comment was detected as containing links. @@ -130,6 +124,12 @@ enum COMMENT_FLAG_DETECTED_REASON { containing a suspect word. """ COMMENT_DETECTED_SUSPECT_WORD + + """ + COMMENT_DETECTED_RECENT_HISTORY is used when a Comment author has exhibited a + recent history of rejected comments. + """ + COMMENT_DETECTED_RECENT_HISTORY } """ @@ -142,10 +142,10 @@ enum COMMENT_FLAG_REASON { COMMENT_REPORTED_OTHER COMMENT_DETECTED_TOXIC COMMENT_DETECTED_SPAM - COMMENT_DETECTED_TRUST COMMENT_DETECTED_LINKS COMMENT_DETECTED_BANNED_WORD COMMENT_DETECTED_SUSPECT_WORD + COMMENT_DETECTED_RECENT_HISTORY } """ @@ -176,10 +176,10 @@ type FlagReasonActionCounts { COMMENT_REPORTED_OTHER: Int! COMMENT_DETECTED_TOXIC: Int! COMMENT_DETECTED_SPAM: Int! - COMMENT_DETECTED_TRUST: Int! COMMENT_DETECTED_LINKS: Int! COMMENT_DETECTED_BANNED_WORD: Int! COMMENT_DETECTED_SUSPECT_WORD: Int! + COMMENT_DETECTED_RECENT_HISTORY: Int! } type Flag { @@ -190,9 +190,11 @@ type Flag { flagger: User """ - reason is the selected reason why the Flag is being created. + reason is the selected reason why the Flag is being created. If the reason is + not defined, or existed from a previous version of Coral, it will return null + to avoid errors. """ - reason: COMMENT_FLAG_REASON! + reason: COMMENT_FLAG_REASON """ additionalDetails stores information from the User as to why the Flag was @@ -795,46 +797,45 @@ type ExternalIntegrations { } ################################################################################ -## Karma +## RecentCommentHistory ################################################################################ """ -KarmaThreshold defines the bounds for which a User will become unreliable or -reliable based on their karma score. If the score is equal or less than the -unreliable value, they are unreliable. If the score is equal or more than the -reliable value, they are reliable. If they are neither reliable or unreliable -then they are neutral. +RecentCommentHistoryConfiguration controls the beheviour around when comments +from Users should be marked for pre-moderation automatically once they have +reached the trigger rate for rejected comments. """ -type KarmaThreshold { - reliable: Int! - unreliable: Int! -} - -type KarmaThresholds { +type RecentCommentHistoryConfiguration { """ - flag represents karma settings in relation to how well a User's flagging - ability aligns with the moderation decisions made by moderators. - """ - flag: KarmaThreshold! - - """ - comment represents the karma setting in relation to how well a User's comments are moderated. - """ - comment: KarmaThreshold! -} - -type Karma { - """ - When true, checks will be completed to ensure that the Karma checks are - completed. + enabled when true will pre-moderate users new comments once they have reached + the trigger rejection rate. """ enabled: Boolean! """ - karmaThresholds contains the currently set thresholds for triggering Trust - behavior. + timeFrame specifies the number of seconds that comments from a given User will + be taken into account when computing their rejection rate. """ - thresholds: KarmaThresholds! + timeFrame: Int! + + """ + triggerRejectionRate specifies the percentage of comments that a given User + may have before their comments will then be placed into pre-moderation until + the rejection rate decreases. + """ + triggerRejectionRate: Float! +} + +""" +RecentCommentHistory returns data associated with a User's recent commenting +history within the specified timeFrame configured. +""" +type RecentCommentHistory { + """ + statuses stores the counts of all the statuses against Comments by a User + within the timeFrame configured. + """ + statuses: CommentStatusCounts! } ################################################################################ @@ -1176,10 +1177,11 @@ type Settings { integrations: ExternalIntegrations! @auth(roles: [ADMIN, MODERATOR]) """ - karma is the set of settings related to how user Trust and Karma are - handled. + recentCommentHistory is the set of settings related to how automatic + pre-moderation is controlled. """ - karma: Karma! @auth(roles: [ADMIN, MODERATOR]) + recentCommentHistory: RecentCommentHistoryConfiguration! + @auth(roles: [ADMIN, MODERATOR]) """ reaction specifies the configuration for reactions. @@ -1558,6 +1560,11 @@ type User { rejectedComments(first: Int = 10, after: Cursor): CommentsConnection! @auth(roles: [ADMIN, MODERATOR]) + """ + recentCommentHistory returns recent commenting history by the User. + """ + recentCommentHistory: RecentCommentHistory! @auth(roles: [ADMIN, MODERATOR]) + """ commentModerationActionHistory returns a CommentModerationActionConnection that this User has created. @@ -2057,9 +2064,9 @@ type CommentStatusCounts { type CommentCounts { """ - totalVisible will return the count of all visible Comments. + totalPublished will return the count of all published Comments. """ - totalVisible: Int! + totalPublished: Int! """ tags stores the counts of all the Tags against Comment's on this Story. @@ -2854,42 +2861,29 @@ input SettingsExternalIntegrationsInput { } """ -KarmaThreshold defines the bounds for which a User will become unreliable or -reliable based on their karma score. If the score is equal or less than the -unreliable value, they are unreliable. If the score is equal or more than the -reliable value, they are reliable. If they are neither reliable or unreliable -then they are neutral. +RecentCommentHistoryConfigurationInput controls the beheviour around when comments from Users +should be marked for pre-moderation automatically once they have reached the +trigger rate for rejected comments. """ -input SettingsKarmaThresholdInput { - reliable: Int - unreliable: Int -} - -input SettingsKarmaThresholdsInput { +input RecentCommentHistoryConfigurationInput { """ - flag represents karma settings in relation to how well a User's flagging - ability aligns with the moderation decisions made by moderators. - """ - flag: SettingsKarmaThresholdInput - - """ - comment represents the karma setting in relation to how well a User's comments are moderated. - """ - comment: SettingsKarmaThresholdInput -} - -input SettingsKarmaInput { - """ - When true, checks will be completed to ensure that the Karma checks are - completed. + enabled when true will pre-moderate users new comments once they have reached + the trigger rejection rate. """ enabled: Boolean """ - karmaThresholds contains the currently set thresholds for triggering Trust - behavior. + timeFrame specifies the number of seconds that comments from a given User will + be taken into account when computing their rejection rate. """ - thresholds: SettingsKarmaThresholdsInput + timeFrame: Int + + """ + triggerRejectionRate specifies the percentage of comments that a given User + may have before their comments will then be placed into pre-moderation until + the rejection rate decreases. + """ + triggerRejectionRate: Float } input SettingsCommunityGuidelinesInput { @@ -3083,10 +3077,10 @@ input SettingsInput { integrations: SettingsExternalIntegrationsInput """ - karma is the set of settings related to how user Trust and Karma are - handled. + recentCommentHistory is the set of settings related to how automatic + pre-moderation is controlled. """ - karma: SettingsKarmaInput + recentCommentHistory: RecentCommentHistoryConfigurationInput """ charCount stores the character count moderation settings. diff --git a/src/core/server/graph/tenant/subscriptions/server.ts b/src/core/server/graph/tenant/subscriptions/server.ts index fbc392d87..35d32d12a 100644 --- a/src/core/server/graph/tenant/subscriptions/server.ts +++ b/src/core/server/graph/tenant/subscriptions/server.ts @@ -34,9 +34,9 @@ import { } from "coral-server/graph/common/extensions"; import { getOperationMetadata } from "coral-server/graph/common/extensions/helpers"; import logger from "coral-server/logger"; +import { userIsStaff } from "coral-server/models/user/helpers"; import { extractTokenFromRequest } from "coral-server/services/jwt"; -import { userIsStaff } from "coral-server/models/user/helpers"; import TenantContext, { TenantContextOptions } from "../context"; type OnConnectFn = ( diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 08922fb4a..dd0d77ba3 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -172,7 +172,7 @@ class Server { logger.info("mongodb autoindexing is enabled, starting indexing"); await ensureIndexes(this.mongo); } else { - logger.info("mongodb autoindexing is disabled, skipping indexing"); + logger.warn("mongodb autoindexing is disabled, skipping indexing"); } // Launch all of the job processors. diff --git a/src/core/server/models/action/__snapshots__/comment.spec.ts.snap b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap index 980659774..fbc9d044e 100644 --- a/src/core/server/models/action/__snapshots__/comment.spec.ts.snap +++ b/src/core/server/models/action/__snapshots__/comment.spec.ts.snap @@ -19,10 +19,10 @@ Object { "reasons": Object { "COMMENT_DETECTED_BANNED_WORD": 1, "COMMENT_DETECTED_LINKS": 0, + "COMMENT_DETECTED_RECENT_HISTORY": 0, "COMMENT_DETECTED_SPAM": 0, "COMMENT_DETECTED_SUSPECT_WORD": 0, "COMMENT_DETECTED_TOXIC": 0, - "COMMENT_DETECTED_TRUST": 0, "COMMENT_REPORTED_OFFENSIVE": 0, "COMMENT_REPORTED_OTHER": 1, "COMMENT_REPORTED_SPAM": 0, diff --git a/src/core/server/models/action/comment.spec.ts b/src/core/server/models/action/comment.spec.ts index ed8411084..f69bc12b9 100644 --- a/src/core/server/models/action/comment.spec.ts +++ b/src/core/server/models/action/comment.spec.ts @@ -80,7 +80,7 @@ describe("#validateAction", () => { }, { actionType: ACTION_TYPE.FLAG, - reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TRUST, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_RECENT_HISTORY, }, { actionType: ACTION_TYPE.FLAG, diff --git a/src/core/server/models/action/comment.ts b/src/core/server/models/action/comment.ts index efd293582..0f90cd8f2 100644 --- a/src/core/server/models/action/comment.ts +++ b/src/core/server/models/action/comment.ts @@ -13,21 +13,20 @@ import { GQLFlagActionCounts, GQLReactionActionCounts, } from "coral-server/graph/tenant/schema/__generated__/types"; +import logger from "coral-server/logger"; import { Connection, ConnectionInput, - resolveConnection, -} from "coral-server/models/helpers/connection"; -import { + createCollection, createConnectionOrderVariants, createIndexFactory, -} from "coral-server/models/helpers/indexing"; -import Query, { FilterQuery } from "coral-server/models/helpers/query"; + FilterQuery, + Query, + resolveConnection, +} from "coral-server/models/helpers"; import { TenantResource } from "coral-server/models/tenant"; -function collection(mongo: Db) { - return mongo.collection>("commentActions"); -} +const collection = createCollection("commentActions"); export enum ACTION_TYPE { /** @@ -529,7 +528,7 @@ interface DecodedActionCountKey { * decodeActionCountGroup will unpack the key as it is encoded into the separate * actionType and reason. */ -function decodeActionCountKey(key: string): DecodedActionCountKey { +function decodeActionCountKey(key: string): DecodedActionCountKey | null { let actionType: string = ""; let reason: string = ""; @@ -546,12 +545,22 @@ function decodeActionCountKey(key: string): DecodedActionCountKey { // Validate that the action type is flag. if (actionType !== ACTION_TYPE.FLAG) { - throw new Error("invalid action type, expected only flag to have reason"); + // This was an invalid action type. + logger.warn( + { actionType }, + "found an action type that should have been flag, but wasn't" + ); + return null; } // 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"); + // This was an invalid reason. + logger.warn( + { reason: reason || null }, + "found an invalid flagging reason" + ); + return null; } } else { actionType = key; @@ -559,7 +568,12 @@ function decodeActionCountKey(key: string): DecodedActionCountKey { // 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"); + // This was an invalid flag given that the action type was invalid. + logger.warn( + { actionType: actionType || null }, + "found an invalid action type" + ); + return null; } const result: DecodedActionCountKey = { @@ -640,8 +654,15 @@ export function decodeActionCounts( // Loop over all the encoded action counts to extract each of the action // counts as they are encoded. Object.entries(encodedActionCounts).forEach(([key, count]) => { + // Decode the encoded action count key. + const decoded = decodeActionCountKey(key); + if (!decoded) { + // If there was an error decoding the action count keys, skip this entry. + return; + } + // Pull out the action type and the reason from the key. - const { actionType, reason } = decodeActionCountKey(key); + const { actionType, reason } = decoded; // Handle the different types and reasons. incrementActionCounts(actionCounts, actionType, reason, count); diff --git a/src/core/server/models/action/moderation/comment.ts b/src/core/server/models/action/moderation/comment.ts index 9d804dd19..67641cc36 100644 --- a/src/core/server/models/action/moderation/comment.ts +++ b/src/core/server/models/action/moderation/comment.ts @@ -6,20 +6,17 @@ import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated_ import { Connection, ConnectionInput, - resolveConnection, -} from "coral-server/models/helpers/connection"; -import { + createCollection, createConnectionOrderVariants, createIndexFactory, -} from "coral-server/models/helpers/indexing"; -import Query from "coral-server/models/helpers/query"; + Query, + resolveConnection, +} from "coral-server/models/helpers"; import { TenantResource } from "coral-server/models/tenant"; -function collection(mongo: Db) { - return mongo.collection>( - "commentModerationActions" - ); -} +const collection = createCollection( + "commentModerationActions" +); export async function createCommentModerationActionIndexes(mongo: Db) { const createIndex = createIndexFactory(collection(mongo)); diff --git a/src/core/server/models/comment/comment.ts b/src/core/server/models/comment/comment.ts new file mode 100644 index 000000000..62a798e3c --- /dev/null +++ b/src/core/server/models/comment/comment.ts @@ -0,0 +1,1137 @@ +import { isEmpty, merge } from "lodash"; +import { Db } from "mongodb"; +import performanceNow from "performance-now"; +import uuid from "uuid"; + +import { Omit, Sub } from "coral-common/types"; +import { dotize } from "coral-common/utils/dotize"; +import { CommentNotFoundError } from "coral-server/errors"; +import { + GQLCOMMENT_SORT, + GQLCOMMENT_STATUS, + GQLCommentTagCounts, + GQLTAG, +} from "coral-server/graph/tenant/schema/__generated__/types"; +import logger from "coral-server/logger"; +import { + EncodedCommentActionCounts, + mergeCommentActionCounts, +} from "coral-server/models/action/comment"; +import { + Connection, + createCollection, + createConnection, + createConnectionOrderVariants, + createIndexFactory, + doesNotContainNull, + FilterQuery, + nodesToEdges, + NodeToCursorTransformer, + OrderedConnectionInput, + Query, + resolveConnection, +} from "coral-server/models/helpers"; +import { TenantResource } from "coral-server/models/tenant"; + +import { PUBLISHED_STATUSES } from "./constants"; +import { + CommentStatusCounts, + createEmptyCommentStatusCounts, + hasAncestors, +} from "./helpers"; +import { Revision } from "./revision"; +import { CommentTag } from "./tag"; + +const collection = createCollection("comments"); + +/** + * Comment's are created by User's on Stories. Each Comment contains a body, and + * can be moderated by another Moderator or Admin User. + */ +export interface Comment extends TenantResource { + /** + * id identifies this Comment specifically. + */ + readonly id: string; + + /** + * ancestorIDs stores all the ancestor ID's, with the direct parent being + * first. + */ + ancestorIDs: string[]; + + /** + * parentID is the ID of the parent Comment if this Comment is a reply. + */ + parentID?: string; + + /** + * parentRevisionID is the ID of the Revision on the Comment referenced by the + * `parentID`. + */ + parentRevisionID?: string; + + /** + * authorID stores the ID of the User that created this Comment. + */ + authorID: string; + + /** + * storyID stores the ID of the Story that this Comment was left on. + */ + storyID: string; + + /** + * revisions stores all the revisions of the Comment body including the most + * recent revision, the last revision is the most recent. + */ + revisions: Revision[]; + + /** + * status is the current Comment Status. + */ + status: GQLCOMMENT_STATUS; + + /** + * actionCounts stores a cached count of all the Action's against this + * Comment. + */ + actionCounts: EncodedCommentActionCounts; + + /** + * childIDs are the ID's of all the Comment's that are direct replies. + */ + childIDs: string[]; + + /** + * tags are CommentTag's on a specific Comment to be showcased with the + * Comment. + */ + tags: CommentTag[]; + + /** + * childCount is the count of direct replies. It is stored as a separate value + * here even though the childIDs field technically contained the same data in + * it's length because we needed to sort by this field sometimes. + */ + childCount: number; + + /** + * createdAt is the date that this Comment was created. + */ + createdAt: Date; + + /** + * deletedAt is the date that this Comment was deleted on. If null or + * undefined, this Comment is not deleted. + */ + deletedAt?: Date; +} + +export async function createCommentIndexes(mongo: Db) { + const createIndex = createIndexFactory(collection(mongo)); + + // UNIQUE { id } + await createIndex({ tenantID: 1, id: 1 }, { unique: true }); + + // Facility for counting the tags against a story. + await createIndex( + { + tenantID: 1, + storyID: 1, + "tags.type": 1, + status: 1, + }, + { + background: true, + partialFilterExpression: { + "tags.type": { $exists: true }, + }, + } + ); + + const variants = createConnectionOrderVariants>([ + { createdAt: -1 }, + { createdAt: 1 }, + { childCount: -1, createdAt: -1 }, + { "actionCounts.REACTION": -1, createdAt: -1 }, + ]); + + // Story based Comment Connection pagination. + // { storyID, ...connectionParams } + await variants(createIndex, { + tenantID: 1, + storyID: 1, + status: 1, + }); + + // Moderation based Comment Connection pagination. + // { storyID, ...connectionParams } + await variants(createIndex, { + tenantID: 1, + status: 1, + }); + + // Story based Comment Connection pagination that are flagged. + // { storyID, ...connectionParams } + await variants(createIndex, { + tenantID: 1, + storyID: 1, + status: 1, + "actionCounts.FLAG": 1, + }); + + // Story + Reply based Comment Connection pagination. + // { storyID, ...connectionParams } + await variants(createIndex, { + tenantID: 1, + storyID: 1, + parentID: 1, + status: 1, + }); + + // Author based Comment Connection pagination. + // { authorID, ...connectionParams } + await variants(createIndex, { + tenantID: 1, + authorID: 1, + status: 1, + }); + + // Tag based Comment Connection pagination. + // { tags.type, ...connectionParams } + await variants(createIndex, { + tenantID: 1, + "tags.type": 1, + }); +} + +export type CreateCommentInput = Omit< + Comment, + | "id" + | "tenantID" + | "createdAt" + | "childIDs" + | "childCount" + | "actionCounts" + | "revisions" + | "deletedAt" +> & + Required> & + Pick & + Partial>; + +export async function createComment( + mongo: Db, + tenantID: string, + input: CreateCommentInput, + now = new Date() +) { + // Pull out some useful properties from the input. + const { body, actionCounts = {}, metadata, ...rest } = input; + + // Generate the revision. + const revision: Revision = { + id: uuid.v4(), + body, + actionCounts, + metadata, + createdAt: now, + }; + + // default are the properties set by the application when a new comment is + // created. + const defaults: Sub = { + id: uuid.v4(), + tenantID, + childIDs: [], + childCount: 0, + revisions: [revision], + createdAt: now, + }; + + // Merge the defaults and the input together. + const comment: Readonly = { + // 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(mongo).insertOne(comment); + + return comment; +} + +/** + * pushChildCommentIDOntoParent will push the new child comment's ID onto the + * parent comment so it can reference direct children. + */ +export async function pushChildCommentIDOntoParent( + mongo: Db, + tenantID: string, + parentID: string, + childID: string +) { + // This pushes the new child ID onto the parent comment. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id: parentID, + }, + { + $push: { childIDs: childID }, + $inc: { childCount: 1 }, + } + ); + + return result.value; +} + +export type EditCommentInput = Pick & { + /** + * lastEditableCommentCreatedAt is the date that the last comment would have + * been editable. It is generally derived from the tenant's + * `editCommentWindowLength` property. + */ + lastEditableCommentCreatedAt: Date; +} & Required> & + Partial>; + +// Only comments with the following status's can be edited. +const EDITABLE_STATUSES = [ + GQLCOMMENT_STATUS.NONE, + GQLCOMMENT_STATUS.PREMOD, + GQLCOMMENT_STATUS.APPROVED, +]; + +export function validateEditable( + comment: Comment, + { + authorID, + lastEditableCommentCreatedAt, + }: Pick +) { + 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( + mongo: Db, + tenantID: string, + input: EditCommentInput, + now = new Date() +): Promise { + const { + id, + body, + lastEditableCommentCreatedAt, + status, + authorID, + metadata, + actionCounts = {}, + } = input; + + // Generate the revision. + const revision: Revision = { + id: uuid.v4(), + body, + actionCounts, + metadata, + createdAt: now, + }; + + const update: Record = { + $set: { status }, + $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, + authorID, + status: { + $in: EDITABLE_STATUSES, + }, + deletedAt: null, + createdAt: { + $gt: lastEditableCommentCreatedAt, + }, + }, + update, + { + // 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(mongo, tenantID, id); + if (!comment) { + // TODO: (wyattjoh) return better error + throw new Error("comment not found"); + } + + // 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"); + } + + // 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(mongo: Db, tenantID: string, id: string) { + return collection(mongo).findOne({ id, tenantID }); +} + +export async function retrieveManyComments( + mongo: Db, + tenantID: string, + ids: string[] +) { + const cursor = await collection(mongo).find({ + id: { + $in: ids, + }, + tenantID, + }); + + const comments = await cursor.toArray(); + + return ids.map(id => comments.find(comment => comment.id === id) || null); +} + +export type CommentConnectionInput = OrderedConnectionInput< + Comment, + GQLCOMMENT_SORT +>; + +function cursorGetterFactory( + input: Pick +): NodeToCursorTransformer { + switch (input.orderBy) { + case GQLCOMMENT_SORT.CREATED_AT_DESC: + case GQLCOMMENT_SORT.CREATED_AT_ASC: + return comment => comment.createdAt; + case GQLCOMMENT_SORT.REPLIES_DESC: + case GQLCOMMENT_SORT.REACTION_DESC: + return (_, index) => + (input.after ? (input.after as number) : 0) + index + 1; + } +} + +/** + * retrieveRepliesConnection returns a Connection for a given comments + * replies. + * + * @param mongo database connection + * @param parentID the parent id for the comment to retrieve + * @param input connection configuration + */ +export const retrieveCommentRepliesConnection = ( + mongo: Db, + tenantID: string, + storyID: string, + parentID: string, + input: CommentConnectionInput +) => + retrievePublishedCommentConnection(mongo, tenantID, { + ...input, + filter: { + ...input.filter, + storyID, + parentID, + }, + }); + +/** + * retrieveCommentParentsConnection will return a comment connection used to + * represent the parents of a given comment. + * + * @param mongo the database connection to use when retrieving comments + * @param tenantID the tenant id for where the comment exists + * @param commentID the id of the comment to retrieve parents of + * @param pagination pagination options to paginate the results + */ +export async function retrieveCommentParentsConnection( + mongo: Db, + tenantID: string, + comment: Comment, + { last: limit, before: skip = 0 }: { last: number; before?: number } +): Promise>>> { + // Return nothing if this comment does not have any parents. + if (!hasAncestors(comment)) { + return createConnection({ + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, + }); + } + + // TODO: (wyattjoh) maybe throw an error when the limit is zero? + + if (limit <= 0) { + return createConnection({ + pageInfo: { + hasNextPage: false, + hasPreviousPage: true, + endCursor: 0, + startCursor: 0, + }, + }); + } + + // Fetch the subset of the comment id's that we are going to query for. + const ancestorIDs = comment.ancestorIDs.slice(skip, skip + limit); + + // Retrieve the parents via the subset list. + const nodes = await retrieveManyComments(mongo, tenantID, ancestorIDs); + + // Loop over the list to ensure that none of the entries is null (indicating + // that there was a misplaced parent). We can assert the type here because we + // will throw an error and abort if one of the comments are null. + if (!doesNotContainNull(nodes)) { + // TODO: (wyattjoh) replace with a better error. + throw new Error("parent id specified does not exist"); + } + + const edges = nodesToEdges( + // We can't have a null parent after the forEach filter above. + nodes, + (_, index) => index + skip + 1 + ).reverse(); + + // Return the resolved connection. + return { + edges, + nodes, + pageInfo: { + hasNextPage: false, + hasPreviousPage: comment.ancestorIDs.length > limit + skip, + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, + }, + }; +} + +/** + * retrieveStoryConnection returns a Connection for a given Stories + * comments. + * + * @param mongo database connection + * @param storyID the Story id for the comment to retrieve + * @param input connection configuration + */ +export const retrieveCommentStoryConnection = ( + mongo: Db, + tenantID: string, + storyID: string, + input: CommentConnectionInput +) => + retrievePublishedCommentConnection(mongo, tenantID, { + ...input, + filter: { + ...input.filter, + storyID, + }, + }); + +/** + * retrieveCommentUserConnection returns a Connection for a given User's + * comments. + * + * @param mongo database connection + * @param tenantID the Tenant's ID + * @param userID the User id for the comment to retrieve + * @param input connection configuration + */ +export const retrieveCommentUserConnection = ( + mongo: Db, + tenantID: string, + userID: string, + input: CommentConnectionInput +) => + retrievePublishedCommentConnection(mongo, tenantID, { + ...input, + filter: { + ...input.filter, + authorID: userID, + }, + }); + +/** + * retrieveAllCommentUserConnection returns a Connection for a given User's + * comments regardless of comment status. + * + * @param mongo database connection + * @param tenantID the Tenant's ID + * @param userID the User id for the comment to retrieve + * @param input connection configuration + */ +export const retrieveAllCommentsUserConnection = ( + mongo: Db, + tenantID: string, + userID: string, + input: CommentConnectionInput +) => + retrieveCommentConnection(mongo, tenantID, { + ...input, + filter: { + ...input.filter, + authorID: userID, + }, + }); + +/** + * retrieveRejectedCommentUserConnection returns a Connection for a given User's + * rejected comments. + * + * @param mongo database connection + * @param tenantID the Tenant's ID + * @param userID the User id for the comment to retrieve + * @param input connection configuration + */ +export const retrieveRejectedCommentUserConnection = ( + mongo: Db, + tenantID: string, + userID: string, + input: CommentConnectionInput +) => + retrieveStatusCommentConnection( + mongo, + tenantID, + [GQLCOMMENT_STATUS.REJECTED], + { + ...input, + filter: { + ...input.filter, + authorID: userID, + }, + } + ); + +/** + * retrievePublishedCommentConnection will retrieve a connection that contains + * comments that are published. + * + * @param mongo database connection + * @param tenantID the Tenant's ID + * @param input connection configuration + */ +export const retrievePublishedCommentConnection = ( + mongo: Db, + tenantID: string, + input: CommentConnectionInput +) => + retrieveStatusCommentConnection(mongo, tenantID, PUBLISHED_STATUSES, input); + +/** + * retrieveStatusCommentConnection will retrieve a connection that contains + * comments with specific statuses. + * + * @param mongo database connection + * @param tenantID the Tenant's ID + * @param statuses the statuses to filter + * @param input connection configuration + */ +export const retrieveStatusCommentConnection = ( + mongo: Db, + tenantID: string, + statuses: GQLCOMMENT_STATUS[], + input: CommentConnectionInput +) => + retrieveCommentConnection(mongo, tenantID, { + ...input, + filter: { + ...input.filter, + status: { $in: statuses }, + }, + }); + +export async function retrieveCommentConnection( + mongo: Db, + tenantID: string, + input: CommentConnectionInput +): Promise>>> { + // 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); +} + +/** + * retrieveConnection returns a Connection for the given input and + * Query. + * + * @param input connection configuration + * @param query the Query for the set of nodes that should have the connection + * configuration applied + */ +async function retrieveConnection( + input: CommentConnectionInput, + query: Query +): Promise>>> { + // Apply some sorting options. + applyInputToQuery(input, query); + + // Return a connection. + return resolveConnection(query, input, cursorGetterFactory(input)); +} + +function applyInputToQuery( + input: CommentConnectionInput, + query: Query +) { + // NOTE: (wyattjoh) if we ever extend these, ensure that the new order variant is added as an index into the `createConnectionOrderVariants` function. + switch (input.orderBy) { + case GQLCOMMENT_SORT.CREATED_AT_DESC: + query.orderBy({ createdAt: -1 }); + if (input.after) { + query.where({ createdAt: { $lt: input.after as Date } }); + } + break; + case GQLCOMMENT_SORT.CREATED_AT_ASC: + query.orderBy({ createdAt: 1 }); + if (input.after) { + query.where({ createdAt: { $gt: input.after as Date } }); + } + break; + case GQLCOMMENT_SORT.REPLIES_DESC: + query.orderBy({ childCount: -1, createdAt: -1 }); + if (input.after) { + query.after(input.after as number); + } + break; + case GQLCOMMENT_SORT.REACTION_DESC: + query.orderBy({ "actionCounts.REACTION": -1, createdAt: -1 }); + if (input.after) { + query.after(input.after as number); + } + break; + } +} + +export interface UpdateCommentStatus { + /** + * comment is the updated Comment with the new status associated with it. + */ + comment: Readonly; + + /** + * oldStatus is the previous status that the given Comment had. + */ + oldStatus: GQLCOMMENT_STATUS; +} + +export async function updateCommentStatus( + mongo: Db, + tenantID: string, + id: string, + revisionID: string, + status: GQLCOMMENT_STATUS +): Promise { + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + "revisions.id": revisionID, + status: { + $ne: status, + }, + }, + { + $set: { status }, + }, + { + // True to return the original document instead of the updated + // document. + returnOriginal: true, + } + ); + if (!result.value) { + return null; + } + + // Grab the old status. + const oldStatus = result.value.status; + + return { + comment: { + ...result.value, + status, + }, + oldStatus, + }; +} + +/** + * updateCommentActionCounts will update the given comment's action counts. + * + * @param mongo the database handle + * @param tenantID the id of the Tenant + * @param id the id of the Comment being updated + * @param actionCounts the action counts to merge into the Comment + */ +export async function updateCommentActionCounts( + mongo: Db, + tenantID: string, + id: string, + revisionID: 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, + "revisions.$[revision]": { actionCounts }, + }), + }, + { + // Add an ArrayFilter to only update one of the OpenID Connect + // integrations. + arrayFilters: [{ "revision.id": revisionID }], + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + + return result.value || null; +} + +/** + * removeStoryComments will remove all comments associated with a particular + * Story. + */ +export async function removeStoryComments( + mongo: Db, + tenantID: string, + storyID: string +) { + // Delete all the comments written on a specific story. + return collection(mongo).deleteMany({ + tenantID, + storyID, + }); +} + +/** + * mergeManyCommentStories will update many comment's storyID's. + */ +export async function mergeManyCommentStories( + mongo: Db, + tenantID: string, + newStoryID: string, + oldStoryIDs: string[] +) { + return collection(mongo).updateMany( + { + tenantID, + storyID: { + $in: oldStoryIDs, + }, + }, + { + $set: { + storyID: newStoryID, + }, + } + ); +} + +export async function addCommentTag( + mongo: Db, + tenantID: string, + commentID: string, + tag: CommentTag +) { + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id: commentID, + tags: { + $not: { + $eq: { + type: tag.type, + }, + }, + }, + }, + { + $push: { + tags: tag, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const comment = await retrieveComment(mongo, tenantID, commentID); + if (!comment) { + throw new CommentNotFoundError(commentID); + } + + if (comment.tags.some(({ type }) => type === tag.type)) { + return comment; + } + + throw new Error("could not add a tag for an unexpected reason"); + } + + return result.value; +} + +export async function removeCommentTag( + mongo: Db, + tenantID: string, + commentID: string, + tagType: GQLTAG +) { + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id: commentID, + }, + { + $pull: { + tags: { type: tagType }, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const comment = await retrieveComment(mongo, tenantID, commentID); + if (!comment) { + throw new CommentNotFoundError(commentID); + } + + throw new Error("could not add a tag for an unexpected reason"); + } + + return result.value; +} + +export async function retrieveStoryCommentTagCounts( + mongo: Db, + tenantID: string, + storyIDs: string[] +): Promise { + // Build up the $match query. + const $match: FilterQuery = { + tenantID, + // We're filtering only for featured comments for now because that's all + // that is returned by the tag counts at the moment. If we ever extend this + // we should switch this out to something like + // `"tags.type": { $exists: true }` to ensure that we are using the + // specified index. + "tags.type": GQLTAG.FEATURED, + // Only show published comment's tag counts. + status: { $in: PUBLISHED_STATUSES }, + }; + if (storyIDs.length > 1) { + $match.storyID = { $in: storyIDs }; + } else { + $match.storyID = storyIDs[0]; + } + + // Get the start time. + const startTime = performanceNow(); + + // Load the counts from the database for this particular tag query. + const cursor = await collection<{ + _id: { tag: GQLTAG; storyID: string }; + total: number; + }>(mongo).aggregate([ + { $match }, + { $unwind: "$tags" }, + { + $group: { + _id: { tag: "$tags.type", storyID: "$storyID" }, + total: { $sum: 1 }, + }, + }, + ]); + + // Get all of the counts. + const tags = await cursor.toArray(); + + // Compute the end time. + const responseTime = Math.round(performanceNow() - startTime); + + // Logging at the info level here to ensure we track any degrading performance + // issues from this query. + logger.info({ responseTime, filter: $match }, "counting tags"); + + // For each of the storyIDs... + return storyIDs.map(storyID => { + // Get the tags associated with this storyID. + const tagCounts = tags.filter(({ _id }) => _id.storyID === storyID) || []; + + // Then remap these tags to strip the storyID as the returned order already + // preserves the storyID information. + return tagCounts.reduce( + (counts, { _id: { tag: code }, total }) => ({ + ...counts, + [code]: total, + }), + // Keep this collection of empty tag counts up to date to ensure we + // provide an accurate model. The type system should warn you if there is + // missing/extra tags here. + { + [GQLTAG.FEATURED]: 0, + } + ); + }); +} + +export async function retrieveManyRecentStatusCounts( + mongo: Db, + tenantID: string, + since: Date, + authorIDs: string[] +) { + // Get all the statuses for the given date stamp. + const cursor = await collection<{ + _id: { + status: GQLCOMMENT_STATUS; + authorID: string; + }; + count: number; + }>(mongo).aggregate([ + { + $match: { + tenantID, + authorID: { + $in: authorIDs, + }, + createdAt: { + $gte: since, + }, + }, + }, + { + $group: { + _id: { + status: "$status", + authorID: "$authorID", + }, + count: { $sum: 1 }, + }, + }, + ]); + + // Get all of the statuses. + const docs = await cursor.toArray(); + + // Iterate over the documents and join up any of the results that are + // associated with each user. + return authorIDs.map(authorID => { + // Get all the author's status counts. + const filtered = docs.filter(doc => doc._id.authorID === authorID); + + // Iterate over the docs to increment the status counts. + const counts = createEmptyCommentStatusCounts(); + for (const doc of filtered) { + counts[doc._id.status] = doc.count; + } + + return counts; + }); +} + +export async function retrieveRecentStatusCounts( + mongo: Db, + tenantID: string, + since: Date, + authorID: string +): Promise { + const counts = await retrieveManyRecentStatusCounts(mongo, tenantID, since, [ + authorID, + ]); + return counts[0]; +} diff --git a/src/core/server/models/comment/constants.ts b/src/core/server/models/comment/constants.ts index 725f1f04a..fc80a5703 100644 --- a/src/core/server/models/comment/constants.ts +++ b/src/core/server/models/comment/constants.ts @@ -1,10 +1,10 @@ import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; /** - * VISIBLE_STATUSES are the comment statuses that a Comment may have that would + * PUBLISHED_STATUSES are the comment statuses that a Comment may have that would * make it visible to readers. */ -export const VISIBLE_STATUSES = [ +export const PUBLISHED_STATUSES = [ GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.APPROVED, ]; diff --git a/src/core/server/models/comment/helpers.ts b/src/core/server/models/comment/helpers.ts index ab97f0448..8bace473d 100644 --- a/src/core/server/models/comment/helpers.ts +++ b/src/core/server/models/comment/helpers.ts @@ -1,5 +1,8 @@ -import { Comment, Revision } from "."; -import { VISIBLE_STATUSES } from "./constants"; +import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; + +import { Comment } from "./comment"; +import { PUBLISHED_STATUSES } from "./constants"; +import { Revision } from "./revision"; /** * hasAncestors will check to see if a given comment has any ancestors. @@ -13,13 +16,13 @@ export function hasAncestors( } /** - * hasVisibleStatus will check to see if the comment has a visibility status + * hasPublishedStatus will check to see if the comment has a visibility status * where readers could see it. * * @param comment the comment to check the status on */ -export function hasVisibleStatus(comment: Pick): boolean { - return VISIBLE_STATUSES.includes(comment.status); +export function hasPublishedStatus(comment: Pick): boolean { + return PUBLISHED_STATUSES.includes(comment.status); } /** @@ -32,3 +35,37 @@ export function getLatestRevision( ): Revision { return comment.revisions[comment.revisions.length - 1]; } + +// 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.APPROVED]: number; + [GQLCOMMENT_STATUS.NONE]: number; + [GQLCOMMENT_STATUS.PREMOD]: number; + [GQLCOMMENT_STATUS.REJECTED]: number; + [GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: number; +} + +export function createEmptyCommentStatusCounts(): CommentStatusCounts { + return { + [GQLCOMMENT_STATUS.APPROVED]: 0, + [GQLCOMMENT_STATUS.NONE]: 0, + [GQLCOMMENT_STATUS.PREMOD]: 0, + [GQLCOMMENT_STATUS.REJECTED]: 0, + [GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: 0, + }; +} + +export function calculateRejectionRate(counts: CommentStatusCounts): number { + const published = PUBLISHED_STATUSES.reduce( + (acc, status) => counts[status] + acc, + 0 + ); + const rejected = counts[GQLCOMMENT_STATUS.REJECTED]; + + return rejected / (published + rejected); +} diff --git a/src/core/server/models/comment/index.ts b/src/core/server/models/comment/index.ts index 16e3684f3..12f108f0b 100644 --- a/src/core/server/models/comment/index.ts +++ b/src/core/server/models/comment/index.ts @@ -1,1100 +1,3 @@ -import { isEmpty, merge } from "lodash"; -import { Db } from "mongodb"; -import performanceNow from "performance-now"; -import uuid from "uuid"; - -import { Omit, Sub } from "coral-common/types"; -import { dotize } from "coral-common/utils/dotize"; -import { CommentNotFoundError } from "coral-server/errors"; -import { - GQLCOMMENT_SORT, - GQLCOMMENT_STATUS, - GQLCommentTagCounts, - GQLTAG, -} from "coral-server/graph/tenant/schema/__generated__/types"; -import logger from "coral-server/logger"; -import { - EncodedCommentActionCounts, - mergeCommentActionCounts, -} from "coral-server/models/action/comment"; -import { - Connection, - createConnection, - doesNotContainNull, - nodesToEdges, - NodeToCursorTransformer, - OrderedConnectionInput, - resolveConnection, -} from "coral-server/models/helpers/connection"; -import { - createConnectionOrderVariants, - createIndexFactory, -} from "coral-server/models/helpers/indexing"; -import Query, { FilterQuery } from "coral-server/models/helpers/query"; -import { TenantResource } from "coral-server/models/tenant"; -import { VISIBLE_STATUSES } from "./constants"; -import { hasAncestors } from "./helpers"; -import { CommentTag } from "./tag"; - +export * from "./comment"; +export * from "./revision"; export * from "./helpers"; - -function collection(mongo: Db) { - return mongo.collection>("comments"); -} - -export interface RevisionMetadata { - akismet?: boolean; - linkCount?: number; - perspective?: { - score: number; - model: string; - }; -} - -/** - * Revision stores a Comment's body for a specific edit. Actions can be tied to - * a Revision, as can moderation actions. - */ -export interface Revision { - /** - * id identifies this Revision. - */ - readonly id: string; - - /** - * body is the body text for this revision. - */ - body: string; - - /** - * actionCounts is the cached action counts on this revision. - */ - actionCounts: EncodedCommentActionCounts; - - /** - * metadata stores properties on this revision. - */ - metadata: RevisionMetadata; - - /** - * createdAt is the date that this revision was created at. - */ - createdAt: Date; -} - -/** - * Comment's are created by User's on Stories. Each Comment contains a body, and - * can be moderated by another Moderator or Admin User. - */ -export interface Comment extends TenantResource { - /** - * id identifies this Comment specifically. - */ - readonly id: string; - - /** - * ancestorIDs stores all the ancestor ID's, with the direct parent being - * first. - */ - ancestorIDs: string[]; - - /** - * parentID is the ID of the parent Comment if this Comment is a reply. - */ - parentID?: string; - - /** - * parentRevisionID is the ID of the Revision on the Comment referenced by the - * `parentID`. - */ - parentRevisionID?: string; - - /** - * authorID stores the ID of the User that created this Comment. - */ - authorID: string; - - /** - * storyID stores the ID of the Story that this Comment was left on. - */ - storyID: string; - - /** - * revisions stores all the revisions of the Comment body including the most - * recent revision, the last revision is the most recent. - */ - revisions: Revision[]; - - /** - * status is the current Comment Status. - */ - status: GQLCOMMENT_STATUS; - - /** - * actionCounts stores a cached count of all the Action's against this - * Comment. - */ - actionCounts: EncodedCommentActionCounts; - - /** - * childIDs are the ID's of all the Comment's that are direct replies. - */ - childIDs: string[]; - - /** - * tags are CommentTag's on a specific Comment to be showcased with the - * Comment. - */ - tags: CommentTag[]; - - /** - * childCount is the count of direct replies. It is stored as a separate value - * here even though the childIDs field technically contained the same data in - * it's length because we needed to sort by this field sometimes. - */ - childCount: number; - - /** - * createdAt is the date that this Comment was created. - */ - createdAt: Date; - - /** - * deletedAt is the date that this Comment was deleted on. If null or - * undefined, this Comment is not deleted. - */ - deletedAt?: Date; -} - -export async function createCommentIndexes(mongo: Db) { - const createIndex = createIndexFactory(collection(mongo)); - - // UNIQUE { id } - await createIndex({ tenantID: 1, id: 1 }, { unique: true }); - - // Facility for counting the tags against a story. - await createIndex( - { - tenantID: 1, - storyID: 1, - "tags.type": 1, - status: 1, - }, - { - background: true, - partialFilterExpression: { - "tags.type": { $exists: true }, - }, - } - ); - - const variants = createConnectionOrderVariants>([ - { createdAt: -1 }, - { createdAt: 1 }, - { childCount: -1, createdAt: -1 }, - { "actionCounts.REACTION": -1, createdAt: -1 }, - ]); - - // Story based Comment Connection pagination. - // { storyID, ...connectionParams } - await variants(createIndex, { - tenantID: 1, - storyID: 1, - status: 1, - }); - - // Story based Comment Connection pagination that are flagged. - // { storyID, ...connectionParams } - await variants(createIndex, { - tenantID: 1, - storyID: 1, - status: 1, - "actionCounts.FLAG": 1, - }); - - // Story + Reply based Comment Connection pagination. - // { storyID, ...connectionParams } - await variants(createIndex, { - tenantID: 1, - storyID: 1, - parentID: 1, - status: 1, - }); - - // Author based Comment Connection pagination. - // { authorID, ...connectionParams } - await variants(createIndex, { - tenantID: 1, - authorID: 1, - status: 1, - }); - - // Tag based Comment Connection pagination. - // { tags.type, ...connectionParams } - await variants(createIndex, { - tenantID: 1, - "tags.type": 1, - }); -} - -export type CreateCommentInput = Omit< - Comment, - | "id" - | "tenantID" - | "createdAt" - | "childIDs" - | "childCount" - | "actionCounts" - | "revisions" - | "deletedAt" -> & - Required> & - Pick & - Partial>; - -export async function createComment( - mongo: Db, - tenantID: string, - input: CreateCommentInput, - now = new Date() -) { - // Pull out some useful properties from the input. - const { body, actionCounts = {}, metadata, ...rest } = input; - - // Generate the revision. - const revision: Revision = { - id: uuid.v4(), - body, - actionCounts, - metadata, - createdAt: now, - }; - - // default are the properties set by the application when a new comment is - // created. - const defaults: Sub = { - id: uuid.v4(), - tenantID, - childIDs: [], - childCount: 0, - revisions: [revision], - createdAt: now, - }; - - // Merge the defaults and the input together. - const comment: Readonly = { - // 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(mongo).insertOne(comment); - - return comment; -} - -/** - * pushChildCommentIDOntoParent will push the new child comment's ID onto the - * parent comment so it can reference direct children. - */ -export async function pushChildCommentIDOntoParent( - mongo: Db, - tenantID: string, - parentID: string, - childID: string -) { - // This pushes the new child ID onto the parent comment. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id: parentID, - }, - { - $push: { childIDs: childID }, - $inc: { childCount: 1 }, - } - ); - - return result.value; -} - -export type EditCommentInput = Pick & { - /** - * lastEditableCommentCreatedAt is the date that the last comment would have - * been editable. It is generally derived from the tenant's - * `editCommentWindowLength` property. - */ - lastEditableCommentCreatedAt: Date; -} & Required> & - Partial>; - -// Only comments with the following status's can be edited. -const EDITABLE_STATUSES = [ - GQLCOMMENT_STATUS.NONE, - GQLCOMMENT_STATUS.PREMOD, - GQLCOMMENT_STATUS.APPROVED, -]; - -export function validateEditable( - comment: Comment, - { - authorID, - lastEditableCommentCreatedAt, - }: Pick -) { - 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( - mongo: Db, - tenantID: string, - input: EditCommentInput, - now = new Date() -): Promise { - const { - id, - body, - lastEditableCommentCreatedAt, - status, - authorID, - metadata, - actionCounts = {}, - } = input; - - // Generate the revision. - const revision: Revision = { - id: uuid.v4(), - body, - actionCounts, - metadata, - createdAt: now, - }; - - const update: Record = { - $set: { status }, - $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, - authorID, - status: { - $in: EDITABLE_STATUSES, - }, - deletedAt: null, - createdAt: { - $gt: lastEditableCommentCreatedAt, - }, - }, - update, - { - // 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(mongo, tenantID, id); - if (!comment) { - // TODO: (wyattjoh) return better error - throw new Error("comment not found"); - } - - // 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"); - } - - // 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(mongo: Db, tenantID: string, id: string) { - return collection(mongo).findOne({ id, tenantID }); -} - -export async function retrieveManyComments( - mongo: Db, - tenantID: string, - ids: string[] -) { - const cursor = await collection(mongo).find({ - id: { - $in: ids, - }, - tenantID, - }); - - const comments = await cursor.toArray(); - - return ids.map(id => comments.find(comment => comment.id === id) || null); -} - -export type CommentConnectionInput = OrderedConnectionInput< - Comment, - GQLCOMMENT_SORT ->; - -function cursorGetterFactory( - input: Pick -): NodeToCursorTransformer { - switch (input.orderBy) { - case GQLCOMMENT_SORT.CREATED_AT_DESC: - case GQLCOMMENT_SORT.CREATED_AT_ASC: - return comment => comment.createdAt; - case GQLCOMMENT_SORT.REPLIES_DESC: - case GQLCOMMENT_SORT.REACTION_DESC: - return (_, index) => - (input.after ? (input.after as number) : 0) + index + 1; - } -} - -/** - * retrieveRepliesConnection returns a Connection for a given comments - * replies. - * - * @param mongo database connection - * @param parentID the parent id for the comment to retrieve - * @param input connection configuration - */ -export const retrieveCommentRepliesConnection = ( - mongo: Db, - tenantID: string, - storyID: string, - parentID: string, - input: CommentConnectionInput -) => - retrieveVisibleCommentConnection(mongo, tenantID, { - ...input, - filter: { - ...input.filter, - storyID, - parentID, - }, - }); - -/** - * retrieveCommentParentsConnection will return a comment connection used to - * represent the parents of a given comment. - * - * @param mongo the database connection to use when retrieving comments - * @param tenantID the tenant id for where the comment exists - * @param commentID the id of the comment to retrieve parents of - * @param pagination pagination options to paginate the results - */ -export async function retrieveCommentParentsConnection( - mongo: Db, - tenantID: string, - comment: Comment, - { last: limit, before: skip = 0 }: { last: number; before?: number } -): Promise>>> { - // Return nothing if this comment does not have any parents. - if (!hasAncestors(comment)) { - return createConnection({ - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - }, - }); - } - - // TODO: (wyattjoh) maybe throw an error when the limit is zero? - - if (limit <= 0) { - return createConnection({ - pageInfo: { - hasNextPage: false, - hasPreviousPage: true, - endCursor: 0, - startCursor: 0, - }, - }); - } - - // Fetch the subset of the comment id's that we are going to query for. - const ancestorIDs = comment.ancestorIDs.slice(skip, skip + limit); - - // Retrieve the parents via the subset list. - const nodes = await retrieveManyComments(mongo, tenantID, ancestorIDs); - - // Loop over the list to ensure that none of the entries is null (indicating - // that there was a misplaced parent). We can assert the type here because we - // will throw an error and abort if one of the comments are null. - if (!doesNotContainNull(nodes)) { - // TODO: (wyattjoh) replace with a better error. - throw new Error("parent id specified does not exist"); - } - - const edges = nodesToEdges( - // We can't have a null parent after the forEach filter above. - nodes, - (_, index) => index + skip + 1 - ).reverse(); - - // Return the resolved connection. - return { - edges, - nodes, - pageInfo: { - hasNextPage: false, - hasPreviousPage: comment.ancestorIDs.length > limit + skip, - startCursor: edges.length > 0 ? edges[0].cursor : null, - endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, - }, - }; -} - -/** - * retrieveStoryConnection returns a Connection for a given Stories - * comments. - * - * @param mongo database connection - * @param storyID the Story id for the comment to retrieve - * @param input connection configuration - */ -export const retrieveCommentStoryConnection = ( - mongo: Db, - tenantID: string, - storyID: string, - input: CommentConnectionInput -) => - retrieveVisibleCommentConnection(mongo, tenantID, { - ...input, - filter: { - ...input.filter, - storyID, - }, - }); - -/** - * retrieveCommentUserConnection returns a Connection for a given User's - * comments. - * - * @param mongo database connection - * @param tenantID the Tenant's ID - * @param userID the User id for the comment to retrieve - * @param input connection configuration - */ -export const retrieveCommentUserConnection = ( - mongo: Db, - tenantID: string, - userID: string, - input: CommentConnectionInput -) => - retrieveVisibleCommentConnection(mongo, tenantID, { - ...input, - filter: { - ...input.filter, - authorID: userID, - }, - }); - -/** - * retrieveAllCommentUserConnection returns a Connection for a given User's - * comments regardless of comment status. - * - * @param mongo database connection - * @param tenantID the Tenant's ID - * @param userID the User id for the comment to retrieve - * @param input connection configuration - */ -export const retrieveAllCommentsUserConnection = ( - mongo: Db, - tenantID: string, - userID: string, - input: CommentConnectionInput -) => - retrieveCommentConnection(mongo, tenantID, { - ...input, - filter: { - ...input.filter, - authorID: userID, - }, - }); - -/** - * retrieveRejectedCommentUserConnection returns a Connection for a given User's - * rejected comments. - * - * @param mongo database connection - * @param tenantID the Tenant's ID - * @param userID the User id for the comment to retrieve - * @param input connection configuration - */ -export const retrieveRejectedCommentUserConnection = ( - mongo: Db, - tenantID: string, - userID: string, - input: CommentConnectionInput -) => - retrieveStatusCommentConnection( - mongo, - tenantID, - [GQLCOMMENT_STATUS.REJECTED], - { - ...input, - filter: { - ...input.filter, - authorID: userID, - }, - } - ); - -/** - * retrieveVisibleCommentConnection will retrieve a connection that contains - * comments that are visible. - * - * @param mongo database connection - * @param tenantID the Tenant's ID - * @param input connection configuration - */ -export const retrieveVisibleCommentConnection = ( - mongo: Db, - tenantID: string, - input: CommentConnectionInput -) => retrieveStatusCommentConnection(mongo, tenantID, VISIBLE_STATUSES, input); - -/** - * retrieveStatusCommentConnection will retrieve a connection that contains - * comments with specific statuses. - * - * @param mongo database connection - * @param tenantID the Tenant's ID - * @param statuses the statuses to filter - * @param input connection configuration - */ -export const retrieveStatusCommentConnection = ( - mongo: Db, - tenantID: string, - statuses: GQLCOMMENT_STATUS[], - input: CommentConnectionInput -) => - retrieveCommentConnection(mongo, tenantID, { - ...input, - filter: { - ...input.filter, - status: { $in: statuses }, - }, - }); - -export async function retrieveCommentConnection( - mongo: Db, - tenantID: string, - input: CommentConnectionInput -): Promise>>> { - // 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); -} - -/** - * retrieveConnection returns a Connection for the given input and - * Query. - * - * @param input connection configuration - * @param query the Query for the set of nodes that should have the connection - * configuration applied - */ -async function retrieveConnection( - input: CommentConnectionInput, - query: Query -): Promise>>> { - // Apply some sorting options. - applyInputToQuery(input, query); - - // Return a connection. - return resolveConnection(query, input, cursorGetterFactory(input)); -} - -function applyInputToQuery( - input: CommentConnectionInput, - query: Query -) { - // NOTE: (wyattjoh) if we ever extend these, ensure that the new order variant is added as an index into the `createConnectionOrderVariants` function. - switch (input.orderBy) { - case GQLCOMMENT_SORT.CREATED_AT_DESC: - query.orderBy({ createdAt: -1 }); - if (input.after) { - query.where({ createdAt: { $lt: input.after as Date } }); - } - break; - case GQLCOMMENT_SORT.CREATED_AT_ASC: - query.orderBy({ createdAt: 1 }); - if (input.after) { - query.where({ createdAt: { $gt: input.after as Date } }); - } - break; - case GQLCOMMENT_SORT.REPLIES_DESC: - query.orderBy({ childCount: -1, createdAt: -1 }); - if (input.after) { - query.after(input.after as number); - } - break; - case GQLCOMMENT_SORT.REACTION_DESC: - query.orderBy({ "actionCounts.REACTION": -1, createdAt: -1 }); - if (input.after) { - query.after(input.after as number); - } - break; - } -} - -export interface UpdateCommentStatus { - /** - * comment is the updated Comment with the new status associated with it. - */ - comment: Readonly; - - /** - * oldStatus is the previous status that the given Comment had. - */ - oldStatus: GQLCOMMENT_STATUS; -} - -export async function updateCommentStatus( - mongo: Db, - tenantID: string, - id: string, - revisionID: string, - status: GQLCOMMENT_STATUS -): Promise { - const result = await collection(mongo).findOneAndUpdate( - { - id, - tenantID, - "revisions.id": revisionID, - status: { - $ne: status, - }, - }, - { - $set: { status }, - }, - { - // True to return the original document instead of the updated - // document. - returnOriginal: true, - } - ); - if (!result.value) { - return null; - } - - // Grab the old status. - const oldStatus = result.value.status; - - return { - comment: { - ...result.value, - status, - }, - oldStatus, - }; -} - -/** - * updateCommentActionCounts will update the given comment's action counts. - * - * @param mongo the database handle - * @param tenantID the id of the Tenant - * @param id the id of the Comment being updated - * @param actionCounts the action counts to merge into the Comment - */ -export async function updateCommentActionCounts( - mongo: Db, - tenantID: string, - id: string, - revisionID: 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, - "revisions.$[revision]": { actionCounts }, - }), - }, - { - // Add an ArrayFilter to only update one of the OpenID Connect - // integrations. - arrayFilters: [{ "revision.id": revisionID }], - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - - return result.value || null; -} - -/** - * removeStoryComments will remove all comments associated with a particular - * Story. - */ -export async function removeStoryComments( - mongo: Db, - tenantID: string, - storyID: string -) { - // Delete all the comments written on a specific story. - return collection(mongo).deleteMany({ - tenantID, - storyID, - }); -} - -/** - * mergeManyCommentStories will update many comment's storyID's. - */ -export async function mergeManyCommentStories( - mongo: Db, - tenantID: string, - newStoryID: string, - oldStoryIDs: string[] -) { - return collection(mongo).updateMany( - { - tenantID, - storyID: { - $in: oldStoryIDs, - }, - }, - { - $set: { - storyID: newStoryID, - }, - } - ); -} - -export async function addCommentTag( - mongo: Db, - tenantID: string, - commentID: string, - tag: CommentTag -) { - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id: commentID, - tags: { - $not: { - $eq: { - type: tag.type, - }, - }, - }, - }, - { - $push: { - tags: tag, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - const comment = await retrieveComment(mongo, tenantID, commentID); - if (!comment) { - throw new CommentNotFoundError(commentID); - } - - if (comment.tags.some(({ type }) => type === tag.type)) { - return comment; - } - - throw new Error("could not add a tag for an unexpected reason"); - } - - return result.value; -} - -export async function removeCommentTag( - mongo: Db, - tenantID: string, - commentID: string, - tagType: GQLTAG -) { - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id: commentID, - }, - { - $pull: { - tags: { type: tagType }, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - const comment = await retrieveComment(mongo, tenantID, commentID); - if (!comment) { - throw new CommentNotFoundError(commentID); - } - - throw new Error("could not add a tag for an unexpected reason"); - } - - return result.value; -} - -export async function retrieveStoryCommentTagCounts( - mongo: Db, - tenantID: string, - storyIDs: string[] -): Promise { - // Build up the $match query. - const $match: FilterQuery = { - tenantID, - // We're filtering only for featured comments for now because that's all - // that is returned by the tag counts at the moment. If we ever extend this - // we should switch this out to something like - // `"tags.type": { $exists: true }` to ensure that we are using the - // specified index. - "tags.type": GQLTAG.FEATURED, - // Only show visible comment's tag counts. - status: { $in: VISIBLE_STATUSES }, - }; - if (storyIDs.length > 1) { - $match.storyID = { $in: storyIDs }; - } else { - $match.storyID = storyIDs[0]; - } - - // Get the start time. - const startTime = performanceNow(); - - // Load the counts from the database for this particular tag query. - const cursor = await collection<{ - _id: { tag: GQLTAG; storyID: string }; - total: number; - }>(mongo).aggregate([ - { $match }, - { $unwind: "$tags" }, - { - $group: { - _id: { tag: "$tags.type", storyID: "$storyID" }, - total: { $sum: 1 }, - }, - }, - ]); - - // Get all of the counts. - const tags = await cursor.toArray(); - - // Compute the end time. - const responseTime = Math.round(performanceNow() - startTime); - - // Logging at the info level here to ensure we track any degrading performance - // issues from this query. - logger.info({ responseTime, filter: $match }, "counting tags"); - - // For each of the storyIDs... - return storyIDs.map(storyID => { - // Get the tags associated with this storyID. - const tagCounts = tags.filter(({ _id }) => _id.storyID === storyID) || []; - - // Then remap these tags to strip the storyID as the returned order already - // preserves the storyID information. - return tagCounts.reduce( - (counts, { _id: { tag: code }, total }) => ({ - ...counts, - [code]: total, - }), - // Keep this collection of empty tag counts up to date to ensure we - // provide an accurate model. The type system should warn you if there is - // missing/extra tags here. - { - [GQLTAG.FEATURED]: 0, - } - ); - }); -} diff --git a/src/core/server/models/comment/revision.ts b/src/core/server/models/comment/revision.ts new file mode 100644 index 000000000..6a20d04e7 --- /dev/null +++ b/src/core/server/models/comment/revision.ts @@ -0,0 +1,41 @@ +import { EncodedCommentActionCounts } from "coral-server/models/action/comment"; + +export interface RevisionMetadata { + akismet?: boolean; + linkCount?: number; + perspective?: { + score: number; + model: string; + }; +} + +/** + * Revision stores a Comment's body for a specific edit. Actions can be tied to + * a Revision, as can moderation actions. + */ +export interface Revision { + /** + * id identifies this Revision. + */ + readonly id: string; + + /** + * body is the body text for this revision. + */ + body: string; + + /** + * actionCounts is the cached action counts on this revision. + */ + actionCounts: EncodedCommentActionCounts; + + /** + * metadata stores properties on this revision. + */ + metadata: RevisionMetadata; + + /** + * createdAt is the date that this revision was created at. + */ + createdAt: Date; +} diff --git a/src/core/server/models/helpers/collection.ts b/src/core/server/models/helpers/collection.ts new file mode 100644 index 000000000..0ede41ec0 --- /dev/null +++ b/src/core/server/models/helpers/collection.ts @@ -0,0 +1,7 @@ +import { Db } from "mongodb"; + +export function createCollection(name: string) { + return (mongo: Db) => { + return mongo.collection>(name); + }; +} diff --git a/src/core/server/models/helpers/connection.ts b/src/core/server/models/helpers/connection.ts index 602af6773..8eacc4953 100644 --- a/src/core/server/models/helpers/connection.ts +++ b/src/core/server/models/helpers/connection.ts @@ -1,6 +1,6 @@ import { merge } from "lodash"; -import Query, { FilterQuery } from "coral-server/models/helpers/query"; +import Query, { FilterQuery } from "./query"; export type Cursor = Date | number | string | null; diff --git a/src/core/server/models/helpers/index.ts b/src/core/server/models/helpers/index.ts new file mode 100644 index 000000000..129528d07 --- /dev/null +++ b/src/core/server/models/helpers/index.ts @@ -0,0 +1,5 @@ +export * from "./collection"; +export * from "./connection"; +export * from "./indexing"; +export { default as Query } from "./query"; +export * from "./query"; diff --git a/src/core/server/models/invite.ts b/src/core/server/models/invite.ts index 89222f28c..cfa61c73f 100644 --- a/src/core/server/models/invite.ts +++ b/src/core/server/models/invite.ts @@ -3,12 +3,13 @@ import uuid from "uuid"; import { Omit, Sub } from "coral-common/types"; import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types"; -import { createIndexFactory } from "coral-server/models/helpers/indexing"; +import { + createCollection, + createIndexFactory, +} from "coral-server/models/helpers"; import { TenantResource } from "coral-server/models/tenant"; -function collection(mongo: Db) { - return mongo.collection>("invites"); -} +const collection = createCollection>("invites"); export async function createInviteIndexes(mongo: Db) { const createIndex = createIndexFactory(collection(mongo)); diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index 649b749d3..ca7a47c88 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -80,7 +80,8 @@ export type Settings = GlobalModerationSettings & Pick< GQLSettings, | "charCount" - | "karma" + | "email" + | "recentCommentHistory" | "wordList" | "integrations" | "reaction" diff --git a/src/core/server/models/story/counts/empty.ts b/src/core/server/models/story/counts/empty.ts index 750d300a1..0529d67d9 100644 --- a/src/core/server/models/story/counts/empty.ts +++ b/src/core/server/models/story/counts/empty.ts @@ -1,9 +1,6 @@ -import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; - import { CommentModerationCountsPerQueue, CommentModerationQueueCounts, - CommentStatusCounts, } from "."; export function createEmptyCommentModerationCountsPerQueue(): CommentModerationCountsPerQueue { @@ -20,13 +17,3 @@ export function createEmptyCommentModerationQueueCounts(): CommentModerationQueu queues: createEmptyCommentModerationCountsPerQueue(), }; } - -export function createEmptyCommentStatusCounts(): CommentStatusCounts { - return { - [GQLCOMMENT_STATUS.APPROVED]: 0, - [GQLCOMMENT_STATUS.NONE]: 0, - [GQLCOMMENT_STATUS.PREMOD]: 0, - [GQLCOMMENT_STATUS.REJECTED]: 0, - [GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: 0, - }; -} diff --git a/src/core/server/models/story/counts/index.ts b/src/core/server/models/story/counts/index.ts index 0f4d1a6e2..5415b33c2 100644 --- a/src/core/server/models/story/counts/index.ts +++ b/src/core/server/models/story/counts/index.ts @@ -9,20 +9,24 @@ import { dotize } from "coral-common/utils/dotize"; import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; import logger from "coral-server/logger"; import { EncodedCommentActionCounts } from "coral-server/models/action/comment"; -import { createIndexFactory } from "coral-server/models/helpers/indexing"; +import { + CommentStatusCounts, + createEmptyCommentStatusCounts, +} from "coral-server/models/comment/helpers"; +import { + createCollection, + createIndexFactory, +} from "coral-server/models/helpers"; import { retrieveStory, Story } from "coral-server/models/story"; import { AugmentedRedis } from "coral-server/services/redis"; -import { createEmptyCommentStatusCounts } from "./empty"; import { updateSharedCommentCounts } from "./shared"; /** * collection provides a reference to the stories collection used by the * counting system. */ -function collection(mongo: Db) { - return mongo.collection>("stories"); -} +const collection = createCollection("stories"); export async function createStoryCountIndexes(mongo: Db) { const createIndex = createIndexFactory(collection(mongo)); @@ -31,20 +35,6 @@ export async function createStoryCountIndexes(mongo: Db) { await createIndex({ tenantID: 1, createdAt: 1 }, { background: true }); } -// 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.APPROVED]: 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. diff --git a/src/core/server/models/story/counts/shared.ts b/src/core/server/models/story/counts/shared.ts index b53efe622..b40039bfd 100644 --- a/src/core/server/models/story/counts/shared.ts +++ b/src/core/server/models/story/counts/shared.ts @@ -4,10 +4,14 @@ import ms from "ms"; import logger from "coral-server/logger"; import { EncodedCommentActionCounts } from "coral-server/models/action/comment"; +import { + CommentStatusCounts, + createEmptyCommentStatusCounts, +} from "coral-server/models/comment/helpers"; +import { createCollection } from "coral-server/models/helpers"; import { Story } from "coral-server/models/story"; import { CommentModerationCountsPerQueue, - CommentStatusCounts, StoryCounts, } from "coral-server/models/story/counts"; import { AugmentedPipeline, AugmentedRedis } from "coral-server/services/redis"; @@ -15,7 +19,6 @@ import { AugmentedPipeline, AugmentedRedis } from "coral-server/services/redis"; import { createEmptyCommentModerationCountsPerQueue, createEmptyCommentModerationQueueCounts, - createEmptyCommentStatusCounts, } from "./empty"; /** @@ -47,9 +50,7 @@ const commentCountsModerationQueueQueuesKey = (tenantID: string) => * collection provides a reference to the stories collection used by the * counting system. */ -function collection(mongo: Db) { - return mongo.collection>("stories"); -} +const collection = createCollection("stories"); /** * recalculateSharedModerationQueueQueueCounts will reset the counts stored for diff --git a/src/core/server/models/story/index.ts b/src/core/server/models/story/index.ts index 31f83e12a..288399f3b 100644 --- a/src/core/server/models/story/index.ts +++ b/src/core/server/models/story/index.ts @@ -11,28 +11,25 @@ import { import { Connection, ConnectionInput, - resolveConnection, -} from "coral-server/models/helpers/connection"; -import { createConnectionOrderVariants, createIndexFactory, -} from "coral-server/models/helpers/indexing"; -import Query from "coral-server/models/helpers/query"; + Query, + resolveConnection, +} from "coral-server/models/helpers"; import { GlobalModerationSettings } from "coral-server/models/settings"; import { TenantResource } from "coral-server/models/tenant"; +import { createEmptyCommentStatusCounts } from "../comment/helpers"; +import { createCollection } from "../helpers/collection"; import { createEmptyCommentModerationQueueCounts, - createEmptyCommentStatusCounts, StoryCommentCounts, } from "./counts"; // Export everything under counts. export * from "./counts"; -function collection(mongo: Db) { - return mongo.collection>("stories"); -} +const collection = createCollection("stories"); export type StorySettings = DeepPartial< Pick & GlobalModerationSettings diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 1637158b6..d45167654 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -9,12 +9,13 @@ import { GQLMODERATION_MODE, GQLSettings, } from "coral-server/graph/tenant/schema/__generated__/types"; -import { createIndexFactory } from "coral-server/models/helpers/indexing"; +import { + createCollection, + createIndexFactory, +} from "coral-server/models/helpers"; import { Settings } from "coral-server/models/settings"; -function collection(mongo: Db) { - return mongo.collection>("tenants"); -} +const collection = createCollection("tenants"); /** * TenantResource references a given resource that should be owned by a specific @@ -160,14 +161,13 @@ export async function createTenant( enabled: false, smtp: {}, }, - karma: { - enabled: true, - thresholds: { - // By default, flaggers are reliable after one correct flag, and - // unreliable if there is an incorrect flag. - flag: { reliable: 1, unreliable: -1 }, - comment: { reliable: 1, unreliable: -1 }, - }, + recentCommentHistory: { + enabled: false, + // 7 days in seconds. + timeFrame: 604800, + // Rejection rate defaulting to 30%, once exceeded, comments will be + // pre-moderated. + triggerRejectionRate: 0.3, }, integrations: { akismet: { diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 359ed8029..0daf0314d 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -27,20 +27,17 @@ import logger from "coral-server/logger"; import { Connection, ConnectionInput, - resolveConnection, -} from "coral-server/models/helpers/connection"; -import { + createCollection, createConnectionOrderVariants, createIndexFactory, -} from "coral-server/models/helpers/indexing"; -import Query from "coral-server/models/helpers/query"; + Query, + resolveConnection, +} from "coral-server/models/helpers"; import { TenantResource } from "coral-server/models/tenant"; import { getLocalProfile, hasLocalProfile } from "./helpers"; -function collection(mongo: Db) { - return mongo.collection>("users"); -} +const collection = createCollection("users"); export interface LocalProfile { type: "local"; diff --git a/src/core/server/services/comments/actions.ts b/src/core/server/services/comments/actions.ts index bd5b85277..f8bc1dd54 100644 --- a/src/core/server/services/comments/actions.ts +++ b/src/core/server/services/comments/actions.ts @@ -16,11 +16,11 @@ import { retrieveUserAction, } from "coral-server/models/action/comment"; import { - getLatestRevision, retrieveComment, updateCommentActionCounts, } from "coral-server/models/comment"; import { Comment } from "coral-server/models/comment"; +import { getLatestRevision } from "coral-server/models/comment/helpers"; import { updateStoryActionCounts, updateStoryCounts, diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 8c3cd26e8..e14a0d00e 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -22,15 +22,15 @@ import { CreateCommentInput, editComment, EditCommentInput, - getLatestRevision, pushChildCommentIDOntoParent, removeCommentTag, retrieveComment, validateEditable, } from "coral-server/models/comment"; import { + getLatestRevision, hasAncestors, - hasVisibleStatus, + hasPublishedStatus, } from "coral-server/models/comment/helpers"; import { retrieveStory, @@ -95,7 +95,7 @@ export async function create( } // Check that the parent comment was visible. - if (!hasVisibleStatus(parent)) { + if (!hasPublishedStatus(parent)) { throw new CommentNotFoundError(parent.id); } @@ -116,6 +116,7 @@ export async function create( try { // Run the comment through the moderation phases. result = await processForModeration({ + mongo, nudge, story, tenant, @@ -225,7 +226,7 @@ export async function create( } // If this comment is visible (and not a reply), publish it. - if (!input.parentID && hasVisibleStatus(comment)) { + if (!input.parentID && hasPublishedStatus(comment)) { publishCommentCreated(publish, comment); } @@ -301,6 +302,7 @@ export async function edit( // Run the comment through the moderation phases. const { body, status, metadata, actions } = await processForModeration({ + mongo, story, tenant, comment: input, diff --git a/src/core/server/services/comments/pipeline/index.ts b/src/core/server/services/comments/pipeline/index.ts index 9476a4d1d..af7cce01e 100644 --- a/src/core/server/services/comments/pipeline/index.ts +++ b/src/core/server/services/comments/pipeline/index.ts @@ -1,3 +1,5 @@ +import { Db } from "mongodb"; + import { Omit, Promiseable, RequireProperty } from "coral-common/types"; import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types"; import { CreateActionInput } from "coral-server/models/action/comment"; @@ -27,6 +29,7 @@ export interface PhaseResult { } export interface ModerationPhaseContext { + mongo: Db; story: Story; tenant: Tenant; comment: RequireProperty, "body">; diff --git a/src/core/server/services/comments/pipeline/phases/index.ts b/src/core/server/services/comments/pipeline/phases/index.ts index 5aeaf3349..472cdbf33 100644 --- a/src/core/server/services/comments/pipeline/phases/index.ts +++ b/src/core/server/services/comments/pipeline/phases/index.ts @@ -3,10 +3,10 @@ import { IntermediateModerationPhase } from "coral-server/services/comments/pipe import { commentingDisabled } from "./commentingDisabled"; import { commentLength } from "./commentLength"; import { detectLinks } from "./detectLinks"; -import { karma } from "./karma"; import { linkify } from "./linkify"; import { preModerate } from "./preModerate"; import { purify } from "./purify"; +import { recentCommentHistory } from "./recentCommentHistory"; import { spam } from "./spam"; import { staff } from "./staff"; import { storyClosed } from "./storyClosed"; @@ -24,7 +24,7 @@ export const moderationPhases: IntermediateModerationPhase[] = [ purify, wordList, staff, - karma, + recentCommentHistory, spam, toxic, detectLinks, diff --git a/src/core/server/services/comments/pipeline/phases/karma.ts b/src/core/server/services/comments/pipeline/phases/karma.ts deleted file mode 100755 index c2ffff2ac..000000000 --- a/src/core/server/services/comments/pipeline/phases/karma.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - GQLCOMMENT_FLAG_REASON, - GQLCOMMENT_STATUS, -} from "coral-server/graph/tenant/schema/__generated__/types"; -import { ACTION_TYPE } from "coral-server/models/action/comment"; -import { - IntermediateModerationPhase, - IntermediatePhaseResult, -} from "coral-server/services/comments/pipeline"; -import { - getCommentTrustScore, - isReliableCommenter, -} from "coral-server/services/users/karma"; - -// This phase checks to see if the user making the comment is allowed to do so -// considering their reliability (Trust) status. -export const karma: IntermediateModerationPhase = ({ - tenant, - author, -}): IntermediatePhaseResult | void => { - // If the user is not a reliable commenter (passed the unreliability - // threshold by having too many rejected comments) then we can change the - // status of the comment to `SYSTEM_WITHHELD`, therefore pushing the user's - // comments away from the public eye until a moderator can manage them. This - // of course can only be applied if the comment's current status is `NONE`, - // we don't want to interfere if the comment was rejected. - if ( - tenant.karma.enabled && - isReliableCommenter(tenant.karma.thresholds, author) === false - ) { - // Add the flag related to Trust to the comment. - return { - status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, - actions: [ - { - userID: null, - actionType: ACTION_TYPE.FLAG, - reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC, - metadata: { - trust: getCommentTrustScore(author), - }, - }, - ], - }; - } -}; diff --git a/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts b/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts new file mode 100644 index 000000000..7f19e5f6e --- /dev/null +++ b/src/core/server/services/comments/pipeline/phases/recentCommentHistory.ts @@ -0,0 +1,61 @@ +import { DateTime } from "luxon"; + +import { + GQLCOMMENT_FLAG_REASON, + GQLCOMMENT_STATUS, +} from "coral-server/graph/tenant/schema/__generated__/types"; +import { ACTION_TYPE } from "coral-server/models/action/comment"; +import { + calculateRejectionRate, + retrieveRecentStatusCounts, +} from "coral-server/models/comment"; +import { + IntermediatePhaseResult, + ModerationPhaseContext, +} from "coral-server/services/comments/pipeline"; + +export const recentCommentHistory = async ({ + tenant, + author, + mongo, + now, +}: Pick< + ModerationPhaseContext, + "author" | "tenant" | "now" | "mongo" +>): Promise => { + // Ensure this mode is enabled. + if (!tenant.recentCommentHistory.enabled) { + return; + } + + // Get the time frame. + const since = DateTime.fromJSDate(now) + .plus({ seconds: -tenant.recentCommentHistory.timeFrame }) + .toJSDate(); + + // Get the comment rates for this User. + const counts = await retrieveRecentStatusCounts( + mongo, + tenant.id, + since, + author.id + ); + + // Get the rejection rate. + const rate = calculateRejectionRate(counts); + if (rate >= tenant.recentCommentHistory.triggerRejectionRate) { + return { + status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD, + actions: [ + { + userID: null, + actionType: ACTION_TYPE.FLAG, + reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_RECENT_HISTORY, + metadata: { + rate, + }, + }, + ], + }; + } +}; diff --git a/src/core/server/services/events/comments.ts b/src/core/server/services/events/comments.ts index 2597ebf41..b297c909e 100644 --- a/src/core/server/services/events/comments.ts +++ b/src/core/server/services/events/comments.ts @@ -4,7 +4,7 @@ import { GQLMODERATION_QUEUE, } from "coral-server/graph/tenant/schema/__generated__/types"; import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher"; -import { Comment, hasVisibleStatus } from "coral-server/models/comment"; +import { Comment, hasPublishedStatus } from "coral-server/models/comment"; import { CommentModerationQueueCounts } from "coral-server/models/story/counts"; export function publishCommentStatusChanges( @@ -31,7 +31,7 @@ export function publishCommentReplyCreated( publish: Publisher, comment: Pick ) { - if (comment.ancestorIDs.length > 0 && hasVisibleStatus(comment)) { + if (comment.ancestorIDs.length > 0 && hasPublishedStatus(comment)) { publish({ channel: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED, payload: { @@ -46,7 +46,7 @@ export function publishCommentCreated( publish: Publisher, comment: Pick ) { - if (!comment.parentID && hasVisibleStatus(comment)) { + if (!comment.parentID && hasPublishedStatus(comment)) { publish({ channel: SUBSCRIPTION_CHANNELS.COMMENT_CREATED, payload: { diff --git a/src/core/server/services/users/karma.ts b/src/core/server/services/users/karma.ts deleted file mode 100644 index bc9f65933..000000000 --- a/src/core/server/services/users/karma.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { get } from "lodash"; - -import { GQLKarmaThresholds } from "coral-server/graph/tenant/schema/__generated__/types"; -import { User } from "coral-server/models/user"; - -export const getCommentTrustScore = (user: User): number => - get(user, "metadata.trust.comment.karma", 0); - -export const isReliableCommenter = ( - thresholds: GQLKarmaThresholds, - user: User -): boolean | null => { - const score = getCommentTrustScore(user); - - if (score >= thresholds.comment.reliable) { - return true; - } else if (score <= thresholds.comment.unreliable) { - return false; - } - - return null; -}; diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 1155c90a4..3c5b0f464 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -235,6 +235,28 @@ configure-auth-oidc-jwksURI = JWKS URI configure-auth-oidc-useLoginOn = Use OpenID Connect login on ### Moderation + +### Recent Comment History + +configure-moderation-recentCommentHistory-title = Recent comment history +configure-moderation-recentCommentHistory-timeFrame = Recent comment history timeframe +configure-moderation-recentCommentHistory-timeFrame-description = + Time period over which a commenter's rejection rate is calcualted + and submitted comments are counted. +configure-moderation-recentCommentHistory-enabled = Recent comment history filter +configure-moderation-recentCommentHistory-enabled-description = + Prevents repeat offenders from publishing comments without approval. + After a commenter's rejection rate rises above the defined threshold + below, their next submitted comments are sent to Pending for + moderator approval. The filter is removed when their rejection rate + falls below the threshold. +configure-moderation-recentCommentHistory-triggerRejectionRate = Rejection rate threshold +configure-moderation-recentCommentHistory-triggerRejectionRate-description = + Calculated by the number of rejected comments divided by the sum of + a commenter’s rejected and published comments, over the recent + comment history timeframe (does not include comments pending for + toxicity, spam or pre-moderation.) + #### Pre-Moderation configure-moderation-preModeration-title = Pre-moderation configure-moderation-preModeration-explanation = @@ -264,13 +286,13 @@ configure-moderation-akismet-accountNote = in your Akismet account: https://akismet.com/account/ configure-moderation-akismet-siteURL = Site URL + +#### Perspective configure-moderation-perspective-title = Perspective Toxic Comment Filter configure-moderation-perspective-explanation = Using the Perspective API, the Toxic Comment filter warns users when comments exceed the predefined toxicity threshold. Comments with a toxicity score above the threshold will not be published and are placed in the Pending Queue for review by a moderator. If approved by a moderator, the comment will be published. - -#### Perspective configure-moderation-perspective-filter = Toxic Comment Filter configure-moderation-perspective-toxicityThreshold = Toxicity Threshold configure-moderation-perspective-toxicityThresholdDescription = @@ -363,7 +385,7 @@ moderate-marker-bannedWord = Banned Word moderate-marker-suspectWord = Suspect Word moderate-marker-spam = Spam moderate-marker-toxic = Toxic -moderate-marker-karma = Karma +moderate-marker-recentHistory = Recent History moderate-marker-bodyCount = Body Count moderate-marker-offensive = Offensive @@ -483,6 +505,20 @@ moderate-user-drawer-suspension = *[other] unknown unit } + +moderate-user-drawer-recent-history-title = Recent comment history +moderate-user-drawer-recent-history-calculated = + Calculated over the last { framework-timeago-time } +moderate-user-drawer-recent-history-rejected = Rejected +moderate-user-drawer-recent-history-tooltip-title = How is this calculated? +moderate-user-drawer-recent-history-tooltip-body = + Rejected comments divided by the sum of rejected and + published comments, during the recent comment history + time frame. +moderate-user-drawer-recent-history-tooltip-button = + .aria-label = Toggle recent comment history tooltip +moderate-user-drawer-recent-history-tooltip-submitted = Submitted + ## Create Username createUsername-createUsernameHeader = Create Username @@ -537,7 +573,7 @@ community-filter-roleSelectField = .aria-label = Search by role community-filter-statusSelectField = -.aria-label = Search by user status + .aria-label = Search by user status community-changeRoleButton = .aria-label = Change role diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl index f282cee11..38c7bd98f 100644 --- a/src/locales/en-US/framework.ftl +++ b/src/locales/en-US/framework.ftl @@ -100,23 +100,28 @@ framework-markdownEditor-toggleFullscreen = Toggle Fullscreen framework-markdownEditor-markdownGuide = Markdown Guide ### Duration Field -framework-durationField-seconds = { $value -> - [1] Second - *[others] Seconds -} -framework-durationField-minutes = { $value -> - [1] Minute - *[others] Minutes -} -framework-durationField-hours = { $value -> - [1] Hour - *[others] Hours -} -framework-durationField-days = { $value -> - [1] Day - *[others] Days -} -framework-durationField-weeks = { $value -> - [1] Week - *[others] Weeks -} + +framework-durationField-unit = + { $unit -> + [second] { $value -> + [1] Second + *[other] Seconds + } + [minute] { $value -> + [1] Minute + *[other] Minutes + } + [hour] { $value -> + [1] Hour + *[other] Hours + } + [day] { $value -> + [1] Day + *[other] Days + } + [week] { $value -> + [1] Week + *[other] Weeks + } + *[other] unknown unit + }