From 4a1492e88d66de26ae8304cc95d7bf749b39a6b8 Mon Sep 17 00:00:00 2001 From: Tessa Thornton Date: Wed, 2 Oct 2019 14:41:53 -0400 Subject: [PATCH] [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 --- .../CreateModeratorNoteMutation.ts | 86 ++++++++++++ .../DeleteModeratorNoteMutation.ts | 72 ++++++++++ .../UserHistoryDrawer/ModeratorNote.css | 37 +++++ .../UserHistoryDrawer/ModeratorNote.tsx | 64 +++++++++ .../components/UserHistoryDrawer/Tabs.css | 8 ++ .../components/UserHistoryDrawer/Tabs.tsx | 43 +++++- .../UserDrawerNotesContainer.css | 31 +++++ .../UserDrawerNotesContainer.tsx | 129 ++++++++++++++++++ .../UserDrawerNotesQuery.css | 14 ++ .../UserDrawerNotesQuery.tsx | 70 ++++++++++ .../UserHistoryDrawerContainer.tsx | 5 +- .../server/graph/tenant/mutators/Users.ts | 21 +++ .../graph/tenant/resolvers/ModeratorNote.ts | 13 ++ .../server/graph/tenant/resolvers/Mutation.ts | 8 ++ .../server/graph/tenant/resolvers/index.ts | 2 + .../server/graph/tenant/schema/schema.graphql | 96 +++++++++++++ src/core/server/models/user/user.ts | 106 ++++++++++++++ ...69947670260_add_moderator_notes_to_user.ts | 51 +++++++ src/core/server/services/users/index.ts | 46 +++++++ src/locales/en-US/admin.ftl | 7 + 20 files changed, 905 insertions(+), 4 deletions(-) create mode 100644 src/core/client/admin/components/UserHistoryDrawer/CreateModeratorNoteMutation.ts create mode 100644 src/core/client/admin/components/UserHistoryDrawer/DeleteModeratorNoteMutation.ts create mode 100644 src/core/client/admin/components/UserHistoryDrawer/ModeratorNote.css create mode 100644 src/core/client/admin/components/UserHistoryDrawer/ModeratorNote.tsx create mode 100644 src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesContainer.css create mode 100644 src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesContainer.tsx create mode 100644 src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesQuery.css create mode 100644 src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesQuery.tsx create mode 100644 src/core/server/graph/tenant/resolvers/ModeratorNote.ts create mode 100644 src/core/server/services/migrate/migrations/1569947670260_add_moderator_notes_to_user.ts diff --git a/src/core/client/admin/components/UserHistoryDrawer/CreateModeratorNoteMutation.ts b/src/core/client/admin/components/UserHistoryDrawer/CreateModeratorNoteMutation.ts new file mode 100644 index 000000000..3523193d1 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/CreateModeratorNoteMutation.ts @@ -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, + { uuidGenerator }: CoralContext + ) => { + const viewer = getViewer(environment)!; + const notes = + lookup(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(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; diff --git a/src/core/client/admin/components/UserHistoryDrawer/DeleteModeratorNoteMutation.ts b/src/core/client/admin/components/UserHistoryDrawer/DeleteModeratorNoteMutation.ts new file mode 100644 index 000000000..389f638be --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/DeleteModeratorNoteMutation.ts @@ -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, + { uuidGenerator }: CoralContext + ) => { + const notes = + lookup(environment, input.userID)!.moderatorNotes.map(note => { + const createdBy = pick(note.createdBy, ["username", "id"]); + return { + ...pick(note, ["id", "body", "createdAt"]), + createdBy, + }; + }) || []; + return commitMutationPromiseNormalized(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; diff --git a/src/core/client/admin/components/UserHistoryDrawer/ModeratorNote.css b/src/core/client/admin/components/UserHistoryDrawer/ModeratorNote.css new file mode 100644 index 000000000..bb157a307 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/ModeratorNote.css @@ -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); +} \ No newline at end of file diff --git a/src/core/client/admin/components/UserHistoryDrawer/ModeratorNote.tsx b/src/core/client/admin/components/UserHistoryDrawer/ModeratorNote.tsx new file mode 100644 index 000000000..790ae9646 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/ModeratorNote.tsx @@ -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) | null; + id: string; +} + +const ModeratorNote: FunctionComponent = ({ + moderator, + createdAt, + body, + onDelete, + id, +}) => { + const deleteNote = useCallback(() => { + if (onDelete) { + onDelete(id); + } + }, [id]); + return ( +
+
+ + {body} + +
+ + + {createdAt} + {moderator && ( + <> + + + Left by: + + + + {moderator} + + + )} + + {onDelete && ( + + + + )} + +
+ ); +}; + +export default ModeratorNote; diff --git a/src/core/client/admin/components/UserHistoryDrawer/Tabs.css b/src/core/client/admin/components/UserHistoryDrawer/Tabs.css index 7fb176ef1..5bb53279f 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/Tabs.css +++ b/src/core/client/admin/components/UserHistoryDrawer/Tabs.css @@ -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; } \ No newline at end of file diff --git a/src/core/client/admin/components/UserHistoryDrawer/Tabs.tsx b/src/core/client/admin/components/UserHistoryDrawer/Tabs.tsx index 1fb5f097e..3e0ef6729 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/Tabs.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/Tabs.tsx @@ -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 = ({ userID }) => { +const UserHistoryTabs: FunctionComponent = ({ userID, notesCount }) => { const [currentTab, setCurrentTab] = useState("ALL_COMMENTS"); const onTabChanged = useCallback( @@ -62,6 +75,23 @@ const UserHistoryTabs: FunctionComponent = ({ userID }) => { + +
+ + subject + + + + Notes + + {notesCount > 0 &&
} + +
+
= ({ userID }) => {
+ +
+
+ +
+
+
); diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesContainer.css b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesContainer.css new file mode 100644 index 000000000..9b02bb6fe --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesContainer.css @@ -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); +} diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesContainer.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesContainer.tsx new file mode 100644 index 000000000..6575efc92 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesContainer.tsx @@ -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 = ({ + 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 ( +
+
+ {({ handleSubmit, submitError, invalid, submitting, ...formProps }) => ( + + + + + + + + + +
+ )} + + + {user.moderatorNotes && + user.moderatorNotes + .concat() + .reverse() + .map( + note => + note && ( + + ) + )} + +
+ ); +}; + +const enhanced = withFragmentContainer({ + 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; diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesQuery.css b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesQuery.css new file mode 100644 index 000000000..3cdb5c5b9 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesQuery.css @@ -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; +} \ No newline at end of file diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesQuery.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesQuery.tsx new file mode 100644 index 000000000..a0c2b5c1d --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerNotesQuery.tsx @@ -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 = ({ userID }) => { + return ( + + query={graphql` + query UserDrawerNotesQuery($userID: ID!) { + user(id: $userID) { + ...UserDrawerNotesContainer_user + } + viewer { + ...UserDrawerNotesContainer_viewer + } + } + `} + variables={{ userID }} + cacheConfig={{ force: true }} + render={({ error, props }: ReadyState) => { + if (error) { + return ( +
+ {error.message} +
+ ); + } + + if (!props) { + return ( +
+ +
+ ); + } + + if (!props.user) { + return ( +
+ + + User not found. + + +
+ ); + } + + return ( + + ); + }} + /> + ); +}; + +export default UserDrawerNotesQuery; diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx index 6570633f4..8f1137716 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserHistoryDrawerContainer.tsx @@ -127,7 +127,7 @@ const UserHistoryDrawerContainer: FunctionComponent = ({
- +
); @@ -140,6 +140,9 @@ const enhanced = withFragmentContainer({ ...UserStatusChangeContainer_user ...UserStatusDetailsContainer_user ...RecentHistoryContainer_user + moderatorNotes { + id + } id username email diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index 9020bf2fc..e5d893a3e 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -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, diff --git a/src/core/server/graph/tenant/resolvers/ModeratorNote.ts b/src/core/server/graph/tenant/resolvers/ModeratorNote.ts new file mode 100644 index 000000000..bd7216822 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/ModeratorNote.ts @@ -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 +> = { + createdBy: ({ createdBy }, input, ctx) => { + return ctx.loaders.Users.user.load(createdBy); + }, + id: ({ id }) => id, + body: ({ body }) => body, + createdAt: ({ createdAt }) => createdAt, +}; diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index d5f23edd2..37c71f461 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -221,4 +221,12 @@ export const Mutation: Required> = { 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, + }), }; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index 80f2e922e..bd217c10f 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -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; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 4e13f4687..632aa8bdf 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -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]) } ################## diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index a3e039189..7e97fd2aa 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -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; +} diff --git a/src/core/server/services/migrate/migrations/1569947670260_add_moderator_notes_to_user.ts b/src/core/server/services/migrate/migrations/1569947670260_add_moderator_notes_to_user.ts new file mode 100644 index 000000000..b5f04fd21 --- /dev/null +++ b/src/core/server/services/migrate/migrations/1569947670260_add_moderator_notes_to_user.ts @@ -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" + ); + } +} diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index a358a3b5a..93f9eca65 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -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. * diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index fc63ae759..5b2e0b153 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -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