[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:
Tessa Thornton
2019-10-02 18:41:53 +00:00
committed by Wyatt Johnson
parent 3a4eae87ad
commit 4a1492e88d
20 changed files with 905 additions and 4 deletions
@@ -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])
}
##################
+106
View File
@@ -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;
}
@@ -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"
);
}
}
+46
View File
@@ -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.
*
+7
View File
@@ -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