mirror of
https://github.com/wassname/talk.git
synced 2026-08-11 11:27:10 +08:00
[CORL-90] moderator notes (#2601)
* add moderator notes and mutations * add create moderator note mutation * add components for moderator notes * style moderator notes * update styles * prevent warning about optimistic update unused fields * fix incorrect comment * fix optimistic response shape * fix create note payload type * add migration for moderator notes * updates from feedback * add border width for firefox
This commit is contained in:
committed by
Wyatt Johnson
parent
3a4eae87ad
commit
4a1492e88d
@@ -0,0 +1,86 @@
|
||||
import { pick } from "lodash";
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { CreateModeratorNoteMutation as MutationTypes } from "coral-admin/__generated__/CreateModeratorNoteMutation.graphql";
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import { CoralContext } from "coral-framework/lib/bootstrap";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
lookup,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLUser } from "coral-framework/schema";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const CreateModeratorNoteMutation = createMutation(
|
||||
"createModeratorNote",
|
||||
(
|
||||
environment: Environment,
|
||||
input: MutationInput<MutationTypes>,
|
||||
{ uuidGenerator }: CoralContext
|
||||
) => {
|
||||
const viewer = getViewer(environment)!;
|
||||
const notes =
|
||||
lookup<GQLUser>(environment, input.userID)!.moderatorNotes.map(note => {
|
||||
const createdBy = pick(note.createdBy, ["username", "id"]);
|
||||
return {
|
||||
...pick(note, ["id", "body", "createdAt"]),
|
||||
createdBy,
|
||||
};
|
||||
}) || [];
|
||||
const now = new Date();
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation CreateModeratorNoteMutation(
|
||||
$input: CreateModeratorNoteInput!
|
||||
) {
|
||||
createModeratorNote(input: $input) {
|
||||
user {
|
||||
moderatorNotes {
|
||||
id
|
||||
body
|
||||
createdBy {
|
||||
username
|
||||
id
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
createModeratorNote: {
|
||||
user: {
|
||||
id: input.userID,
|
||||
moderatorNotes: [
|
||||
{
|
||||
id: uuidGenerator(),
|
||||
body: input.body,
|
||||
createdAt: now.toISOString(),
|
||||
createdBy: {
|
||||
username: viewer.username,
|
||||
id: viewer.id,
|
||||
} as any,
|
||||
},
|
||||
...notes,
|
||||
],
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default CreateModeratorNoteMutation;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { pick } from "lodash";
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { DeleteModeratorNoteMutation as MutationTypes } from "coral-admin/__generated__/DeleteModeratorNoteMutation.graphql";
|
||||
import { CoralContext } from "coral-framework/lib/bootstrap";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
lookup,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLUser } from "coral-framework/schema";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const DeleteModeratorNoteMutation = createMutation(
|
||||
"deleteModeratorNote",
|
||||
(
|
||||
environment: Environment,
|
||||
input: MutationInput<MutationTypes>,
|
||||
{ uuidGenerator }: CoralContext
|
||||
) => {
|
||||
const notes =
|
||||
lookup<GQLUser>(environment, input.userID)!.moderatorNotes.map(note => {
|
||||
const createdBy = pick(note.createdBy, ["username", "id"]);
|
||||
return {
|
||||
...pick(note, ["id", "body", "createdAt"]),
|
||||
createdBy,
|
||||
};
|
||||
}) || [];
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation DeleteModeratorNoteMutation(
|
||||
$input: DeleteModeratorNoteInput!
|
||||
) {
|
||||
deleteModeratorNote(input: $input) {
|
||||
user {
|
||||
moderatorNotes {
|
||||
id
|
||||
body
|
||||
createdBy {
|
||||
username
|
||||
id
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
deleteModeratorNote: {
|
||||
user: {
|
||||
id: input.userID,
|
||||
moderatorNotes: notes.filter(note => note.id !== input.id),
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default DeleteModeratorNoteMutation;
|
||||
@@ -0,0 +1,37 @@
|
||||
.root {
|
||||
|
||||
}
|
||||
|
||||
.body {
|
||||
background-color: #f2f2f2;
|
||||
border-radius: 4px;
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.bodyType {
|
||||
color: var(--palette-text-dark);
|
||||
}
|
||||
|
||||
.leftBy {
|
||||
padding-left: var(--spacing-4);
|
||||
padding-right: var(--spacing-1);
|
||||
position: relative;
|
||||
color: var(--palette-grey-main);
|
||||
}
|
||||
|
||||
.leftBy:before {
|
||||
content: "";
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: var(--palette-grey-main);
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
left: var(--spacing-1);
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: var(--font-family-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--palette-grey-main);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
|
||||
import { Button, Flex, Icon, Timestamp, Typography } from "coral-ui/components";
|
||||
|
||||
import styles from "./ModeratorNote.css";
|
||||
|
||||
interface Props {
|
||||
body: string;
|
||||
moderator: string | null;
|
||||
createdAt: string;
|
||||
onDelete: ((id: string) => Promise<any>) | null;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const ModeratorNote: FunctionComponent<Props> = ({
|
||||
moderator,
|
||||
createdAt,
|
||||
body,
|
||||
onDelete,
|
||||
id,
|
||||
}) => {
|
||||
const deleteNote = useCallback(() => {
|
||||
if (onDelete) {
|
||||
onDelete(id);
|
||||
}
|
||||
}, [id]);
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.body}>
|
||||
<Typography variant="bodyCopy" className={styles.bodyType}>
|
||||
{body}
|
||||
</Typography>
|
||||
</div>
|
||||
<Flex justifyContent="space-between">
|
||||
<Flex alignItems="center">
|
||||
<Timestamp>{createdAt}</Timestamp>
|
||||
{moderator && (
|
||||
<>
|
||||
<Localized id="moderatorNote-left-by">
|
||||
<Typography variant="timestamp" className={styles.leftBy}>
|
||||
Left by:
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Typography className={styles.username} variant="timestamp">
|
||||
{moderator}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
{onDelete && (
|
||||
<Localized id="moderatorNote-delete">
|
||||
<Button size="small" color="primary" onClick={deleteNote}>
|
||||
<Icon>delete</Icon>
|
||||
<span>Delete</span>
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModeratorNote;
|
||||
@@ -57,4 +57,12 @@
|
||||
.scrollable {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.redDot {
|
||||
background-color: var(--palette-error-main);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-left: 2px;
|
||||
}
|
||||
@@ -2,21 +2,34 @@ import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import { Icon, Tab, TabBar, TabContent, TabPane } from "coral-ui/components";
|
||||
import {
|
||||
Flex,
|
||||
Icon,
|
||||
Tab,
|
||||
TabBar,
|
||||
TabContent,
|
||||
TabPane,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import UserDrawerAccountHistoryQuery from "./UserDrawerAccountHistoryQuery";
|
||||
import UserDrawerNotesQuery from "./UserDrawerNotesQuery";
|
||||
import UserHistoryDrawerAllCommentsQuery from "./UserHistoryDrawerAllCommentsQuery";
|
||||
import UserHistoryDrawerRejectedCommentsQuery from "./UserHistoryDrawerRejectedCommentsQuery";
|
||||
|
||||
import styles from "./Tabs.css";
|
||||
|
||||
type UserTabs = "ALL_COMMENTS" | "REJECTED_COMMENTS" | "ACCOUNT_HISTORY";
|
||||
type UserTabs =
|
||||
| "ALL_COMMENTS"
|
||||
| "REJECTED_COMMENTS"
|
||||
| "ACCOUNT_HISTORY"
|
||||
| "NOTES";
|
||||
|
||||
interface Props {
|
||||
userID: string;
|
||||
notesCount: number;
|
||||
}
|
||||
|
||||
const UserHistoryTabs: FunctionComponent<Props> = ({ userID }) => {
|
||||
const UserHistoryTabs: FunctionComponent<Props> = ({ userID, notesCount }) => {
|
||||
const [currentTab, setCurrentTab] = useState<UserTabs>("ALL_COMMENTS");
|
||||
|
||||
const onTabChanged = useCallback(
|
||||
@@ -62,6 +75,23 @@ const UserHistoryTabs: FunctionComponent<Props> = ({ userID }) => {
|
||||
</Localized>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab tabID="NOTES" onTabClick={onTabChanged}>
|
||||
<div
|
||||
className={cn(styles.tab, {
|
||||
[styles.activeTab]: currentTab === "NOTES",
|
||||
})}
|
||||
>
|
||||
<Icon size="sm" className={styles.tabIcon}>
|
||||
subject
|
||||
</Icon>
|
||||
<Flex>
|
||||
<Localized id="moderate-user-drawer-tab-notes">
|
||||
<span>Notes</span>
|
||||
</Localized>
|
||||
{notesCount > 0 && <div className={styles.redDot} />}
|
||||
</Flex>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab tabID="ACCOUNT_HISTORY" onTabClick={onTabChanged}>
|
||||
<div
|
||||
className={cn(styles.tab, {
|
||||
@@ -99,6 +129,13 @@ const UserHistoryTabs: FunctionComponent<Props> = ({ userID }) => {
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tabID="NOTES">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.scrollable}>
|
||||
<UserDrawerNotesQuery userID={userID} />
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
.root {
|
||||
|
||||
}
|
||||
|
||||
.textArea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: calc(12 * var(--mini-unit));
|
||||
border-width: 1px;
|
||||
border-color: var(--palette-grey-main);
|
||||
padding: calc(0.5 * var(--mini-unit));
|
||||
border-radius: 2px;
|
||||
margin-bottom: var(--spacing-2);
|
||||
padding: var(--spacing-3);
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-size: calc(16rem / var(--rem-base));
|
||||
line-height: 1;
|
||||
letter-spacing: calc(0.2em / 16);
|
||||
color: var(--palette-text-primary);
|
||||
}
|
||||
|
||||
.textArea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: var(--spacing-2) 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
|
||||
import { UserDrawerNotesContainer_user as UserData } from "coral-admin/__generated__/UserDrawerNotesContainer_user.graphql";
|
||||
import { UserDrawerNotesContainer_viewer as ViewerData } from "coral-admin/__generated__/UserDrawerNotesContainer_viewer.graphql";
|
||||
import {
|
||||
graphql,
|
||||
useMutation,
|
||||
withFragmentContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { required } from "coral-framework/lib/validation";
|
||||
import { Button, Flex, HorizontalGutter } from "coral-ui/components";
|
||||
import { FormApi } from "final-form";
|
||||
import { Field, Form } from "react-final-form";
|
||||
import CreateModeratorNoteMutation from "./CreateModeratorNoteMutation";
|
||||
import DeleteModeratorNoteMutation from "./DeleteModeratorNoteMutation";
|
||||
import ModeratorNote from "./ModeratorNote";
|
||||
|
||||
import styles from "./UserDrawerNotesContainer.css";
|
||||
|
||||
interface Props {
|
||||
user: UserData;
|
||||
viewer: ViewerData | null;
|
||||
}
|
||||
|
||||
const UserDrawerNotesContainer: FunctionComponent<Props> = ({
|
||||
user,
|
||||
viewer,
|
||||
}) => {
|
||||
const createNote = useMutation(CreateModeratorNoteMutation);
|
||||
const deleteNote = useMutation(DeleteModeratorNoteMutation);
|
||||
const onDelete = useCallback(
|
||||
(id: string) => {
|
||||
return deleteNote({
|
||||
id,
|
||||
userID: user.id,
|
||||
});
|
||||
},
|
||||
[user]
|
||||
);
|
||||
const onSubmit = useCallback(
|
||||
async ({ body }, form: FormApi) => {
|
||||
await createNote({
|
||||
userID: user.id,
|
||||
body,
|
||||
});
|
||||
form.reset();
|
||||
},
|
||||
[user]
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<Form onSubmit={onSubmit}>
|
||||
{({ handleSubmit, submitError, invalid, submitting, ...formProps }) => (
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={handleSubmit}
|
||||
data-testid="userdrawer-notes-form"
|
||||
>
|
||||
<Localized id="moderate-user-drawer-notes-field">
|
||||
<Field
|
||||
className={styles.textArea}
|
||||
id="suspendModal-message"
|
||||
component="textarea"
|
||||
name="body"
|
||||
validate={required}
|
||||
placeholder="Leave a note..."
|
||||
/>
|
||||
</Localized>
|
||||
<Flex justifyContent="flex-end">
|
||||
<Localized id="moderate-user-drawer-notes-button">
|
||||
<Button variant="filled" color="primary" type="submit">
|
||||
Add note
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
<HorizontalGutter size="double">
|
||||
{user.moderatorNotes &&
|
||||
user.moderatorNotes
|
||||
.concat()
|
||||
.reverse()
|
||||
.map(
|
||||
note =>
|
||||
note && (
|
||||
<ModeratorNote
|
||||
key={note.id}
|
||||
id={note.id}
|
||||
body={note.body}
|
||||
moderator={note.createdBy.username}
|
||||
createdAt={note.createdAt}
|
||||
onDelete={
|
||||
viewer && viewer.id === note.createdBy.id
|
||||
? onDelete
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</HorizontalGutter>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
user: graphql`
|
||||
fragment UserDrawerNotesContainer_user on User {
|
||||
id
|
||||
moderatorNotes {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
createdBy {
|
||||
username
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment UserDrawerNotesContainer_viewer on User {
|
||||
id
|
||||
}
|
||||
`,
|
||||
})(UserDrawerNotesContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,14 @@
|
||||
.root {
|
||||
|
||||
}
|
||||
|
||||
.spinner {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.callout {
|
||||
width: 100%;
|
||||
font-family: var(--font-family-sans-serif);
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { ReadyState } from "react-relay";
|
||||
|
||||
import { UserDrawerNotesQuery as QueryTypes } from "coral-admin/__generated__/UserDrawerNotesQuery.graphql";
|
||||
|
||||
import { CallOut, Spinner } from "coral-ui/components";
|
||||
|
||||
import UserDrawerNotesContainer from "./UserDrawerNotesContainer";
|
||||
|
||||
import styles from "./UserDrawerNotesQuery.css";
|
||||
|
||||
interface Props {
|
||||
userID: string;
|
||||
}
|
||||
|
||||
const UserDrawerNotesQuery: FunctionComponent<Props> = ({ userID }) => {
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query UserDrawerNotesQuery($userID: ID!) {
|
||||
user(id: $userID) {
|
||||
...UserDrawerNotesContainer_user
|
||||
}
|
||||
viewer {
|
||||
...UserDrawerNotesContainer_viewer
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{ userID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ error, props }: ReadyState<QueryTypes["response"]>) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.callout}>
|
||||
<CallOut>{error.message}</CallOut>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!props) {
|
||||
return (
|
||||
<div className={styles.spinner}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!props.user) {
|
||||
return (
|
||||
<div className={styles.callout}>
|
||||
<CallOut>
|
||||
<Localized id="moderate-user-drawer-user-not-found ">
|
||||
User not found.
|
||||
</Localized>
|
||||
</CallOut>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UserDrawerNotesContainer user={props.user} viewer={props.viewer} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDrawerNotesQuery;
|
||||
@@ -127,7 +127,7 @@ const UserHistoryDrawerContainer: FunctionComponent<Props> = ({
|
||||
</div>
|
||||
<hr className={styles.divider} />
|
||||
<div className={styles.comments}>
|
||||
<Tabs userID={user.id} />
|
||||
<Tabs userID={user.id} notesCount={user.moderatorNotes.length} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -140,6 +140,9 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...UserStatusChangeContainer_user
|
||||
...UserStatusDetailsContainer_user
|
||||
...RecentHistoryContainer_user
|
||||
moderatorNotes {
|
||||
id
|
||||
}
|
||||
id
|
||||
username
|
||||
email
|
||||
|
||||
@@ -3,10 +3,12 @@ import { mapFieldsetToErrorCodes } from "coral-server/graph/common/errors";
|
||||
import TenantContext from "coral-server/graph/tenant/context";
|
||||
import { User } from "coral-server/models/user";
|
||||
import {
|
||||
addModeratorNote,
|
||||
ban,
|
||||
cancelAccountDeletion,
|
||||
createToken,
|
||||
deactivateToken,
|
||||
destroyModeratorNote,
|
||||
ignore,
|
||||
premod,
|
||||
removeBan,
|
||||
@@ -35,8 +37,10 @@ import { deleteUser } from "coral-server/services/users/delete";
|
||||
import {
|
||||
GQLBanUserInput,
|
||||
GQLCancelAccountDeletionInput,
|
||||
GQLCreateModeratorNoteInput,
|
||||
GQLCreateTokenInput,
|
||||
GQLDeactivateTokenInput,
|
||||
GQLDeleteModeratorNoteInput,
|
||||
GQLDeleteUserAccountInput,
|
||||
GQLIgnoreUserInput,
|
||||
GQLInviteUsersInput,
|
||||
@@ -203,6 +207,23 @@ export const Users = (ctx: TenantContext) => ({
|
||||
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
|
||||
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
|
||||
updateRole(ctx.mongo, ctx.tenant, ctx.user!, input.userID, input.role),
|
||||
createModeratorNote: async (input: GQLCreateModeratorNoteInput) =>
|
||||
addModeratorNote(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
input.userID,
|
||||
input.body,
|
||||
ctx.now
|
||||
),
|
||||
deleteModeratorNote: async (input: GQLDeleteModeratorNoteInput) =>
|
||||
destroyModeratorNote(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
input.userID,
|
||||
input.id,
|
||||
ctx.user!
|
||||
),
|
||||
ban: async (input: GQLBanUserInput) =>
|
||||
ban(
|
||||
ctx.mongo,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { GQLModeratorNoteTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import * as user from "coral-server/models/user";
|
||||
|
||||
export const ModeratorNote: Required<
|
||||
GQLModeratorNoteTypeResolver<user.ModeratorNote>
|
||||
> = {
|
||||
createdBy: ({ createdBy }, input, ctx) => {
|
||||
return ctx.loaders.Users.user.load(createdBy);
|
||||
},
|
||||
id: ({ id }) => id,
|
||||
body: ({ body }) => body,
|
||||
createdAt: ({ createdAt }) => createdAt,
|
||||
};
|
||||
@@ -221,4 +221,12 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
user: await ctx.mutators.Users.deleteAccount(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
createModeratorNote: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.createModeratorNote(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
deleteModeratorNote: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.deleteModeratorNote(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Invite } from "./Invite";
|
||||
import { LiveConfiguration } from "./LiveConfiguration";
|
||||
import { ModerationQueue } from "./ModerationQueue";
|
||||
import { ModerationQueues } from "./ModerationQueues";
|
||||
import { ModeratorNote } from "./ModeratorNote";
|
||||
import { Mutation } from "./Mutation";
|
||||
import { OIDCAuthIntegration } from "./OIDCAuthIntegration";
|
||||
import { PremodStatus } from "./PremodStatus";
|
||||
@@ -92,6 +93,7 @@ const Resolvers: GQLResolver = {
|
||||
User,
|
||||
UserStatus,
|
||||
UsernameStatus,
|
||||
ModeratorNote,
|
||||
};
|
||||
|
||||
export default Resolvers;
|
||||
|
||||
@@ -1571,6 +1571,28 @@ enum USER_STATUS {
|
||||
PREMOD
|
||||
}
|
||||
|
||||
type ModeratorNote {
|
||||
"""
|
||||
id is the identifier of the Note.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
body is the content of the Note
|
||||
"""
|
||||
body: String!
|
||||
|
||||
"""
|
||||
createdAt is the date in which the Note was created.
|
||||
"""
|
||||
createdAt: Time!
|
||||
|
||||
"""
|
||||
createdBy is the Moderator that authored the Note.
|
||||
"""
|
||||
createdBy: User!
|
||||
}
|
||||
|
||||
enum DIGEST_FREQUENCY {
|
||||
"""
|
||||
NONE will have the notifications send immediatly rather than bundling for digesting.
|
||||
@@ -1689,6 +1711,14 @@ type User {
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
)
|
||||
|
||||
"""
|
||||
moderatorNotes are notes left by moderators about the User.
|
||||
"""
|
||||
moderatorNotes: [ModeratorNote!]!
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
)
|
||||
|
||||
"""
|
||||
ignoreable is a computed property based on the
|
||||
user's role. Typically, users with elevated privileges
|
||||
@@ -4283,6 +4313,62 @@ type InviteUsersPayload {
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
input CreateModeratorNoteInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
body is the content of the Note
|
||||
"""
|
||||
body: String!
|
||||
|
||||
"""
|
||||
userID the id of the User who is the subject of the note.
|
||||
"""
|
||||
userID: ID!
|
||||
}
|
||||
|
||||
input DeleteModeratorNoteInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
"""
|
||||
userID is the user who is the subject of the note
|
||||
"""
|
||||
userID: ID!
|
||||
"""
|
||||
id is the identifier of the note
|
||||
"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type CreateModeratorNotePayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
createdBy is the moderator who created the note
|
||||
user is the updated user.
|
||||
"""
|
||||
user: User!
|
||||
}
|
||||
|
||||
type DeleteModeratorNotePayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
"""
|
||||
user is the updated user.
|
||||
"""
|
||||
user: User!
|
||||
}
|
||||
|
||||
##################
|
||||
# setEmail
|
||||
##################
|
||||
@@ -5310,6 +5396,16 @@ type Mutation {
|
||||
"""
|
||||
removeUserPremod(input: RemovePremodUserInput!): RemovePremodUserPayload!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
createModeratorNote creates a note on a user account.
|
||||
"""
|
||||
createModeratorNote(input: CreateModeratorNoteInput!): CreateModeratorNotePayload! @auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
deleteModeratorNote deletes a note on a user account.
|
||||
"""
|
||||
deleteModeratorNote(input: DeleteModeratorNoteInput!): DeleteModeratorNotePayload! @auth(roles: [ADMIN, MODERATOR])
|
||||
}
|
||||
|
||||
##################
|
||||
|
||||
@@ -103,6 +103,28 @@ export interface Token {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* ModeratorNote ModeratorNote is a note left by a moderator on the subject of a user.
|
||||
*/
|
||||
export interface ModeratorNote {
|
||||
/**
|
||||
* id is the identifier of the Note.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* body is the content of the Note
|
||||
*/
|
||||
body: string;
|
||||
/**
|
||||
* createdAt is the date in which the Note was created.
|
||||
*/
|
||||
createdAt: Date;
|
||||
/**
|
||||
* createdBy is the Moderator that authored the Note
|
||||
*/
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SuspensionStatusHistory SuspensionStatusHistory is the list of all suspension
|
||||
* events against a specific User.
|
||||
@@ -407,6 +429,11 @@ export interface User extends TenantResource {
|
||||
*/
|
||||
ignoredUsers: IgnoredUser[];
|
||||
|
||||
/**
|
||||
* moderatorNotes are notes left by moderators about the User.
|
||||
*/
|
||||
moderatorNotes: ModeratorNote[];
|
||||
|
||||
/**
|
||||
* lastDownloadedAt is the last time the user requested to download their
|
||||
* user data.
|
||||
@@ -478,6 +505,7 @@ async function findOrCreateUserInput(
|
||||
onStaffReplies: false,
|
||||
digestFrequency: GQLDIGEST_FREQUENCY.NONE,
|
||||
},
|
||||
moderatorNotes: [],
|
||||
profiles: [],
|
||||
digests: [],
|
||||
createdAt: now,
|
||||
@@ -2323,3 +2351,81 @@ export async function retrieveUserScheduledForDeletion(
|
||||
);
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* createModeratorNote will add a note to a users account
|
||||
* @param mongo the database to put the notification digests into
|
||||
* @param tenantID the ID of the Tenant that this User exists on
|
||||
* @param id the ID of the User who is the subject of the note
|
||||
* @param createdBy the ID of Moderator that is creating the note
|
||||
* @param note the contents of the note
|
||||
* @param now the current time that the note was created
|
||||
*/
|
||||
export async function createModeratorNote(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
createdBy: string,
|
||||
note: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const moderatorNote: ModeratorNote = {
|
||||
id: uuid(),
|
||||
createdAt: now,
|
||||
body: note,
|
||||
createdBy,
|
||||
};
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
{
|
||||
$push: {
|
||||
moderatorNotes: moderatorNote,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* deleteModeratorNote will remove a note from a user profile
|
||||
* @param mongo the database to put the notification digests into
|
||||
* @param tenantID the ID of the Tenant that this User exists on
|
||||
* @param userID the ID of the user
|
||||
* @param id the ID of the note to delete
|
||||
* @param createdBy the ID of the note author
|
||||
*/
|
||||
export async function deleteModeratorNote(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
userID: string,
|
||||
id: string,
|
||||
createdBy: string
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id: userID,
|
||||
tenantID,
|
||||
},
|
||||
{
|
||||
$pull: {
|
||||
moderatorNotes: { id, createdBy },
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import collections from "coral-server/services/mongodb/collections";
|
||||
|
||||
import Migration from "coral-server/services/migrate/migration";
|
||||
|
||||
export default class extends Migration {
|
||||
public async up(mongo: Db, tenantID: string) {
|
||||
const result = await collections.users(mongo).updateMany(
|
||||
{
|
||||
tenantID,
|
||||
moderatorNotes: null,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
moderatorNotes: [],
|
||||
},
|
||||
}
|
||||
);
|
||||
this.log(tenantID).warn(
|
||||
{
|
||||
matchedCount: result.matchedCount,
|
||||
modifiedCount: result.matchedCount,
|
||||
},
|
||||
"added empty moderatorNotes array"
|
||||
);
|
||||
}
|
||||
|
||||
public async down(mongo: Db, tenantID: string) {
|
||||
const result = await collections.users(mongo).updateMany(
|
||||
{
|
||||
tenantID,
|
||||
moderatorNotes: {
|
||||
$exists: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
$unset: {
|
||||
moderatorNotes: "",
|
||||
},
|
||||
}
|
||||
);
|
||||
this.log(tenantID).warn(
|
||||
{
|
||||
matchedCount: result.matchedCount,
|
||||
modifiedCount: result.matchedCount,
|
||||
},
|
||||
"removed moderatorNotes"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,11 @@ import {
|
||||
consolidateUserBanStatus,
|
||||
consolidateUserPremodStatus,
|
||||
consolidateUserSuspensionStatus,
|
||||
createModeratorNote,
|
||||
createUser,
|
||||
createUserToken,
|
||||
deactivateUserToken,
|
||||
deleteModeratorNote,
|
||||
findOrCreateUser,
|
||||
FindOrCreateUserInput,
|
||||
ignoreUser,
|
||||
@@ -734,6 +736,50 @@ export async function updateAvatar(
|
||||
return updateUserAvatar(mongo, tenant.id, userID, avatar);
|
||||
}
|
||||
|
||||
/**
|
||||
* addModeratorNote will add a note to the users account.
|
||||
*
|
||||
* @param mongo mongo database to interact with
|
||||
* @param tenant Tenant where the User will be banned on
|
||||
* @param moderator the Moderator that is creating the note
|
||||
* @param userID the ID of the User who is the subject of the note
|
||||
* @param note the contents of the note
|
||||
* @param now the current time that the note was created
|
||||
*/
|
||||
export async function addModeratorNote(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
moderator: User,
|
||||
userID: string,
|
||||
note: string,
|
||||
now = new Date()
|
||||
) {
|
||||
if (!note || note.length < 1) {
|
||||
throw new Error("Note cannot be empty");
|
||||
}
|
||||
|
||||
return createModeratorNote(mongo, tenant.id, userID, moderator.id, note, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* destroyModeratorNote will remove a note from a user
|
||||
*
|
||||
* @param mongo mongo database to interact with
|
||||
* @param tenant Tenant where the User will be banned on
|
||||
* @param userID id of the user who is the subjet
|
||||
* @param id id of the note to delete
|
||||
*/
|
||||
|
||||
export async function destroyModeratorNote(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
userID: string,
|
||||
id: string,
|
||||
createdBy: User
|
||||
) {
|
||||
return deleteModeratorNote(mongo, tenant.id, userID, id, createdBy.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* ban will ban a specific user from interacting with Coral.
|
||||
*
|
||||
|
||||
@@ -475,6 +475,7 @@ moderate-user-drawer-member-id =
|
||||
moderate-user-drawer-tab-all-comments = All Comments
|
||||
moderate-user-drawer-tab-rejected-comments = Rejected
|
||||
moderate-user-drawer-tab-account-history = Account History
|
||||
moderate-user-drawer-tab-notes = Notes
|
||||
moderate-user-drawer-load-more = Load More
|
||||
moderate-user-drawer-all-no-comments = {$username} has not submitted any comments.
|
||||
moderate-user-drawer-rejected-no-comments = {$username} does not have any rejected comments.
|
||||
@@ -540,6 +541,12 @@ moderate-user-drawer-recent-history-tooltip-button =
|
||||
.aria-label = Toggle recent comment history tooltip
|
||||
moderate-user-drawer-recent-history-tooltip-submitted = Submitted
|
||||
|
||||
moderate-user-drawer-notes-field =
|
||||
.placeholder = Leave a note...
|
||||
moderate-user-drawer-notes-button = Add note
|
||||
moderatorNote-left-by = Left by:
|
||||
moderatorNote-delete = Delete
|
||||
|
||||
## Create Username
|
||||
|
||||
createUsername-createUsernameHeader = Create Username
|
||||
|
||||
Reference in New Issue
Block a user