diff --git a/INDEXES.md b/INDEXES.md index 564e05c52b..77f282603a 100644 --- a/INDEXES.md +++ b/INDEXES.md @@ -6,6 +6,20 @@ The goal of this document is to date-mark the indexes you add to support the cha If you are releasing, you can use this readme to check all the indexes prior to the release you are deploying and have a good idea of what indexes you might need to deploy to Mongo along with your release of a new Coral Docker image to kubernetes. +## 2023-10-18 + +``` +db.notifications.createIndex({ tenantID: 1, id: 1 }, { unique: true }); +``` + +- This index creates the uniqueness constraint for the `tenantID` and `id` fields on the notifications collection + +``` +db.notifications.createIndex({ tenantID: 1, ownerID: 1, createdAt: 1 }); +``` + +- This index speeds up the retrieval of notifications by `tenantID`, `ownerID`, and `createdAt` which is the most common way of retrieving notifications for pagination in the notifications tab on the stream. + ## 2023-03-28 ``` diff --git a/client/scripts/precommitLint.js b/client/scripts/precommitLint.js index ebb883f587..140103e604 100644 --- a/client/scripts/precommitLint.js +++ b/client/scripts/precommitLint.js @@ -26,12 +26,12 @@ sgf((err, results) => { const eslintFiles = []; for (const item of results) { - const { filename } = item; + const { filename, status } = item; // only include valid, filtered extensions // this is primarily to keep eslint rampaging // over non-source files - if (!matchesExtension(extensions, filename)) { + if (!matchesExtension(extensions, filename) || status === "Deleted") { continue; } diff --git a/client/src/core/client/admin/App/Navigation/Navigation.tsx b/client/src/core/client/admin/App/Navigation/Navigation.tsx index 4e2c54cbf8..ae36ac3928 100644 --- a/client/src/core/client/admin/App/Navigation/Navigation.tsx +++ b/client/src/core/client/admin/App/Navigation/Navigation.tsx @@ -8,6 +8,7 @@ import NavigationLink from "./NavigationLink"; interface Props { showConfigure: boolean; showDashboard: boolean; + showReports: boolean; } const Navigation: FunctionComponent = (props) => ( @@ -31,6 +32,12 @@ const Navigation: FunctionComponent = (props) => ( Dashboard )} + {/* TODO: Any other permissions needed? */} + {props.showReports && ( + + DSA Reports + + )} ); diff --git a/client/src/core/client/admin/App/Navigation/NavigationContainer.tsx b/client/src/core/client/admin/App/Navigation/NavigationContainer.tsx index 08f9b1b42a..3efcdd26c8 100644 --- a/client/src/core/client/admin/App/Navigation/NavigationContainer.tsx +++ b/client/src/core/client/admin/App/Navigation/NavigationContainer.tsx @@ -1,14 +1,15 @@ -import React from "react"; +import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; import { Ability, can } from "coral-admin/permissions/tenant"; -import { withFragmentContainer } from "coral-framework/lib/relay"; +import { useLocal, withFragmentContainer } from "coral-framework/lib/relay"; import { SignOutMutation, withSignOutMutation, } from "coral-framework/mutations"; import { NavigationContainer_viewer as ViewerData } from "coral-admin/__generated__/NavigationContainer_viewer.graphql"; +import { NavigationContainerLocal } from "coral-admin/__generated__/NavigationContainerLocal.graphql"; import Navigation from "./Navigation"; @@ -17,21 +18,22 @@ interface Props { viewer: ViewerData | null; } -class NavigationContainer extends React.Component { - public render() { - return ( - - ); - } -} +const NavigationContainer: FunctionComponent = ({ viewer }) => { + const [{ dsaFeaturesEnabled }] = useLocal( + graphql` + fragment NavigationContainerLocal on Local { + dsaFeaturesEnabled + } + ` + ); + return ( + + ); +}; const enhanced = withSignOutMutation( withFragmentContainer({ diff --git a/client/src/core/client/admin/components/Comment/InReplyTo.tsx b/client/src/core/client/admin/components/Comment/InReplyTo.tsx index 4d0d4fe44a..26840c6818 100644 --- a/client/src/core/client/admin/components/Comment/InReplyTo.tsx +++ b/client/src/core/client/admin/components/Comment/InReplyTo.tsx @@ -1,4 +1,5 @@ import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; import React, { FunctionComponent } from "react"; import { EmailActionReplyIcon, SvgIcon } from "coral-ui/components/icons"; @@ -7,11 +8,16 @@ import { BaseButton, Flex } from "coral-ui/components/v2"; import styles from "./InReplyTo.css"; interface Props { + className?: string; children: string; onUsernameClick: () => void; } -const InReplyTo: FunctionComponent = ({ children, onUsernameClick }) => { +const InReplyTo: FunctionComponent = ({ + className, + children, + onUsernameClick, +}) => { const Username = () => ( = ({ children, onUsernameClick }) => { vars={{ username: children }} > - {children} + {children} ); diff --git a/client/src/core/client/admin/routeConfig.tsx b/client/src/core/client/admin/routeConfig.tsx index 192b3794b5..875f6790c6 100644 --- a/client/src/core/client/admin/routeConfig.tsx +++ b/client/src/core/client/admin/routeConfig.tsx @@ -47,6 +47,8 @@ import { UnmoderatedQueueRoute, } from "./routes/Moderate/Queue"; import SingleModerateRoute from "./routes/Moderate/SingleModerate"; +import ReportsRoute from "./routes/Reports"; +import SingleReportRoute from "./routes/Reports/SingleReportRoute"; import StoriesRoute from "./routes/Stories"; interface CoralContainerProps { @@ -123,6 +125,11 @@ export default makeRouteConfig( + + ) => { + const result = commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation AddReportNoteMutation($input: AddDSAReportNoteInput!) { + addDSAReportNote(input: $input) { + dsaReport { + id + history { + id + createdBy { + username + } + createdAt + body + type + status + } + } + clientMutationId + } + } + `, + variables: { + input: { + userID: input.userID, + body: input.body, + reportID: input.reportID, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + return result; + } +); + +export default AddReportNoteMutation; diff --git a/client/src/core/client/admin/routes/Reports/AddReportShareMutation.tsx b/client/src/core/client/admin/routes/Reports/AddReportShareMutation.tsx new file mode 100644 index 0000000000..c9c16d82c5 --- /dev/null +++ b/client/src/core/client/admin/routes/Reports/AddReportShareMutation.tsx @@ -0,0 +1,50 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { AddReportShareMutation as MutationTypes } from "coral-admin/__generated__/AddReportShareMutation.graphql"; + +let clientMutationId = 0; + +const AddReportShareMutation = createMutation( + "addReportShare", + (environment: Environment, input: MutationInput) => { + const result = commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation AddReportShareMutation($input: AddDSAReportShareInput!) { + addDSAReportShare(input: $input) { + dsaReport { + id + history { + id + createdBy { + username + } + createdAt + body + type + status + } + } + clientMutationId + } + } + `, + variables: { + input: { + userID: input.userID, + reportID: input.reportID, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + return result; + } +); + +export default AddReportShareMutation; diff --git a/client/src/core/client/admin/routes/Reports/ChangeReportStatusMutation.tsx b/client/src/core/client/admin/routes/Reports/ChangeReportStatusMutation.tsx new file mode 100644 index 0000000000..34fc784907 --- /dev/null +++ b/client/src/core/client/admin/routes/Reports/ChangeReportStatusMutation.tsx @@ -0,0 +1,54 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { ChangeReportStatusMutation as MutationTypes } from "coral-admin/__generated__/ChangeReportStatusMutation.graphql"; + +let clientMutationId = 0; + +const ChangeReportStatusMutation = createMutation( + "changeReportStatus", + (environment: Environment, input: MutationInput) => { + const result = commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation ChangeReportStatusMutation( + $input: ChangeDSAReportStatusInput! + ) { + changeDSAReportStatus(input: $input) { + dsaReport { + id + status + history { + id + createdBy { + username + } + createdAt + body + type + status + } + } + clientMutationId + } + } + `, + variables: { + input: { + userID: input.userID, + reportID: input.reportID, + status: input.status, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + return result; + } +); + +export default ChangeReportStatusMutation; diff --git a/client/src/core/client/admin/routes/Reports/ChangeStatusModal.tsx b/client/src/core/client/admin/routes/Reports/ChangeStatusModal.tsx new file mode 100644 index 0000000000..618fdf49f7 --- /dev/null +++ b/client/src/core/client/admin/routes/Reports/ChangeStatusModal.tsx @@ -0,0 +1,137 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback, useMemo } from "react"; +import { Form } from "react-final-form"; + +import ModalHeader from "coral-admin/components/ModalHeader"; +import { useMutation } from "coral-framework/lib/relay"; +import { + GQLDSAReportHistoryType, + GQLDSAReportStatus, +} from "coral-framework/schema"; +import { + Button, + Card, + CardCloseButton, + Flex, + HorizontalGutter, + Modal, +} from "coral-ui/components/v2"; + +import ChangeReportStatusMutation from "./ChangeReportStatusMutation"; + +interface Props { + reportID: string; + userID?: string; + showChangeStatusModal: Exclude< + GQLDSAReportHistoryType, + | GQLDSAReportHistoryType.STATUS_CHANGED + | GQLDSAReportHistoryType.DECISION_MADE + > | null; + setShowChangeStatusModal: ( + changeType: Exclude< + GQLDSAReportHistoryType, + | GQLDSAReportHistoryType.STATUS_CHANGED + | GQLDSAReportHistoryType.DECISION_MADE + > | null + ) => void; +} + +const ChangeStatusModal: FunctionComponent = ({ + reportID, + userID, + showChangeStatusModal, + setShowChangeStatusModal, +}) => { + const changeReportStatus = useMutation(ChangeReportStatusMutation); + + const onCloseChangeStatusModal = useCallback(() => { + setShowChangeStatusModal(null); + }, [setShowChangeStatusModal]); + + const onSubmitStatusUpdate = useCallback(async () => { + if (userID) { + await changeReportStatus({ + userID, + reportID, + status: GQLDSAReportStatus.UNDER_REVIEW, + }); + setShowChangeStatusModal(null); + } + }, [userID, reportID, setShowChangeStatusModal, changeReportStatus]); + + const actionText = useMemo(() => { + if (!showChangeStatusModal) { + return null; + } + const mapping = { + NOTE: { + text: "You have added a note. Would you like to update your status to In review.", + id: "reports-changeStatusModal-prompt-addNote", + }, + SHARE: { + text: "You have downloaded the report. Would you like to update your status to In review.", + id: "reports-changeStatusModal-prompt-downloadReport", + }, + }; + return mapping[showChangeStatusModal]; + }, [showChangeStatusModal]); + + return ( + + {({ firstFocusableRef }) => ( + + + + + + Update status? + +
+ {({ handleSubmit, hasValidationErrors }) => ( + + + + {actionText && ( + +
{actionText.text}
+
+ )} + + + + + + + + + + + + +
+
+
+ )} + +
+ )} +
+ ); +}; + +export default ChangeStatusModal; diff --git a/client/src/core/client/admin/routes/Reports/DeleteReportNoteMutation.tsx b/client/src/core/client/admin/routes/Reports/DeleteReportNoteMutation.tsx new file mode 100644 index 0000000000..37e73b2eb0 --- /dev/null +++ b/client/src/core/client/admin/routes/Reports/DeleteReportNoteMutation.tsx @@ -0,0 +1,50 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { DeleteReportNoteMutation as MutationTypes } from "coral-admin/__generated__/DeleteReportNoteMutation.graphql"; + +let clientMutationId = 0; + +const DeleteReportNoteMutation = createMutation( + "deleteReportNote", + (environment: Environment, input: MutationInput) => { + const result = commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation DeleteReportNoteMutation($input: DeleteDSAReportNoteInput!) { + deleteDSAReportNote(input: $input) { + dsaReport { + id + history { + id + createdBy { + username + } + createdAt + body + type + status + } + } + clientMutationId + } + } + `, + variables: { + input: { + id: input.id, + reportID: input.reportID, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + return result; + } +); + +export default DeleteReportNoteMutation; diff --git a/client/src/core/client/admin/routes/Reports/MakeReportDecisionMutation.tsx b/client/src/core/client/admin/routes/Reports/MakeReportDecisionMutation.tsx new file mode 100644 index 0000000000..acbaa3d675 --- /dev/null +++ b/client/src/core/client/admin/routes/Reports/MakeReportDecisionMutation.tsx @@ -0,0 +1,68 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { MakeReportDecisionMutation as MutationTypes } from "coral-admin/__generated__/MakeReportDecisionMutation.graphql"; + +let clientMutationId = 0; + +const MakeReportDecisionMutation = createMutation( + "makeReportDecision", + (environment: Environment, input: MutationInput) => { + const result = commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation MakeReportDecisionMutation( + $input: MakeDSAReportDecisionInput! + ) { + makeDSAReportDecision(input: $input) { + dsaReport { + id + status + decision { + legality + legalGrounds + detailedExplanation + } + history { + id + createdBy { + username + } + createdAt + body + type + status + decision { + legality + legalGrounds + detailedExplanation + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + userID: input.userID, + reportID: input.reportID, + legality: input.legality, + legalGrounds: input.legalGrounds, + detailedExplanation: input.detailedExplanation, + commentID: input.commentID, + commentRevisionID: input.commentRevisionID, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); + return result; + } +); + +export default MakeReportDecisionMutation; diff --git a/client/src/core/client/admin/routes/Reports/ReportHistory.css b/client/src/core/client/admin/routes/Reports/ReportHistory.css new file mode 100644 index 0000000000..e20101c981 --- /dev/null +++ b/client/src/core/client/admin/routes/Reports/ReportHistory.css @@ -0,0 +1,74 @@ +.reportHistoryWrapper { + width: 30%; +} + +.reportHistory { + padding: var(--spacing-6) var(--spacing-4) 0 var(--spacing-4); + max-height: 550px; + overflow: scroll; + margin-bottom: var(--spacing-2); + white-space: pre-wrap; +} + +.fadeBottom:after { + position: absolute; + bottom: 0; + width: 100%; + height: 100%; + content: ""; + background: linear-gradient( + to top, + rgba(255, 255, 255, 0.75) 0%, + rgba(255, 255, 255, 0) 200px + ); + pointer-events: none; +} + +.reportHistoryHeader { + text-transform: uppercase; + font-size: var(--font-size-3); + font-weight: var(--font-weight-secondary-bold); + font-family: var(--font-family-secondary); + padding-bottom: var(--spacing-3); +} + +.reportHistoryText { + font-size: var(--font-size-2); +} + +.reportHistoryCreatedAt { + clear: both; + font-size: var(--font-size-1); + color: var(--palette-grey-500); +} + +.reportHistoryNoteBody { + padding: var(--spacing-1); + background-color: var(--palette-grey-100); + margin-top: var(--spacing-1); + font-size: var(--font-size-2); +} + +.deleteReportNoteButton { + float: right; + margin-top: var(--spacing-1); +} + +.addNoteTextarea { + height: 80px; + width: 100%; +} + +.addNoteForm { + padding: var(--spacing-2) var(--spacing-4) var(--spacing-4) var(--spacing-4); + width: 100%; +} + +.addNoteFormWrapper { + position: relative; +} + +.addNoteTextarea::placeholder { + color: var(--palette-grey-300); + font-size: var(--font-size-2); +} diff --git a/client/src/core/client/admin/routes/Reports/ReportHistory.tsx b/client/src/core/client/admin/routes/Reports/ReportHistory.tsx new file mode 100644 index 0000000000..548066e70f --- /dev/null +++ b/client/src/core/client/admin/routes/Reports/ReportHistory.tsx @@ -0,0 +1,374 @@ +import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; +import { FormApi } from "final-form"; +import React, { + FunctionComponent, + useCallback, + useEffect, + useState, +} from "react"; +import { Field, Form } from "react-final-form"; +import { graphql } from "react-relay"; + +import { useDateTimeFormatter } from "coral-framework/hooks"; +import { useCoralContext } from "coral-framework/lib/bootstrap"; +import { getMessage } from "coral-framework/lib/i18n"; +import { useInView } from "coral-framework/lib/intersection"; +import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; +import { required } from "coral-framework/lib/validation"; +import { + GQLDSAReportDecisionLegality, + GQLDSAReportHistoryType, + GQLDSAReportStatus, +} from "coral-framework/schema"; +import { AddIcon, BinIcon, ButtonSvgIcon } from "coral-ui/components/icons"; +import { + Button, + Flex, + HorizontalGutter, + Textarea, +} from "coral-ui/components/v2"; +import { useShadowRootOrDocument } from "coral-ui/encapsulation"; + +import { + DSAReportStatus, + ReportHistory_dsaReport, +} from "coral-admin/__generated__/ReportHistory_dsaReport.graphql"; + +import styles from "./ReportHistory.css"; + +import AddReportNoteMutation from "./AddReportNoteMutation"; +import DeleteReportNoteMutation from "./DeleteReportNoteMutation"; + +interface Props { + dsaReport: ReportHistory_dsaReport; + setShowChangeStatusModal: ( + changeType: Exclude< + GQLDSAReportHistoryType, + | GQLDSAReportHistoryType.STATUS_CHANGED + | GQLDSAReportHistoryType.DECISION_MADE + > | null + ) => void; + userID?: string; +} + +const ReportHistory: FunctionComponent = ({ + dsaReport, + userID, + setShowChangeStatusModal, +}) => { + const root = useShadowRootOrDocument(); + const { localeBundles } = useCoralContext(); + const [reportHistoryStyles, setReportHistoryStyles] = useState( + cn(styles.reportHistory, styles.fadeBottom) + ); + const { inView, intersectionRef: bottomOfReportHistoryInViewRef } = + useInView(); + + const statusMappings = { + AWAITING_REVIEW: getMessage( + localeBundles, + "reports-status-awaitingReview", + "Awaiting review" + ), + UNDER_REVIEW: getMessage( + localeBundles, + "reports-status-inReview", + "In review" + ), + COMPLETED: getMessage( + localeBundles, + "reports-status-completed", + "Completed" + ), + VOID: getMessage(localeBundles, "reports-status-void", "Void"), + "%future added value": getMessage( + localeBundles, + "reports-status-unknown", + "Unknown status" + ), + }; + + useEffect(() => { + if (inView) { + setReportHistoryStyles(styles.reportHistory); + } else { + setReportHistoryStyles(cn(styles.reportHistory, styles.fadeBottom)); + } + }, [inView]); + + const addReportNote = useMutation(AddReportNoteMutation); + const deleteReportNote = useMutation(DeleteReportNoteMutation); + + const reportHistoryFormatter = useDateTimeFormatter({ + day: "numeric", + month: "long", + year: "numeric", + }); + + const statusMapping = useCallback( + (status: DSAReportStatus | null) => { + if (!status) { + return "Unknown status"; + } + return statusMappings[status]; + }, + [statusMappings] + ); + + const onDeleteReportNoteButton = useCallback( + async (id: string) => { + await deleteReportNote({ id, reportID: dsaReport.id }); + }, + [deleteReportNote, dsaReport.id] + ); + + const onSubmitAddNote = useCallback( + async (input: any, form: FormApi) => { + if (userID) { + await addReportNote({ + body: input.note, + reportID: dsaReport.id, + userID, + }); + form.change("note", undefined); + // Wait for new note to appear then scroll down to it + setTimeout(() => { + const element = root.getElementById("reportHistory"); + if (element && element.scroll) { + element.scroll({ top: element.scrollHeight, behavior: "smooth" }); + } + }, 0); + if (dsaReport.status === GQLDSAReportStatus.AWAITING_REVIEW) { + setShowChangeStatusModal(GQLDSAReportHistoryType.NOTE); + } + } + }, + [addReportNote, dsaReport, userID, setShowChangeStatusModal, root] + ); + + if (!dsaReport) { + return null; + } + + return ( + + + +
History
+
+ +
+ +
+ Illegal content report submitted +
+
+
+ {reportHistoryFormatter(dsaReport.createdAt)} +
+
+ <> + {dsaReport.history?.map((h) => { + if (h) { + return ( +
+ {h?.type === GQLDSAReportHistoryType.NOTE && ( + <> + +
{`${h.createdBy?.username} added a note`}
+
+
+ {h.body} +
+
+ }} + > + + +
+ + )} + + {h?.type === GQLDSAReportHistoryType.STATUS_CHANGED && ( + <> + {h.status === GQLDSAReportStatus.VOID ? ( + +
+ User deleted their account. Report is void. +
+
+ ) : ( + +
{`${ + h.createdBy?.username + } changed status to "${statusMapping( + h.status + )}"`}
+
+ )} + + )} + + {h?.type === GQLDSAReportHistoryType.SHARE && ( + +
{`${h.createdBy?.username} shared this report`}
+
+ )} + + {h?.type === GQLDSAReportHistoryType.DECISION_MADE && ( + <> + {h.decision?.legality === + GQLDSAReportDecisionLegality.ILLEGAL ? ( + +
{`${h.createdBy?.username} made a decision that this report contains illegal content`}
+
+ ) : ( + +
{`${h.createdBy?.username} made a decision that this report does not contain illegal content`}
+
+ )} + {h.decision?.legality === "ILLEGAL" && ( + <> + +
+ Legal grounds: {`${h.decision?.legalGrounds}`} +
+
+ +
+ Explanation:{" "} + {`${h.decision?.detailedExplanation}`} +
+
+ + )} + + )} + +
+ {reportHistoryFormatter(h.createdAt)} +
+
+ ); + } else { + return null; + } + })} + +
+
+
+ +
+ {({ handleSubmit }) => ( + + + + {({ input }) => ( +