[CORL-174] premod user (#2572)

* add types for setting user premod

* add types, fields, and mutations for user premod

* enforce premoderation on users marked premod

* add user premod to dropdown

* update specs

* fix tests

* update premod workflow to match v4

* allow user filtering by premod status

* add premod user history to account actions drawer

* update snaps

* fix permissions on new premod fields

* update strings

* fix tests

* clean up formatting and copy

* fix: nullable fixes pre-migration
This commit is contained in:
Tessa Thornton
2019-09-25 21:31:12 +00:00
committed by Wyatt Johnson
parent a5c3e94751
commit e61e62a238
34 changed files with 925 additions and 38 deletions
@@ -1,14 +1,19 @@
import React, { FunctionComponent } from "react";
import BanAction, { BanActionProps } from "./BanAction";
import PremodAction, { PremodActionProps } from "./PremodAction";
import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction";
import UsernameChangeAction, {
UsernameChangeActionProps,
} from "./UsernameChangeAction";
export interface HistoryActionProps {
kind: "username" | "suspension" | "ban";
action: UsernameChangeActionProps | SuspensionActionProps | BanActionProps;
kind: "username" | "suspension" | "ban" | "premod";
action:
| UsernameChangeActionProps
| SuspensionActionProps
| BanActionProps
| PremodActionProps;
}
const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
@@ -22,6 +27,8 @@ const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
return <SuspensionAction {...action as SuspensionActionProps} />;
case "ban":
return <BanAction {...action as BanActionProps} />;
case "premod":
return <PremodAction {...action as PremodActionProps} />;
default:
return null;
}
@@ -0,0 +1,19 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
export interface PremodActionProps {
action: "created" | "removed";
}
const PremodAction: FunctionComponent<PremodActionProps> = ({ action }) =>
action === "created" ? (
<Localized id="moderate-user-drawer-account-history-premod-set">
<span>Set always premoderate</span>
</Localized>
) : (
<Localized id="moderate-user-drawer-account-history-premod-removed">
<span>Removed always premoderate</span>
</Localized>
);
export default PremodAction;
@@ -107,6 +107,21 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
});
});
// FIXME: (wyattjoh) once migration has been performed, remove check
if (user.status.premod) {
// Merge in all the premod history items.
user.status.premod.history.forEach(record => {
history.push({
kind: "premod",
action: {
action: record.active ? "created" : "removed",
},
date: new Date(record.createdAt),
takenBy: record.createdBy ? record.createdBy.username : system,
});
});
}
user.status.username.history.forEach((record, i) => {
history.push({
kind: "username",
@@ -190,6 +205,15 @@ const enhanced = withFragmentContainer<any>({
createdAt
}
}
premod {
history {
active
createdBy {
username
}
createdAt
}
}
suspension {
history {
active
@@ -0,0 +1,3 @@
.card {
max-width: 500px;
}
@@ -0,0 +1,79 @@
import {
Button,
Card,
CardCloseButton,
Flex,
HorizontalGutter,
Modal,
Typography,
} from "coral-ui/components";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import NotAvailable from "coral-admin/components/NotAvailable";
import styles from "./PremodModal.css";
interface Props {
username: string | null;
open: boolean;
onClose: () => void;
onConfirm: () => void;
}
const PremodModal: FunctionComponent<Props> = ({
open,
onClose,
onConfirm,
username,
}) => {
return (
<Modal open={open} onClose={onClose} aria-labelledby="PremodModal-title">
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.card}>
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
<HorizontalGutter size="double">
<HorizontalGutter>
<Localized
id="community-premodModal-areYouSure"
strong={<strong />}
$username={username || <NotAvailable />}
>
<Typography variant="header2" id="PremodModal-title">
Are you sure you want to always premoderate{" "}
<strong>{username || <NotAvailable />}</strong>?
</Typography>
</Localized>
<Localized id="community-premodModal-consequence">
<Typography>
Note: Always premoderating this user will place all of their
comments in the Pre-Moderate queue.
</Typography>
</Localized>
</HorizontalGutter>
<Flex justifyContent="flex-end" itemGutter>
<Localized id="community-premodModal-cancel">
<Button variant="outlined" onClick={onClose}>
Cancel
</Button>
</Localized>
<Localized id="community-premodModal-premodUser">
<Button
variant="filled"
color="primary"
onClick={onConfirm}
ref={lastFocusableRef}
>
Yes, always premoderate user
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</Card>
)}
</Modal>
);
};
export default PremodModal;
@@ -0,0 +1,82 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { PremodUserMutation as MutationTypes } from "coral-admin/__generated__/PremodUserMutation.graphql";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
let clientMutationId = 0;
const PremodUserMutation = createMutation(
"premodUser",
(environment: Environment, input: MutationInput<MutationTypes>) => {
const viewer = getViewer(environment)!;
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation PremodUserMutation($input: PremodUserInput!) {
premodUser(input: $input) {
user {
id
status {
current
premod {
active
history {
active
createdAt
createdBy {
id
username
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
premodUser: {
user: {
id: input.userID,
status: {
current: lookup<GQLUser>(
environment,
input.userID
)!.status.current.concat(GQLUSER_STATUS.PREMOD),
premod: {
active: true,
history: [
{
active: true,
createdAt: new Date(),
createdBy: {
id: viewer.id,
username: viewer.username,
},
},
],
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default PremodUserMutation;
@@ -0,0 +1,82 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { RemoveUserPremodMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserPremodMutation.graphql";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
let clientMutationId = 0;
const RemoveUserPremodMutation = createMutation(
"removeUserPremod",
(environment: Environment, input: MutationInput<MutationTypes>) => {
const viewer = getViewer(environment)!;
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RemoveUserPremodMutation($input: RemovePremodUserInput!) {
removeUserPremod(input: $input) {
user {
id
status {
current
premod {
active
history {
active
createdAt
createdBy {
id
username
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
removeUserPremod: {
user: {
id: input.userID,
status: {
current: lookup<GQLUser>(
environment,
input.userID
)!.status.current.filter(s => s !== GQLUSER_STATUS.PREMOD),
premod: {
active: false,
history: [
{
active: false,
createdAt: new Date(),
createdBy: {
id: viewer.id,
username: viewer.username,
},
},
],
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default RemoveUserPremodMutation;
@@ -7,6 +7,7 @@ import { PropTypesOf } from "coral-ui/types";
interface Props {
banned: boolean;
suspended: boolean;
premod: boolean;
}
const render = (
@@ -39,6 +40,15 @@ const UserStatus: FunctionComponent<Props> = props => {
</Localized>
);
}
if (props.premod) {
return render(
"warning",
// tslint:disable-next-line:jsx-wrap-multiline
<Localized id="userStatus-premod">
<div>Always Premoderated</div>
</Localized>
);
}
return render(
"success",
// tslint:disable-next-line:jsx-wrap-multiline
@@ -18,8 +18,11 @@ interface Props {
onRemoveBan: () => void;
onSuspend: () => void;
onRemoveSuspension: () => void;
onPremod: () => void;
onRemovePremod: () => void;
banned: boolean;
suspended: boolean;
premod: boolean;
children: React.ReactNode;
fullWidth?: boolean;
}
@@ -29,8 +32,11 @@ const UserStatusChange: FunctionComponent<Props> = ({
onRemoveBan,
onSuspend,
onRemoveSuspension,
onPremod,
onRemovePremod,
banned,
suspended,
premod,
children,
fullWidth = true,
}) => (
@@ -42,21 +48,8 @@ const UserStatusChange: FunctionComponent<Props> = ({
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Dropdown>
{!banned && (
<Localized id="community-userStatus-banUser">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
onBan();
toggleVisibility();
}}
>
Ban User
</DropdownButton>
</Localized>
)}
{banned && (
<Localized id="community-userStatus-removeBan">
{banned ? (
<Localized id="community-userStatus-removeUserBan">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
@@ -64,25 +57,24 @@ const UserStatusChange: FunctionComponent<Props> = ({
toggleVisibility();
}}
>
Remove Ban
Remove ban
</DropdownButton>
</Localized>
)}
{!suspended && (
<Localized id="community-userStatus-suspendUser">
) : (
<Localized id="community-userStatus-ban">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
onSuspend();
onBan();
toggleVisibility();
}}
>
Suspend User
Ban
</DropdownButton>
</Localized>
)}
{suspended && (
<Localized id="community-userStatus-removeSuspension">
{suspended ? (
<Localized id="community-userStatus-removeUserSuspension">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
@@ -90,7 +82,44 @@ const UserStatusChange: FunctionComponent<Props> = ({
toggleVisibility();
}}
>
Remove Suspension
Remove suspension
</DropdownButton>
</Localized>
) : (
<Localized id="community-userStatus-suspend">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
onSuspend();
toggleVisibility();
}}
>
Suspend
</DropdownButton>
</Localized>
)}
{premod ? (
<Localized id="community-userStatus-removePremod">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
onRemovePremod();
toggleVisibility();
}}
>
Remove always pre-moderate
</DropdownButton>
</Localized>
) : (
<Localized id="community-userStatus-premodUser">
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
onPremod();
toggleVisibility();
}}
>
Always pre-moderate
</DropdownButton>
</Localized>
)}
@@ -12,7 +12,10 @@ import { GQLUSER_ROLE } from "coral-framework/schema";
import ButtonPadding from "../ButtonPadding";
import BanModal from "./BanModal";
import BanUserMutation from "./BanUserMutation";
import PremodModal from "./PremodModal";
import PremodUserMutation from "./PremodUserMutation";
import RemoveUserBanMutation from "./RemoveUserBanMutation";
import RemoveUserPremodMudtaion from "./RemoveUserPremodMutation";
import RemoveUserSuspensionMutation from "./RemoveUserSuspensionMutation";
import SuspendModal from "./SuspendModal";
import SuspendUserMutation from "./SuspendUserMutation";
@@ -31,6 +34,9 @@ const UserStatusChangeContainer: FunctionComponent<Props> = props => {
const suspendUser = useMutation(SuspendUserMutation);
const removeUserBan = useMutation(RemoveUserBanMutation);
const removeUserSuspension = useMutation(RemoveUserSuspensionMutation);
const premodUser = useMutation(PremodUserMutation);
const removeUserPremod = useMutation(RemoveUserPremodMudtaion);
const [showPremod, setShowPremod] = useState<boolean>(false);
const [showBanned, setShowBanned] = useState<boolean>(false);
const [showSuspend, setShowSuspend] = useState<boolean>(false);
const [showSuspendSuccess, setShowSuspendSuccess] = useState<boolean>(false);
@@ -59,6 +65,31 @@ const UserStatusChangeContainer: FunctionComponent<Props> = props => {
removeUserSuspension({ userID: user.id });
}, [user, removeUserSuspension]);
const handlePremod = useCallback(() => {
// FIXME: (wyattjoh) once migration has been performed, remove check
if (user.status.premod && user.status.premod.active) {
return;
}
setShowPremod(true);
}, [user, setShowPremod]);
const handlePremodConfirm = useCallback(() => {
premodUser({ userID: user.id });
setShowPremod(false);
}, [premodUser, user, setShowPremod]);
const hidePremod = useCallback(() => {
setShowPremod(false);
}, [setShowPremod]);
const handleRemovePremod = useCallback(() => {
// FIXME: (wyattjoh) once migration has been performed, remove check
if (!user.status.premod || !user.status.premod.active) {
return;
}
removeUserPremod({ userID: user.id });
}, [user, premodUser]);
const handleSuspendModalClose = useCallback(() => {
setShowSuspend(false);
setShowSuspendSuccess(false);
@@ -103,8 +134,12 @@ const UserStatusChangeContainer: FunctionComponent<Props> = props => {
onRemoveBan={handleRemoveBan}
onSuspend={handleSuspend}
onRemoveSuspension={handleRemoveSuspension}
onPremod={handlePremod}
onRemovePremod={handleRemovePremod}
banned={user.status.ban.active}
suspended={user.status.suspension.active}
// FIXME: (wyattjoh) once migration has been performed, remove check
premod={Boolean(user.status.premod && user.status.premod.active)}
fullWidth={fullWidth}
>
<UserStatusContainer user={user} />
@@ -117,6 +152,12 @@ const UserStatusChangeContainer: FunctionComponent<Props> = props => {
organizationName={settings.organization.name}
onConfirm={handleSuspendConfirm}
/>
<PremodModal
username={user.username}
open={showPremod}
onClose={hidePremod}
onConfirm={handlePremodConfirm}
/>
<BanModal
username={user.username}
open={showBanned}
@@ -140,6 +181,9 @@ const enhanced = withFragmentContainer<Props>({
suspension {
active
}
premod {
active
}
}
...UserStatusContainer_user
}
@@ -16,6 +16,7 @@ const UserStatusContainer: FunctionComponent<Props> = props => {
<UserStatus
banned={props.user.status.current.includes(GQLUSER_STATUS.BANNED)}
suspended={props.user.status.current.includes(GQLUSER_STATUS.SUSPENDED)}
premod={props.user.status.current.includes(GQLUSER_STATUS.PREMOD)}
/>
);
};
@@ -178,6 +178,11 @@ const UserTableFilter: FunctionComponent<Props> = props => (
<Localized id="userStatus-banned">
<Option value={GQLUSER_STATUS.BANNED}>Banned</Option>
</Localized>
<Localized id="userStatus-premod">
<Option value={GQLUSER_STATUS.PREMOD}>
Always Premoderate
</Option>
</Localized>
</SelectField>
</Localized>
</Flex>
@@ -166,6 +166,11 @@ exports[`renders community 1`] = `
>
Banned
</option>
<option
value="PREMOD"
>
Always pre-moderate
</option>
</select>
<span
aria-hidden={true}
@@ -825,6 +830,11 @@ exports[`renders empty community 1`] = `
>
Banned
</option>
<option
value="PREMOD"
>
Always pre-moderate
</option>
</select>
<span
aria-hidden={true}
@@ -446,7 +446,7 @@ it("suspend user", async () => {
TestRenderer.act(() => {
within(popup)
.getByText("Suspend User", { selector: "button" })
.getByText("Suspend", { selector: "button" })
.props.onClick();
});
@@ -515,7 +515,7 @@ it("remove user suspension", async () => {
TestRenderer.act(() => {
within(popup)
.getByText("Remove Suspension", { selector: "button" })
.getByText("Remove suspension", { selector: "button" })
.props.onClick();
});
expect(resolvers.Mutation!.removeUserSuspension!.called).toBe(true);
@@ -566,7 +566,7 @@ it("suspend user with custom timeout", async () => {
TestRenderer.act(() => {
within(popup)
.getByText("Suspend User", { selector: "button" })
.getByText("Suspend", { selector: "button" })
.props.onClick();
});
@@ -632,7 +632,7 @@ it("suspend user with custom message", async () => {
TestRenderer.act(() => {
within(popup)
.getByText("Suspend User", { selector: "button" })
.getByText("Suspend", { selector: "button" })
.props.onClick();
});
@@ -704,7 +704,7 @@ it("ban user", async () => {
TestRenderer.act(() => {
within(popup)
.getByText("Ban User", { selector: "button" })
.getByText("Ban", { selector: "button" })
.props.onClick();
});
@@ -767,7 +767,7 @@ it("ban user with custom message", async () => {
TestRenderer.act(() => {
within(popup)
.getByText("Ban User", { selector: "button" })
.getByText("Ban", { selector: "button" })
.props.onClick();
});
@@ -853,7 +853,7 @@ it("remove user ban", async () => {
TestRenderer.act(() => {
within(popup)
.getByText("Remove Ban", { selector: "button" })
.getByText("Remove ban", { selector: "button" })
.props.onClick();
});
+4
View File
@@ -321,6 +321,10 @@ export const baseUser = createFixture<GQLUser>({
active: false,
history: [],
},
premod: {
active: false,
history: [],
},
},
});
@@ -40,6 +40,10 @@ export function createUserStatus(banned: boolean = false): GQLUserStatus {
username: {
history: [],
},
premod: {
active: false,
history: [],
},
};
}
+5
View File
@@ -39,6 +39,10 @@ export function createUserStatus(banned: boolean = false) {
until: null,
history: [],
},
premod: {
active: false,
history: [],
},
};
}
@@ -143,6 +147,7 @@ export function createComment(author?: GQLUser) {
COMMENT_DETECTED_LINKS: 0,
COMMENT_DETECTED_BANNED_WORD: 0,
COMMENT_DETECTED_SUSPECT_WORD: 0,
COMMENT_DETECTED_PREMOD_USER: 0,
},
},
},
+2
View File
@@ -314,4 +314,6 @@ export enum ERROR_CODES {
* all non-admin users.
*/
RAW_QUERY_NOT_AUTHORIZED = "RAW_QUERY_NOT_AUTHORIZED",
USER_ALREADY_PREMOD = "USER_ALREADY_PREMOD",
}
+8
View File
@@ -593,6 +593,14 @@ export class UserAlreadySuspendedError extends CoralError {
}
}
export class UserAlreadyPremoderated extends CoralError {
constructor() {
super({
code: ERROR_CODES.USER_ALREADY_PREMOD,
});
}
}
export class UserAlreadyBannedError extends CoralError {
constructor() {
super({
+1
View File
@@ -54,4 +54,5 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
USERNAME_UPDATED_WITHIN_WINDOW: "error-usernameAlreadyUpdated",
PERSISTED_QUERY_NOT_FOUND: "error-persistedQueryNotFound",
RAW_QUERY_NOT_AUTHORIZED: "error-rawQueryNotAuthorized",
USER_ALREADY_PREMOD: "error-userAlreadyPremod",
};
@@ -40,6 +40,7 @@ const statusFilter = (
case GQLUSER_STATUS.ACTIVE:
return {
"status.ban.active": false,
"status.premod.active": false,
"status.suspension.history": {
$not: {
$elemMatch: {
@@ -55,6 +56,8 @@ const statusFilter = (
};
case GQLUSER_STATUS.BANNED:
return { "status.ban.active": true };
case GQLUSER_STATUS.PREMOD:
return { "status.premod.active": true };
case GQLUSER_STATUS.SUSPENDED:
return {
"status.suspension.history": {
@@ -8,8 +8,10 @@ import {
createToken,
deactivateToken,
ignore,
premod,
removeBan,
removeIgnore,
removePremod,
removeSuspension,
requestAccountDeletion,
requestCommentsDownload,
@@ -38,6 +40,8 @@ import {
GQLDeleteUserAccountInput,
GQLIgnoreUserInput,
GQLInviteUsersInput,
GQLPremodUserInput,
GQLRemovePremodUserInput,
GQLRemoveUserBanInput,
GQLRemoveUserIgnoreInput,
GQLRemoveUserSuspensionInput,
@@ -209,6 +213,8 @@ export const Users = (ctx: TenantContext) => ({
input.message,
ctx.now
),
premodUser: async (input: GQLPremodUserInput) =>
premod(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
suspend: async (input: GQLSuspendUserInput) =>
suspend(
ctx.mongo,
@@ -224,6 +230,8 @@ export const Users = (ctx: TenantContext) => ({
removeBan(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
removeSuspension: async (input: GQLRemoveUserSuspensionInput) =>
removeSuspension(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
removeUserPremod: async (input: GQLRemovePremodUserInput) =>
removePremod(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
ignore: async (input: GQLIgnoreUserInput) =>
ignore(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
removeIgnore: async (input: GQLRemoveUserIgnoreInput) =>
@@ -181,6 +181,14 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
user: await ctx.mutators.Users.suspend(input),
clientMutationId: input.clientMutationId,
}),
premodUser: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.premodUser(input),
clientMutationId: input.clientMutationId,
}),
removeUserPremod: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.removeUserPremod(input),
clientMutationId: input.clientMutationId,
}),
removeUserSuspension: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.removeSuspension(input),
clientMutationId: input.clientMutationId,
@@ -0,0 +1,14 @@
import { GQLPremodStatusTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types";
import * as user from "coral-server/models/user";
export type PremodStatusInput = user.ConsolidatedPremodStatus & {
userID: string;
};
export const PremodStatus: Required<
GQLPremodStatusTypeResolver<PremodStatusInput>
> = {
active: ({ active }) => active,
history: ({ history, userID }) =>
history.map(status => ({ ...status, userID })),
};
@@ -0,0 +1,16 @@
import { GQLPremodStatusHistoryTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types";
import * as user from "coral-server/models/user";
export const PremodStatusHistory: Required<
GQLPremodStatusHistoryTypeResolver<user.PremodStatusHistory>
> = {
active: ({ active }) => active,
createdBy: ({ createdBy }, input, ctx) => {
if (createdBy) {
return ctx.loaders.Users.user.load(createdBy);
}
return null;
},
createdAt: ({ createdAt }) => createdAt,
};
@@ -5,6 +5,7 @@ import {
import * as user from "coral-server/models/user";
import { BanStatusInput } from "./BanStatus";
import { PremodStatusInput } from "./PremodStatus";
import { SuspensionStatusInput } from "./SuspensionStatus";
import { UsernameStatusInput } from "./UsernameStatus";
@@ -29,6 +30,12 @@ export const UserStatus: Required<
statuses.push(GQLUSER_STATUS.SUSPENDED);
}
// If they are set to mandatory premod, then mark it.
// FIXME: (wyattjoh) once migration has been performed, remove check
if (consolidatedStatus.premod && consolidatedStatus.premod.active) {
statuses.push(GQLUSER_STATUS.PREMOD);
}
// If no other statuses were applied, then apply the active status.
if (statuses.length === 0) {
statuses.push(GQLUSER_STATUS.ACTIVE);
@@ -48,4 +55,17 @@ export const UserStatus: Required<
...user.consolidateUserSuspensionStatus(suspension),
userID,
}),
// FIXME: (wyattjoh) once migration has been performed, return PremodStatusInput only
premod: ({ premod, userID }): PremodStatusInput | null => {
const status = user.consolidateUserPremodStatus(premod);
// FIXME: (wyattjoh) once migration has been performed, remove check
if (!status) {
return null;
}
return {
...status,
userID,
};
},
};
@@ -29,6 +29,8 @@ import { ModerationQueue } from "./ModerationQueue";
import { ModerationQueues } from "./ModerationQueues";
import { Mutation } from "./Mutation";
import { OIDCAuthIntegration } from "./OIDCAuthIntegration";
import { PremodStatus } from "./PremodStatus";
import { PremodStatusHistory } from "./PremodStatusHistory";
import { Profile } from "./Profile";
import { Query } from "./Query";
import { RecentCommentHistory } from "./RecentCommentHistory";
@@ -72,6 +74,8 @@ const Resolvers: GQLResolver = {
ModerationQueues,
Mutation,
OIDCAuthIntegration,
PremodStatus,
PremodStatusHistory,
Profile,
Query,
RecentCommentHistory,
@@ -141,6 +141,11 @@ enum COMMENT_FLAG_DETECTED_REASON {
recent history of rejected comments.
"""
COMMENT_DETECTED_RECENT_HISTORY
"""
COMMENT_DETECTED_PREMOD_USER is used when a Comment author has been tagged as requiring premoderation
"""
COMMENT_DETECTED_PREMOD_USER
}
"""
@@ -157,6 +162,7 @@ enum COMMENT_FLAG_REASON {
COMMENT_DETECTED_BANNED_WORD
COMMENT_DETECTED_SUSPECT_WORD
COMMENT_DETECTED_RECENT_HISTORY
COMMENT_DETECTED_PREMOD_USER
}
"""
@@ -191,6 +197,7 @@ type FlagReasonActionCounts {
COMMENT_DETECTED_BANNED_WORD: Int!
COMMENT_DETECTED_SUSPECT_WORD: Int!
COMMENT_DETECTED_RECENT_HISTORY: Int!
COMMENT_DETECTED_PREMOD_USER: Int!
}
type Flag {
@@ -1440,6 +1447,34 @@ type SuspensionStatus {
history: [SuspensionStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
}
type PremodStatusHistory {
"""
active when true, indicates that the given user is premodded.
"""
active: Boolean!
"""
createdBy is the user that flagged the commenter as pre-mod
"""
createdBy: User!
"""
createdAt is the time the user was set to pre-mod
"""
createdAt: Time!
}
type PremodStatus {
"""
active when true, indicates that the given user is set to pre-mod.
"""
active: Boolean!
"""
history is the list of all suspension events against a specific User.
"""
history: [PremodStatusHistory!]!
}
type UsernameHistory {
"""
username is the username that was assigned
@@ -1503,6 +1538,13 @@ type UserStatus {
changes.
"""
suspension: SuspensionStatus!
"""
premod stores the user premod status as well as the history of changes.
FIXME: (wyattjoh) once migration has been performed, make non-nullable
"""
premod: PremodStatus @auth(roles: [ADMIN, MODERATOR])
}
"""
@@ -1524,6 +1566,11 @@ enum USER_STATUS {
SUSPENDED is used when a User is currently suspended.
"""
SUSPENDED
"""
PREMOD is used when a User is currently set to require pre-moderation.
"""
PREMOD
}
enum DIGEST_FREQUENCY {
@@ -4771,6 +4818,62 @@ type RemoveUserSuspensionPayload {
clientMutationId: String!
}
##################
# premodUser
##################
input PremodUserInput {
"""
userID is the ID of the User that should be premodded.
"""
userID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type PremodUserPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# removePremod
##################
input RemovePremodUserInput {
"""
userID is the ID of the User that should be premodded.
"""
userID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RemovePremodUserPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# ignoreUser
##################
@@ -5196,6 +5299,18 @@ type Mutation {
requestUserCommentsDownload(
input: RequestUserCommentsDownloadInput!
): RequestUserCommentsDownloadPayload! @auth(roles: [ADMIN])
"""
premodUser sets a user to mandatory premod
"""
premodUser(input: PremodUserInput!): PremodUserPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
removeUserPremod removes a user from mandatory premod
"""
removeUserPremod(input: RemovePremodUserInput!): RemovePremodUserPayload!
@auth(roles: [ADMIN, MODERATOR])
}
##################
@@ -5367,7 +5482,7 @@ type Subscription {
commentReleased returns when a Comment on a premoderated stream is approved
"""
commentReleased(storyID: ID!): CommentReleasedPayload!
"""
commentReplyCreated returns when a Comment is posted in the ancestor chain of
comments.
@@ -19,6 +19,7 @@ Object {
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_PREMOD_USER": 0,
"COMMENT_DETECTED_RECENT_HISTORY": 0,
"COMMENT_DETECTED_SPAM": 0,
"COMMENT_DETECTED_SUSPECT_WORD": 0,
+183 -4
View File
@@ -14,6 +14,7 @@ import {
PasswordResetTokenExpired,
TokenNotFoundError,
UserAlreadyBannedError,
UserAlreadyPremoderated,
UserAlreadySuspendedError,
UsernameAlreadySetError,
UserNotFoundError,
@@ -21,6 +22,7 @@ import {
import {
GQLBanStatus,
GQLDIGEST_FREQUENCY,
GQLPremodStatus,
GQLSuspensionStatus,
GQLTimeRange,
GQLUSER_ROLE,
@@ -236,6 +238,42 @@ export interface UsernameStatus {
history: UsernameHistory[];
}
/**
* PremodStatusHistory is the history of premod status changes
* against a specific User.
*/
export interface PremodStatusHistory {
/**
* active when true, indicates that the given user is premodded.
*/
active: boolean;
/**
* createdBy is the ID for the User that premodded the User. If `null`, the
* premod was created by the system.
*/
createdBy?: string;
/**
* createdAt is the time that the given premod status was set.
*/
createdAt: Date;
}
/**
* PremodStatus is the status of whether a user is set to mandatory premod
*/
export interface PremodStatus {
/**
* active when true, indicates that the given user is set to mandatory premod.
*/
active: boolean;
/**
* history is a list of previous enable/disable of premod status
*/
history: PremodStatusHistory[];
}
/**
* UserStatus stores the user status information regarding moderation state.
*/
@@ -255,6 +293,14 @@ export interface UserStatus {
* username stores the history of username changes for this user.
*/
username: UsernameStatus;
/**
* premod stores whether a user is set to mandatory premod and history of
* premod status.
*
* FIXME: (wyattjoh) set defaults during migration
*/
premod?: PremodStatus;
}
/**
@@ -503,6 +549,7 @@ async function findOrCreateUserInput(
},
suspension: { history: [] },
ban: { active: false, history: [] },
premod: { active: false, history: [] },
},
notifications: {
onReply: false,
@@ -1358,6 +1405,130 @@ async function retrieveConnection(
return resolveConnection(query, input, user => user.createdAt);
}
/**
* premodUser will set a user to mandatory premod.
*
* @param mongo the mongo database handle
* @param tenantID the Tenant's ID where the User exists
* @param id the ID of the user being banned
* @param createdBy the ID of the user premodding
* @param now the current date
*/
export async function premodUser(
mongo: Db,
tenantID: string,
id: string,
createdBy: string,
now = new Date()
) {
// Create the new ban.
const premodStatusHistory: PremodStatusHistory = {
active: true,
createdBy,
createdAt: now,
};
// Try to update the user if the user isn't already banned.
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
"status.premod.active": {
$ne: true,
},
},
{
$set: {
"status.premod.active": true,
},
$push: {
"status.premod.history": premodStatusHistory,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the ban operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// Check to see if the user is already banned.
const premod = consolidateUserPremodStatus(user.status.premod);
// FIXME: (wyattjoh) once migration has been performed, remove check
if (premod && premod.active) {
throw new UserAlreadyPremoderated();
}
throw new Error("an unexpected error occurred");
}
return result.value;
}
/**
* removeUserPremod will lift a user premod requirement
* @param mongo the mongo database handle
* @param tenantID the Tenant's ID where the User exists
* @param id the ID of the user having their ban lifted
* @param modifiedBy the ID of the user lifting the premod
* @param now the current date
*/
export async function removeUserPremod(
mongo: Db,
tenantID: string,
id: string,
createdBy: string,
now = new Date()
) {
// Create the new ban.
const premod: PremodStatusHistory = {
active: false,
createdBy,
createdAt: now,
};
// Try to update the user if the user isn't already banned.
const result = await collection(mongo).findOneAndUpdate(
{
id,
tenantID,
"status.premod.active": true,
},
{
$set: {
"status.premod.active": false,
},
$push: {
"status.premod.history": premod,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Get the user so we can figure out why the ban operation failed.
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
// The user wasn't banned already, so nothing needs to be done!
return user;
}
return result.value;
}
/**
* banUser will ban a specific user from interacting with the site.
*
@@ -1651,18 +1822,23 @@ export type ConsolidatedBanStatus = Omit<GQLBanStatus, "history"> &
export type ConsolidatedUsernameStatus = Omit<GQLUsernameStatus, "history"> &
Pick<UsernameStatus, "history">;
export type ConsolidatedPremodStatus = Omit<GQLPremodStatus, "history"> &
Pick<PremodStatus, "history">;
export function consolidateUsernameStatus(
username: User["status"]["username"]
): ConsolidatedUsernameStatus {
) {
return username;
}
export function consolidateUserBanStatus(
ban: User["status"]["ban"]
): ConsolidatedBanStatus {
export function consolidateUserBanStatus(ban: User["status"]["ban"]) {
return ban;
}
export function consolidateUserPremodStatus(premod: User["status"]["premod"]) {
return premod;
}
export type ConsolidatedSuspensionStatus = Omit<
GQLSuspensionStatus,
"history"
@@ -1697,6 +1873,8 @@ export function consolidateUserSuspensionStatus(
export interface ConsolidatedUserStatus {
suspension: ConsolidatedSuspensionStatus;
ban: ConsolidatedBanStatus;
// FIXME: (wyattjoh) once migration has been performed, make required
premod?: ConsolidatedPremodStatus;
}
export function consolidateUserStatus(
@@ -1707,6 +1885,7 @@ export function consolidateUserStatus(
return {
suspension: consolidateUserSuspensionStatus(status.suspension, now),
ban: consolidateUserBanStatus(status.ban),
premod: consolidateUserPremodStatus(status.premod),
};
}
@@ -5,6 +5,7 @@ import { commentLength } from "./commentLength";
import { detectLinks } from "./detectLinks";
import { linkify } from "./linkify";
import { preModerate } from "./preModerate";
import { premodUser } from "./preModerateUser";
import { purify } from "./purify";
import { recentCommentHistory } from "./recentCommentHistory";
import { spam } from "./spam";
@@ -29,4 +30,5 @@ export const moderationPhases: IntermediateModerationPhase[] = [
spam,
detectLinks,
preModerate,
premodUser,
];
@@ -0,0 +1,17 @@
import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "coral-server/services/comments/pipeline";
// If a given user is set to always premod, set to premod.
export const premodUser: IntermediateModerationPhase = ({
author,
}): IntermediatePhaseResult | void => {
// FIXME: (wyattjoh) once migration has been performed, remove check
if (author.status.premod && author.status.premod.active) {
return {
status: GQLCOMMENT_STATUS.PREMOD,
};
}
};
+64
View File
@@ -17,6 +17,7 @@ import {
PasswordIncorrect,
TokenNotFoundError,
UserAlreadyBannedError,
UserAlreadyPremoderated,
UserAlreadySuspendedError,
UserCannotBeIgnoredError,
UsernameAlreadySetError,
@@ -33,6 +34,7 @@ import {
banUser,
clearDeletionDate,
consolidateUserBanStatus,
consolidateUserPremodStatus,
consolidateUserSuspensionStatus,
createUser,
createUserToken,
@@ -41,9 +43,11 @@ import {
FindOrCreateUserInput,
ignoreUser,
NotificationSettingsInput,
premodUser,
removeActiveUserSuspensions,
removeUserBan,
removeUserIgnore,
removeUserPremod,
retrieveUser,
retrieveUserWithEmail,
scheduleDeletionDate,
@@ -791,6 +795,66 @@ export async function ban(
return user;
}
/**
* premod will premod a specific user.
*
* @param mongo mongo database to interact with
* @param tenant Tenant where the User will be banned on
* @param moderator the User that is banning the User
* @param userID the ID of the User being banned
* @param now the current time that the ban took effect
*/
export async function premod(
mongo: Db,
tenant: Tenant,
moderator: User,
userID: string,
now = new Date()
) {
// Get the user being banned to check to see if the user already has an
// existing ban.
const targetUser = await retrieveUser(mongo, tenant.id, userID);
if (!targetUser) {
throw new UserNotFoundError(userID);
}
// Check to see if the User is currently banned.
const premodStatus = consolidateUserPremodStatus(targetUser.status.premod);
// FIXME: (wyattjoh) once migration has been performed, remove check
if (premodStatus && premodStatus.active) {
throw new UserAlreadyPremoderated();
}
// Ban the user.
return premodUser(mongo, tenant.id, userID, moderator.id, now);
}
export async function removePremod(
mongo: Db,
tenant: Tenant,
moderator: User,
userID: string,
now = new Date()
) {
// Get the user being suspended to check to see if the user already has an
// existing suspension.
const targetUser = await retrieveUser(mongo, tenant.id, userID);
if (!targetUser) {
throw new UserNotFoundError(userID);
}
// Check to see if the User is currently suspended.
const premodStatus = consolidateUserPremodStatus(targetUser.status.premod);
// FIXME: (wyattjoh) once migration has been performed, remove check
if (!premodStatus || !premodStatus.active) {
// The user is not premodded currently, just return the user because we
// don't have to do anything.
return targetUser;
}
// For each of the suspensions, remove it.
return removeUserPremod(mongo, tenant.id, userID, moderator.id, now);
}
/**
* suspend will suspend a give user from interacting with Coral.
*
+17
View File
@@ -22,6 +22,7 @@ role-plural-commenter = Commenters
userStatus-active = Active
userStatus-banned = Banned
userStatus-suspended = Suspended
userStatus-premod = Always pre-moderate
## Navigation
navigation-moderate = Moderate
@@ -486,6 +487,9 @@ moderate-user-drawer-username-change = Username change
moderate-user-drawer-username-change-new = New:
moderate-user-drawer-username-change-old = Old:
moderate-user-drawer-account-history-premod-set = Always pre-moderate
moderate-user-drawer-account-history-premod-removed = Removed pre-moderate
moderate-user-drawer-suspension =
Suspension, { $value } { $unit ->
[second] { $value ->
@@ -614,12 +618,18 @@ community-userStatus-popover =
.description = A dropdown to change the user status
community-userStatus-banUser = Ban User
community-userStatus-ban = Ban
community-userStatus-removeBan = Remove Ban
community-userStatus-removeUserBan = Remove ban
community-userStatus-suspendUser = Suspend User
community-userStatus-suspend = Suspend
community-userStatus-removeSuspension = Remove Suspension
community-userStatus-removeUserSuspension = Remove suspension
community-userStatus-unknown = Unknown
community-userStatus-changeButton =
.aria-label = Change user status
community-userStatus-premodUser = Always pre-moderate
community-userStatus-removePremod = Remove pre-moderate
community-banModal-areYouSure = Are you sure you want to ban <strong>{ $username }</strong>?
community-banModal-consequence =
@@ -652,6 +662,13 @@ community-suspendModal-success =
community-suspendModal-success-close = Close
community-suspendModal-selectDuration = Select suspension length
community-premodModal-areYouSure =
Are you sure you want to always pre-moderate <strong>{ $username }</strong>?
community-premodModal-consequence =
All their comments will go to the Pending queue until you remove this status.
community-premodModal-cancel = Cancel
community-premodModal-premodUser = Yes, always pre-moderate
community-invite-inviteMember = Invite members to your organization
community-invite-emailAddressLabel = Email address:
community-invite-inviteMore = Invite more