-
+
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 (
+ }>
+
+ }>
+
+
+
+ {({ input, meta }) => (
+ <>
+
+
+ >
+ )}
+
+
+ }>
+
+ }
+ >
+
+ 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.
+
+
+
+
+
+
+
+
+ {({ 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"
>
+
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 (
-
+ />
);
};
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
+ }