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/src/core/client/stream/App/App.tsx b/client/src/core/client/stream/App/App.tsx index 915d7c2db9..eaace3541a 100644 --- a/client/src/core/client/stream/App/App.tsx +++ b/client/src/core/client/stream/App/App.tsx @@ -4,6 +4,7 @@ import React, { FunctionComponent } from "react"; import { useCoralContext } from "coral-framework/lib/bootstrap/CoralContext"; import CLASSES from "coral-stream/classes"; +import NotificationsQuery from "coral-stream/tabs/Notifications/NotificationsQuery"; import { HorizontalGutter, TabContent, TabPane } from "coral-ui/components/v2"; import Comments from "../tabs/Comments"; @@ -18,10 +19,12 @@ type TabValue = "COMMENTS" | "PROFILE" | "DISCUSSIONS" | "%future added value"; export interface AppProps { activeTab: TabValue; + dsaFeaturesEnabled: boolean; } const App: FunctionComponent = (props) => { const { browserInfo } = useCoralContext(); + return ( = (props) => { > + {props.dsaFeaturesEnabled && ( + + + + )} diff --git a/client/src/core/client/stream/App/AppContainer.tsx b/client/src/core/client/stream/App/AppContainer.tsx index 4852e1b921..1d8ae524ae 100644 --- a/client/src/core/client/stream/App/AppContainer.tsx +++ b/client/src/core/client/stream/App/AppContainer.tsx @@ -30,16 +30,18 @@ interface Props { } const AppContainer: FunctionComponent = ({ disableListeners }) => { - const [{ activeTab }] = useLocal(graphql` - fragment AppContainerLocal on Local { - activeTab - } - `); + const [{ activeTab, dsaFeaturesEnabled }] = + useLocal(graphql` + fragment AppContainerLocal on Local { + activeTab + dsaFeaturesEnabled + } + `); return ( <> {disableListeners ? null : listeners} - + ); }; diff --git a/client/src/core/client/stream/App/TabBar.css b/client/src/core/client/stream/App/TabBar.css index 28793e4b81..8b63940722 100644 --- a/client/src/core/client/stream/App/TabBar.css +++ b/client/src/core/client/stream/App/TabBar.css @@ -1,8 +1,9 @@ .smallTab { - padding-top: var(--spacing-3); - padding-bottom: var(--spacing-3); - padding-left: var(--spacing-3); - padding-right: var(--spacing-3); + padding: var(--spacing-3); +} + +.condensedTab { + padding: var(--spacing-2); } .configureTab { diff --git a/client/src/core/client/stream/App/TabBar.tsx b/client/src/core/client/stream/App/TabBar.tsx index d9b5614b9b..3deba8cc90 100644 --- a/client/src/core/client/stream/App/TabBar.tsx +++ b/client/src/core/client/stream/App/TabBar.tsx @@ -5,10 +5,12 @@ import useGetMessage from "coral-framework/lib/i18n/useGetMessage"; import { GQLSTORY_MODE } from "coral-framework/schema"; import CLASSES from "coral-stream/classes"; import { + ActiveNotificationBellIcon, CogIcon, ConversationChatIcon, ConversationQuestionWarningIcon, MessagesBubbleSquareIcon, + NotificationBellIcon, RatingStarIcon, SingleNeutralCircleIcon, SvgIcon, @@ -17,7 +19,12 @@ import { MatchMedia, Tab, TabBar } from "coral-ui/components/v2"; import styles from "./TabBar.css"; -type TabValue = "COMMENTS" | "PROFILE" | "DISCUSSIONS" | "%future added value"; +type TabValue = + | "COMMENTS" + | "PROFILE" + | "DISCUSSIONS" + | "NOTIFICATIONS" + | "%future added value"; export interface Props { activeTab: TabValue; @@ -25,6 +32,8 @@ export interface Props { showProfileTab: boolean; showDiscussionsTab: boolean; showConfigureTab: boolean; + showNotificationsTab: boolean; + hasNewNotifications: boolean; mode: | "COMMENTS" | "QA" @@ -155,6 +164,29 @@ const AppTabBar: FunctionComponent = (props) => { )} )} + + {props.showNotificationsTab && ( + +
+ +
+
+ )} )} diff --git a/client/src/core/client/stream/App/TabBarContainer.tsx b/client/src/core/client/stream/App/TabBarContainer.tsx index 294bcdf957..0373c7d74e 100644 --- a/client/src/core/client/stream/App/TabBarContainer.tsx +++ b/client/src/core/client/stream/App/TabBarContainer.tsx @@ -30,11 +30,14 @@ export const TabBarContainer: FunctionComponent = ({ settings, setActiveTab, }) => { - const [{ activeTab }] = useLocal(graphql` - fragment TabBarContainerLocal on Local { - activeTab - } - `); + const [{ activeTab, dsaFeaturesEnabled, hasNewNotifications }] = + useLocal(graphql` + fragment TabBarContainerLocal on Local { + activeTab + dsaFeaturesEnabled + hasNewNotifications + } + `); const handleSetActiveTab = useCallback( (tab: SetActiveTabInput["tab"]) => { void setActiveTab({ tab }); @@ -59,6 +62,11 @@ export const TabBarContainer: FunctionComponent = ({ [viewer, story] ); + const showNotificationsTab = useMemo( + () => !!viewer && !!dsaFeaturesEnabled, + [viewer, dsaFeaturesEnabled] + ); + return ( = ({ showProfileTab={!!viewer} showDiscussionsTab={showDiscussionsTab} showConfigureTab={showConfigureTab} + showNotificationsTab={showNotificationsTab} + hasNewNotifications={!!hasNewNotifications} onTabClick={handleSetActiveTab} /> ); diff --git a/client/src/core/client/stream/App/TabBarQuery.tsx b/client/src/core/client/stream/App/TabBarQuery.tsx index 1b5d488e88..26b470da79 100644 --- a/client/src/core/client/stream/App/TabBarQuery.tsx +++ b/client/src/core/client/stream/App/TabBarQuery.tsx @@ -11,12 +11,15 @@ import ErrorReporterSetUserContainer from "./ErrorReporterSetUserContainer"; import TabBarContainer from "./TabBarContainer"; const TabBarQuery: FunctionComponent = () => { - const [{ storyID, storyURL }] = useLocal(graphql` - fragment TabBarQueryLocal on Local { - storyID - storyURL - } - `); + const [{ storyID, storyURL, hasNewNotifications }, setLocal] = + useLocal(graphql` + fragment TabBarQueryLocal on Local { + storyID + storyURL + hasNewNotifications + } + `); + return ( query={graphql` @@ -24,6 +27,7 @@ const TabBarQuery: FunctionComponent = () => { viewer { ...TabBarContainer_viewer ...ErrorReporterSetUserContainer_viewer + hasNewNotifications } story(id: $storyID, url: $storyURL) { ...TabBarContainer_story @@ -46,6 +50,13 @@ const TabBarQuery: FunctionComponent = () => { ) : null; + if (hasNewNotifications === null) { + setLocal({ + hasNewNotifications: + props && props.viewer ? props.viewer.hasNewNotifications : false, + }); + } + return ( <> {ErrorReporterSetUser} diff --git a/client/src/core/client/stream/classes.ts b/client/src/core/client/stream/classes.ts index 239fd069fd..b4de3f6efc 100644 --- a/client/src/core/client/stream/classes.ts +++ b/client/src/core/client/stream/classes.ts @@ -77,6 +77,8 @@ const CLASSES = { configure: "coral coral-tabBar-tab coral-tabBar-configure", activeTab: "coral-tabBar-tab-active", + + notifications: "coral coral-tabBar-tab coral-tabBar-notifications", }, /** @@ -142,6 +144,10 @@ const CLASSES = { settings: "coral coral-tabBarSecondary-tab coral-tabBarMyProfile-settings", }, + tabBarNotifications: { + loadMore: "coral coral-tabBarNotifications-loadMore", + }, + /** * createComment is the comment creation box where a user can write a comment. */ diff --git a/client/src/core/client/stream/local/initLocalState.ts b/client/src/core/client/stream/local/initLocalState.ts index 2549ad3d29..2be1a5da29 100644 --- a/client/src/core/client/stream/local/initLocalState.ts +++ b/client/src/core/client/stream/local/initLocalState.ts @@ -179,5 +179,7 @@ export const createInitLocalState: (options: Options) => InitLocalState = const dsaFeaturesEnabled = staticConfig?.dsaFeaturesEnabled ?? false; localRecord.setValue(dsaFeaturesEnabled, "dsaFeaturesEnabled"); + + localRecord.setValue(null, "hasNewNotifications"); }); }; diff --git a/client/src/core/client/stream/local/local.graphql b/client/src/core/client/stream/local/local.graphql index 131585783f..d5e573a3dc 100644 --- a/client/src/core/client/stream/local/local.graphql +++ b/client/src/core/client/stream/local/local.graphql @@ -129,4 +129,6 @@ extend type Local { refreshStream: Boolean dsaFeaturesEnabled: Boolean + + hasNewNotifications: Boolean } diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.css new file mode 100644 index 0000000000..3452ea87fd --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.css @@ -0,0 +1,47 @@ +.toggle { + padding: 0; + margin-bottom: var(--spacing-1); + + border-style: none; + + background-color: transparent; + + color: var(--palette-primary-300); + font-family: var(--font-family-primary); + font-style: normal; + font-weight: var(--font-weight-primary-bold); + font-size: var(--font-size-2); + + &:hover, + &.mouseHover { + color: var(--palette-primary-200); + } + &:active, + &.active { + color: var(--palette-primary-400); + } +} + +.content { + border-left-style: solid; + border-left-width: 2px; + border-left-color: var(--palette-grey-400); + + padding-left: var(--spacing-3); +} + +.timestamp { + font-family: var(--font-family-primary); + font-style: normal; + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-2); + color: var(--palette-grey-500); +} + +.storyTitle { + font-family: var(--font-family-primary); + font-style: normal; + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-2); + color: var(--palette-text-900); +} diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx new file mode 100644 index 0000000000..701d8636fa --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationCommentContainer.tsx @@ -0,0 +1,95 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback, useState } from "react"; +import { graphql } from "react-relay"; + +import { withFragmentContainer } from "coral-framework/lib/relay"; +import { GQLCOMMENT_STATUS } from "coral-framework/schema"; +import HTMLContent from "coral-stream/common/HTMLContent"; +import { Timestamp } from "coral-ui/components/v2"; + +import { + COMMENT_STATUS, + NotificationCommentContainer_comment, +} from "coral-stream/__generated__/NotificationCommentContainer_comment.graphql"; + +import styles from "./NotificationCommentContainer.css"; + +interface Props { + comment: NotificationCommentContainer_comment; + status: COMMENT_STATUS; +} + +const NotificationCommentContainer: FunctionComponent = ({ + comment, + status, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const onToggleOpenClosed = useCallback(() => { + setIsOpen(!isOpen); + }, [setIsOpen, isOpen]); + + return ( + <> + {status === GQLCOMMENT_STATUS.APPROVED && isOpen && ( + + )} + {status === GQLCOMMENT_STATUS.APPROVED && !isOpen && ( + + )} + {status === GQLCOMMENT_STATUS.REJECTED && isOpen && ( + + )} + {status === GQLCOMMENT_STATUS.REJECTED && !isOpen && ( + + )} + + {isOpen && ( +
+ + {comment.createdAt} + + {comment.body || ""} + {comment.story.metadata?.title && ( +
+ {comment.story.metadata.title} +
+ )} +
+ )} + + ); +}; + +const enhanced = withFragmentContainer({ + comment: graphql` + fragment NotificationCommentContainer_comment on Comment { + createdAt + body + status + story { + metadata { + title + } + } + } + `, +})(NotificationCommentContainer); + +export default enhanced; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css new file mode 100644 index 0000000000..6a59574db4 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.css @@ -0,0 +1,85 @@ +.root { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-regular); + font-size: var(--font-size-2); + + box-sizing: border-box; + + border-left-style: solid; + border-left-width: 4px; + + margin: var(--spacing-2); + padding: var(--spacing-2); +} + +.seen { + border-left-color: var(--palette-background-body); +} + +.notSeen { + border-left-color: var(--palette-primary-500); +} + +.title { + width: 100%; + display: flex; + justify-content: left; + align-items: center; + + margin-bottom: var(--spacing-2); +} + +.titleText { + margin-left: var(--spacing-2); + + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-bold); + font-size: var(--font-size-3); + + word-break: break-word; +} + +.body { + margin-left: var(--spacing-5); + margin-bottom: var(--spacing-2); + + width: calc(100% - var(--spacing-4)); + + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-regular); + font-size: var(--font-size-2); +} + +.contextItem { + margin-left: var(--spacing-5); + margin-bottom: var(--spacing-2); +} + +.footer { + margin-left: var(--spacing-5); +} + +.timestamp { + font-family: var(--font-family-primary); + font-style: normal; + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-2); + color: var(--palette-text-500); +} + +.divider { + margin-left: var(--spacing-4); + + width: calc(100% - var(--spacing-4) * 2); + height: 1px; + + background-color: var(--palette-grey-200); +} + +.commentData { + padding: var(--spacing-4); + + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-regular); + font-size: var(--font-size-2); +} diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx new file mode 100644 index 0000000000..54f128dedf --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationContainer.tsx @@ -0,0 +1,91 @@ +import cn from "classnames"; +import React, { FunctionComponent, useMemo } from "react"; +import { graphql } from "react-relay"; + +import { withFragmentContainer } from "coral-framework/lib/relay"; +import { CheckCircleIcon, SvgIcon } from "coral-ui/components/icons"; +import { Timestamp } from "coral-ui/components/v2"; + +import { NotificationContainer_notification } from "coral-stream/__generated__/NotificationContainer_notification.graphql"; +import { NotificationContainer_viewer } from "coral-stream/__generated__/NotificationContainer_viewer.graphql"; + +import NotificationCommentContainer from "./NotificationCommentContainer"; + +import styles from "./NotificationContainer.css"; + +interface Props { + viewer: NotificationContainer_viewer | null; + notification: NotificationContainer_notification; +} + +const NotificationContainer: FunctionComponent = ({ + notification: { title, body, comment, createdAt, commentStatus }, + viewer, +}) => { + const seen = useMemo(() => { + if (!viewer) { + return false; + } + + const createdAtDate = new Date(createdAt); + const lastSeenDate = viewer.lastSeenNotificationDate + ? new Date(viewer.lastSeenNotificationDate) + : new Date(0); + + return createdAtDate.getTime() <= lastSeenDate.getTime(); + }, [createdAt, viewer]); + + return ( + <> +
+ {title && ( +
+ +
{title}
+
+ )} + {body &&
{body}
} + {comment && ( +
+ +
+ )} +
+ {createdAt} +
+
+
+ + ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment NotificationContainer_viewer on User { + lastSeenNotificationDate + } + `, + notification: graphql` + fragment NotificationContainer_notification on Notification { + id + createdAt + title + body + comment { + ...NotificationCommentContainer_comment + status + } + commentStatus + } + `, +})(NotificationContainer); + +export default enhanced; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css new file mode 100644 index 0000000000..fc49a10ff4 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.css @@ -0,0 +1,11 @@ +.userBox { + padding-bottom: var(--spacing-2); +} + +.title { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-3); + + word-break: break-word; +} diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx new file mode 100644 index 0000000000..bafb554fd6 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsContainer.tsx @@ -0,0 +1,66 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback, useEffect } from "react"; +import { graphql } from "react-relay"; + +import { useLocal, withFragmentContainer } from "coral-framework/lib/relay"; +import { UserBoxContainer } from "coral-stream/common/UserBox"; + +import { NotificationsContainer_settings } from "coral-stream/__generated__/NotificationsContainer_settings.graphql"; +import { NotificationsContainer_viewer } from "coral-stream/__generated__/NotificationsContainer_viewer.graphql"; +import { NotificationsContainerLocal } from "coral-stream/__generated__/NotificationsContainerLocal.graphql"; + +import NotificationsListQuery from "./NotificationsListQuery"; + +import styles from "./NotificationsContainer.css"; + +interface Props { + viewer: NotificationsContainer_viewer; + settings: NotificationsContainer_settings; +} + +const NotificationsContainer: FunctionComponent = ({ + viewer, + settings, +}) => { + const [, setLocal] = useLocal(graphql` + fragment NotificationsContainerLocal on Local { + hasNewNotifications + } + `); + + const setViewed = useCallback(() => { + setLocal({ hasNewNotifications: false }); + }, [setLocal]); + + useEffect(() => { + setTimeout(setViewed, 300); + }, [setViewed]); + + return ( + <> +
+ +
+
+ Notifications +
+ + + ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment NotificationsContainer_viewer on User { + id + ...UserBoxContainer_viewer + } + `, + settings: graphql` + fragment NotificationsContainer_settings on Settings { + ...UserBoxContainer_settings + } + `, +})(NotificationsContainer); + +export default enhanced; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsListQuery.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsListQuery.tsx new file mode 100644 index 0000000000..1b53319661 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsListQuery.tsx @@ -0,0 +1,41 @@ +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import { QueryRenderer } from "coral-framework/lib/relay"; +import { Spinner } from "coral-ui/components/v2"; +import { QueryError } from "coral-ui/components/v3"; + +import { NotificationsListQuery as QueryTypes } from "coral-stream/__generated__/NotificationsListQuery.graphql"; +import NotificationsPaginator from "./NotificationsPaginator"; + +interface Props { + viewerID: string; +} + +const NotificationsListQuery: FunctionComponent = ({ viewerID }) => { + return ( + + query={graphql` + query NotificationsListQuery($viewerID: ID!) { + ...NotificationsPaginator_query @arguments(viewerID: $viewerID) + } + `} + variables={{ + viewerID, + }} + render={({ error, props }) => { + if (error) { + return ; + } + + if (!props) { + return ; + } + + return ; + }} + /> + ); +}; + +export default NotificationsListQuery; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx new file mode 100644 index 0000000000..32c9c714c7 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsPaginator.tsx @@ -0,0 +1,144 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback, useState } from "react"; +import { graphql, RelayPaginationProp } from "react-relay"; + +import { useRefetch, withPaginationContainer } from "coral-framework/lib/relay"; +import CLASSES from "coral-stream/classes"; +import Spinner from "coral-stream/common/Spinner"; +import { Button } from "coral-ui/components/v3"; + +import { NotificationsPaginator_query } from "coral-stream/__generated__/NotificationsPaginator_query.graphql"; +import { NotificationsPaginatorPaginationQueryVariables } from "coral-stream/__generated__/NotificationsPaginatorPaginationQuery.graphql"; + +import NotificationContainer from "./NotificationContainer"; + +interface Props { + query: NotificationsPaginator_query; + relay: RelayPaginationProp; + viewerID: string; +} + +const NotificationsPaginator: FunctionComponent = (props) => { + const [disableLoadMore, setDisableLoadMore] = useState(false); + + const [, isRefetching] = + useRefetch( + props.relay, + 10, + { viewerID: props.viewerID } + ); + + const loadMore = useCallback(() => { + if (!props.relay.hasMore() || props.relay.isLoading()) { + return; + } + + setDisableLoadMore(true); + + props.relay.loadMore( + 10, // Fetch the next 10 feed items + (error: any) => { + setDisableLoadMore(false); + + if (error) { + // eslint-disable-next-line no-console + console.error(error); + } + } + ); + }, [props.relay]); + + if (!props.query || !props.query.viewer) { + return null; + } + + return ( +
+ {props.query.notifications.edges.map(({ node }) => { + return ( + + ); + })} + {isRefetching && } + {!isRefetching && !disableLoadMore && props.relay.hasMore() && ( + + + + )} +
+ ); +}; + +type FragmentVariables = NotificationsPaginatorPaginationQueryVariables; + +const enhanced = withPaginationContainer< + Props, + NotificationsPaginatorPaginationQueryVariables, + FragmentVariables +>( + { + query: graphql` + fragment NotificationsPaginator_query on Query + @argumentDefinitions( + count: { type: "Int", defaultValue: 10 } + cursor: { type: "Cursor" } + viewerID: { type: "ID!" } + ) { + viewer { + ...NotificationContainer_viewer + } + notifications(ownerID: $viewerID, after: $cursor, first: $count) + @connection(key: "NotificationsPaginator_notifications") { + edges { + node { + id + ...NotificationContainer_notification + } + } + } + } + `, + }, + { + getConnectionFromProps(props) { + return props.query && props.query.notifications; + }, + getVariables(props, { count, cursor }, fragmentVariables) { + return { + ...fragmentVariables, + count, + cursor, + viewerID: props.viewerID, + }; + }, + query: graphql` + # Pagination query to be fetched upon calling 'loadMore'. + # Notice that we re-use our fragment, and the shape of this query matches our fragment spec. + query NotificationsPaginatorPaginationQuery( + $viewerID: ID! + $cursor: Cursor + $count: Int! + ) { + ...NotificationsPaginator_query + @arguments(viewerID: $viewerID, count: $count, cursor: $cursor) + } + `, + } +)(NotificationsPaginator); + +export default enhanced; diff --git a/client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx b/client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx new file mode 100644 index 0000000000..20f1adbb65 --- /dev/null +++ b/client/src/core/client/stream/tabs/Notifications/NotificationsQuery.tsx @@ -0,0 +1,75 @@ +import { Localized } from "@fluent/react/compat"; +import { once } from "lodash"; +import React, { FunctionComponent, Suspense } from "react"; +import { graphql } from "react-relay"; + +import { QueryRenderData, QueryRenderer } from "coral-framework/lib/relay"; +import { Delay, Spinner } from "coral-ui/components/v2"; +import { QueryError } from "coral-ui/components/v3"; + +import { NotificationsQuery as QueryTypes } from "coral-stream/__generated__/NotificationsQuery.graphql"; + +const loadNotificationsContainer = () => + import("./NotificationsContainer" /* webpackChunkName: "notifications" */); + +// (cvle) For some reason without `setTimeout` this request will block other requests. +const preload = once(() => + setTimeout(() => { + void loadNotificationsContainer(); + }, 0) +); + +const LazyLoadContainer = React.lazy(loadNotificationsContainer); + +export const render = ({ error, props }: QueryRenderData) => { + if (error) { + return ; + } + + preload(); + + if (!props) { + return ( + + + + ); + } + + if (!props.viewer) { + return ( + +
Error loading notifications
+
+ ); + } + + return ( + }> + + + ); +}; + +const NotificationsQuery: FunctionComponent = () => { + return ( + + query={graphql` + query NotificationsQuery { + viewer { + ...NotificationsContainer_viewer + } + settings { + ...NotificationsContainer_settings + } + } + `} + variables={{}} + render={(data) => { + return render(data); + }} + /> + ); +}; + +export default NotificationsQuery; diff --git a/client/src/core/client/stream/test/comments/stream/reportComment.spec.tsx b/client/src/core/client/stream/test/comments/stream/reportComment.spec.tsx index c78f55b523..d169e6cbd6 100644 --- a/client/src/core/client/stream/test/comments/stream/reportComment.spec.tsx +++ b/client/src/core/client/stream/test/comments/stream/reportComment.spec.tsx @@ -24,7 +24,7 @@ async function createTestRenderer( return stories[0]; }), viewer: sinon.stub().returns(commenters[0]), - settings: sinon.stub().returns(settings), + settings: sinon.stub().returns({ ...settings, dsa: { enabled: true } }), ...resolver.Query, }, Mutation: { diff --git a/client/src/core/client/stream/test/create.tsx b/client/src/core/client/stream/test/create.tsx index 7eb6c6827d..26ad0a9e76 100644 --- a/client/src/core/client/stream/test/create.tsx +++ b/client/src/core/client/stream/test/create.tsx @@ -40,6 +40,7 @@ const initLocalState = ( if (params.initLocalState) { params.initLocalState(localRecord, source, environment); } + localRecord.setValue(false, "hasNewNotifications"); }; export default function create(params: CreateTestRendererParams) { diff --git a/client/src/core/client/stream/test/fixtures.ts b/client/src/core/client/stream/test/fixtures.ts index ba7a63e661..b4b2d19d7b 100644 --- a/client/src/core/client/stream/test/fixtures.ts +++ b/client/src/core/client/stream/test/fixtures.ts @@ -240,6 +240,7 @@ export const baseUser = createFixture({ }, ], avatar: NULL_VALUE, + hasNewNotifications: false, }); export const userWithModMessageHistory = createFixture( diff --git a/client/src/core/client/ui/components/icons/ActiveNotificationBellIcon.tsx b/client/src/core/client/ui/components/icons/ActiveNotificationBellIcon.tsx new file mode 100644 index 0000000000..749cf8bc26 --- /dev/null +++ b/client/src/core/client/ui/components/icons/ActiveNotificationBellIcon.tsx @@ -0,0 +1,32 @@ +import React, { FunctionComponent } from "react"; + +const ActiveNotificationBellIcon: FunctionComponent = () => { + return ( + + + + + + + ); +}; + +export default ActiveNotificationBellIcon; diff --git a/client/src/core/client/ui/components/icons/NotificationBellIcon.tsx b/client/src/core/client/ui/components/icons/NotificationBellIcon.tsx new file mode 100644 index 0000000000..7ebbefb703 --- /dev/null +++ b/client/src/core/client/ui/components/icons/NotificationBellIcon.tsx @@ -0,0 +1,28 @@ +import React, { FunctionComponent } from "react"; + +const NotificationBellIcon: FunctionComponent = () => { + return ( + + + + + + ); +}; + +export default NotificationBellIcon; diff --git a/client/src/core/client/ui/components/icons/index.ts b/client/src/core/client/ui/components/icons/index.ts index e6987c823b..6a8e2f0f4d 100644 --- a/client/src/core/client/ui/components/icons/index.ts +++ b/client/src/core/client/ui/components/icons/index.ts @@ -1,3 +1,4 @@ +export { default as ActiveNotificationBellIcon } from "./ActiveNotificationBellIcon"; export { default as AddIcon } from "./AddIcon"; export { default as AlarmBellIcon } from "./AlarmBellIcon"; export { default as AlarmClockIcon } from "./AlarmClockIcon"; @@ -51,6 +52,7 @@ export { default as MultipleActionsChatIcon } from "./MultipleActionsChatIcon"; export { default as MultipleNeutralIcon } from "./MultipleNeutralIcon"; export { default as NavigationMenuHorizontalIcon } from "./NavigationMenuHorizontalIcon"; export { default as NavigationMenuVerticalIcon } from "./NavigationMenuVerticalIcon"; +export { default as NotificationBellIcon } from "./NotificationBellIcon"; export { default as PaperWriteIcon } from "./PaperWriteIcon"; export { default as PencilIcon } from "./PencilIcon"; export { default as QuestionCircleIcon } from "./QuestionCircleIcon"; diff --git a/locales/en-US/stream.ftl b/locales/en-US/stream.ftl index 85edf661d9..854659ca3f 100644 --- a/locales/en-US/stream.ftl +++ b/locales/en-US/stream.ftl @@ -28,6 +28,8 @@ general-tabBar-myProfileTab = My Profile general-tabBar-discussionsTab = Discussions general-tabBar-reviewsTab = Reviews general-tabBar-configure = Configure +general-tabBar-notifications = Notifications +general-tabBar-notifications-hasNew = Notifications (has new) general-mainTablist = .aria-label = Main Tablist @@ -1029,3 +1031,14 @@ stream-footer-links-discussions = More discussions .title = Go to more discussions stream-footer-navigation = .aria-label = Comments Footer + +## Notifications + +notifications-title = Notifications +notifications-loadMore = Load More +notification-comment-toggle-approved-open = Approved comment +notification-comment-toggle-approved-closed = + Approved comment +notification-comment-toggle-rejected-open = Rejected comment +notification-comment-toggle-rejected-closed = + Rejected comment +notification-comment-toggle-default-open = Comment +notification-comment-toggle-default-closed = + Comment \ No newline at end of file diff --git a/server/src/core/server/app/handlers/api/account/notifications.ts b/server/src/core/server/app/handlers/api/account/notifications.ts index ff7daccabb..dbec9512c3 100644 --- a/server/src/core/server/app/handlers/api/account/notifications.ts +++ b/server/src/core/server/app/handlers/api/account/notifications.ts @@ -2,7 +2,7 @@ import { AppOptions } from "coral-server/app"; import { RequestLimiter } from "coral-server/app/request/limiter"; import { updateUserNotificationSettings } from "coral-server/models/user"; import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt"; -import { verifyUnsubscribeTokenString } from "coral-server/services/notifications/categories/unsubscribe"; +import { verifyUnsubscribeTokenString } from "coral-server/services/notifications/email/categories/unsubscribe"; import { RequestHandler, TenantCoralRequest } from "coral-server/types/express"; export type UnsubscribeCheckOptions = Pick< diff --git a/server/src/core/server/cron/notificationDigesting.ts b/server/src/core/server/cron/notificationDigesting.ts index 993dd1e8b3..a6f5c6a317 100644 --- a/server/src/core/server/cron/notificationDigesting.ts +++ b/server/src/core/server/cron/notificationDigesting.ts @@ -5,7 +5,7 @@ import { MongoContext } from "coral-server/data/context"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates"; import { JWTSigningConfig } from "coral-server/services/jwt"; -import NotificationContext from "coral-server/services/notifications/context"; +import NotificationContext from "coral-server/services/notifications/email/context"; import { TenantCache } from "coral-server/services/tenant/cache"; import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types"; diff --git a/server/src/core/server/data/context.ts b/server/src/core/server/data/context.ts index 375a4b37be..9aa1ec6f15 100644 --- a/server/src/core/server/data/context.ts +++ b/server/src/core/server/data/context.ts @@ -8,6 +8,7 @@ import { DSAReport } from "coral-server/models/dsaReport/report"; import { createCollection } from "coral-server/models/helpers"; import { Invite } from "coral-server/models/invite"; import { MigrationRecord } from "coral-server/models/migration"; +import { Notification } from "coral-server/models/notifications/notification"; import { PersistedQuery } from "coral-server/models/queries"; import { SeenComments } from "coral-server/models/seenComments/seenComments"; import { Site } from "coral-server/models/site"; @@ -37,6 +38,7 @@ export interface MongoContext { >; seenComments(): Collection>; dsaReports(): Collection>; + notifications(): Collection>; } export class MongoContextImpl implements MongoContext { @@ -88,6 +90,10 @@ export class MongoContextImpl implements MongoContext { public dsaReports(): Collection> { return createCollection("dsaReports")(this.live); } + public notifications(): Collection> { + return createCollection("notifications")(this.live); + } + public archivedComments(): Collection> { if (!this.archive) { throw new Error( diff --git a/server/src/core/server/events/listeners/notifier.ts b/server/src/core/server/events/listeners/notifier.ts index 6ecd044140..a8dfb80142 100644 --- a/server/src/core/server/events/listeners/notifier.ts +++ b/server/src/core/server/events/listeners/notifier.ts @@ -1,5 +1,5 @@ import { NotifierQueue } from "coral-server/queue/tasks/notifier"; -import { categories } from "coral-server/services/notifications/categories"; +import { categories } from "coral-server/services/notifications/email/categories"; import { CommentFeaturedCoralEventPayload, diff --git a/server/src/core/server/graph/context.ts b/server/src/core/server/graph/context.ts index d68c3feca5..4a27d6e557 100644 --- a/server/src/core/server/graph/context.ts +++ b/server/src/core/server/graph/context.ts @@ -24,6 +24,7 @@ import { WordListService } from "coral-server/services/comments/pipeline/phases/ import { ErrorReporter } from "coral-server/services/errors"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { TenantCache } from "coral-server/services/tenant/cache"; import { Request } from "coral-server/types/express"; @@ -103,6 +104,8 @@ export default class GraphContext { public readonly wordList: WordListService; + public readonly notifications: InternalNotificationContext; + constructor(options: GraphContextOptions) { this.id = options.id || uuid(); this.now = options.now || new Date(); @@ -151,5 +154,11 @@ export default class GraphContext { this.disableCaching, this.config.get("redis_cache_expiry") / 1000 ); + + this.notifications = new InternalNotificationContext( + this.mongo, + this.i18n, + this.logger + ); } } diff --git a/server/src/core/server/graph/loaders/DSAReports.ts b/server/src/core/server/graph/loaders/DSAReports.ts new file mode 100644 index 0000000000..af8d1da4e8 --- /dev/null +++ b/server/src/core/server/graph/loaders/DSAReports.ts @@ -0,0 +1,22 @@ +import DataLoader from "dataloader"; + +import { find } from "coral-server/models/dsaReport/report"; + +import GraphContext from "../context"; +import { createManyBatchLoadFn } from "./util"; + +export interface FindDSAReportInput { + id: string; +} + +export default (ctx: GraphContext) => ({ + dsaReport: new DataLoader( + createManyBatchLoadFn((input: FindDSAReportInput) => + find(ctx.mongo, ctx.tenant, input) + ), + { + cacheKeyFn: (input: FindDSAReportInput) => `${input.id}`, + cache: !ctx.disableCaching, + } + ), +}); diff --git a/server/src/core/server/graph/loaders/Notifications.ts b/server/src/core/server/graph/loaders/Notifications.ts new file mode 100644 index 0000000000..de5bbbfce5 --- /dev/null +++ b/server/src/core/server/graph/loaders/Notifications.ts @@ -0,0 +1,23 @@ +import Context from "coral-server/graph/context"; +import { + hasNewNotifications, + NotificationsConnectionInput, + retrieveNotificationsConnection, +} from "coral-server/models/notifications/notification"; + +export default (ctx: Context) => ({ + connection: async ({ + ownerID, + first, + after, + }: NotificationsConnectionInput) => { + return await retrieveNotificationsConnection(ctx.mongo, ctx.tenant.id, { + ownerID, + first, + after, + }); + }, + hasNewNotifications: async (ownerID: string, lastSeen: Date) => { + return hasNewNotifications(ctx.tenant.id, ctx.mongo, ownerID, lastSeen); + }, +}); diff --git a/server/src/core/server/graph/loaders/index.ts b/server/src/core/server/graph/loaders/index.ts index 1b47829656..906ed2d51a 100644 --- a/server/src/core/server/graph/loaders/index.ts +++ b/server/src/core/server/graph/loaders/index.ts @@ -4,6 +4,8 @@ import Auth from "./Auth"; import CommentActions from "./CommentActions"; import CommentModerationActions from "./CommentModerationActions"; import Comments from "./Comments"; +import DSAReports from "./DSAReports"; +import Notifications from "./Notifications"; import SeenComments from "./SeenComments"; import Sites from "./Sites"; import Stories from "./Stories"; @@ -18,4 +20,6 @@ export default (ctx: Context) => ({ Users: Users(ctx), Sites: Sites(ctx), SeenComments: SeenComments(ctx), + Notifications: Notifications(ctx), + DSAReports: DSAReports(ctx), }); diff --git a/server/src/core/server/graph/mutators/Actions.ts b/server/src/core/server/graph/mutators/Actions.ts index 5a8c6757ba..4687c2bbd4 100644 --- a/server/src/core/server/graph/mutators/Actions.ts +++ b/server/src/core/server/graph/mutators/Actions.ts @@ -21,6 +21,7 @@ export const Actions = (ctx: GraphContext) => ({ ctx.cache, ctx.config, ctx.broker, + ctx.notifications, ctx.tenant, input.commentID, input.commentRevisionID, @@ -39,6 +40,7 @@ export const Actions = (ctx: GraphContext) => ({ ctx.cache, ctx.config, ctx.broker, + ctx.notifications, ctx.tenant, input.commentID, input.commentRevisionID, diff --git a/server/src/core/server/graph/mutators/Comments.ts b/server/src/core/server/graph/mutators/Comments.ts index ebd8fba08a..34374151ba 100644 --- a/server/src/core/server/graph/mutators/Comments.ts +++ b/server/src/core/server/graph/mutators/Comments.ts @@ -14,6 +14,7 @@ import { } from "coral-server/services/comments/actions"; import { CreateCommentMediaInput } from "coral-server/services/comments/media"; import { publishCommentFeatured } from "coral-server/services/events"; +import { NotificationType } from "coral-server/services/notifications/internal/context"; import { markSeen } from "coral-server/services/seenComments"; import { approveComment, @@ -57,6 +58,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.cache, ctx.config, ctx.broker, + ctx.notifications, ctx.tenant, ctx.user!, { @@ -250,6 +252,7 @@ export const Comments = (ctx: GraphContext) => ({ ctx.cache, ctx.config, ctx.broker, + ctx.notifications, ctx.tenant, commentID, commentRevisionID, @@ -274,6 +277,12 @@ export const Comments = (ctx: GraphContext) => ({ // Publish that the comment was featured. await publishCommentFeatured(ctx.broker, comment); + await ctx.notifications.create(ctx.tenant.id, ctx.tenant.locale, { + targetUserID: comment.authorID!, + comment, + type: NotificationType.COMMENT_FEATURED, + }); + // Return it to the next step. return comment; }, diff --git a/server/src/core/server/graph/resolvers/Notification.ts b/server/src/core/server/graph/resolvers/Notification.ts new file mode 100644 index 0000000000..08131c481f --- /dev/null +++ b/server/src/core/server/graph/resolvers/Notification.ts @@ -0,0 +1,34 @@ +import { CommentNotFoundError } from "coral-server/errors"; +import { Notification } from "coral-server/models/notifications/notification"; + +import { GQLNotificationTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export const NotificationResolver: Required< + GQLNotificationTypeResolver +> = { + id: ({ id }) => id, + ownerID: ({ ownerID }) => ownerID, + createdAt: ({ createdAt }) => createdAt, + title: ({ title }) => title, + body: ({ body }) => body, + comment: async ({ commentID }, input, ctx) => { + if (!commentID) { + return null; + } + + const comment = await ctx.loaders.Comments.comment.load(commentID); + if (!comment) { + throw new CommentNotFoundError(commentID); + } + + return comment; + }, + commentStatus: ({ commentStatus }) => commentStatus, + dsaReport: async ({ reportID }, input, ctx) => { + if (!reportID) { + return null; + } + + return await ctx.loaders.DSAReports.dsaReport.load({ id: reportID }); + }, +}; diff --git a/server/src/core/server/graph/resolvers/NotificationDSAReportDetails.ts b/server/src/core/server/graph/resolvers/NotificationDSAReportDetails.ts new file mode 100644 index 0000000000..2008143672 --- /dev/null +++ b/server/src/core/server/graph/resolvers/NotificationDSAReportDetails.ts @@ -0,0 +1,27 @@ +import { DSAReport } from "coral-server/models/dsaReport/report"; + +import { GQLNotificationDSAReportDetailsTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export const NotificationDSAReportDetailsResolver: Required< + GQLNotificationDSAReportDetailsTypeResolver +> = { + id: ({ id }) => id, + referenceID: ({ referenceID }) => referenceID, + comment: async ({ commentID }, input, ctx) => { + if (!commentID) { + return null; + } + + return await ctx.loaders.Comments.comment.load(commentID); + }, + user: async ({ userID }, input, ctx) => { + if (!userID) { + return null; + } + + return await ctx.loaders.Users.user.load(userID); + }, + lawBrokenDescription: ({ lawBrokenDescription }) => lawBrokenDescription, + additionalInformation: ({ additionalInformation }) => additionalInformation, + submissionID: ({ submissionID }) => submissionID, +}; diff --git a/server/src/core/server/graph/resolvers/Query.ts b/server/src/core/server/graph/resolvers/Query.ts index c8857f7aa7..f60792bd57 100644 --- a/server/src/core/server/graph/resolvers/Query.ts +++ b/server/src/core/server/graph/resolvers/Query.ts @@ -1,6 +1,8 @@ import { defaultTo } from "lodash"; +import { UserNotFoundError } from "coral-server/errors"; import { ACTION_TYPE } from "coral-server/models/action/comment"; +import { markLastSeenNotification } from "coral-server/models/notifications/notification"; import { getEmailDomain, getExternalModerationPhase, @@ -71,4 +73,25 @@ export const Query: Required> = { }, }, }), + notifications: async (source, { ownerID, first, after }, ctx) => { + const user = await ctx.loaders.Users.user.load(ownerID); + if (!user) { + throw new UserNotFoundError(ownerID); + } + + const connection = await ctx.loaders.Notifications.connection({ + ownerID, + first: defaultTo(first, 10), + after, + }); + + await markLastSeenNotification( + ctx.tenant.id, + ctx.mongo, + user, + connection.nodes.map((n) => n.createdAt) + ); + + return connection; + }, }; diff --git a/server/src/core/server/graph/resolvers/User.ts b/server/src/core/server/graph/resolvers/User.ts index f1ec3c7d22..dcf1a1e1fc 100644 --- a/server/src/core/server/graph/resolvers/User.ts +++ b/server/src/core/server/graph/resolvers/User.ts @@ -89,4 +89,17 @@ export const User: GQLUserTypeResolver = { return ctx.loaders.Stories.story.loadMany(results.map(({ _id }) => _id)); }, mediaSettings: ({ mediaSettings = {} }) => mediaSettings, + hasNewNotifications: ({ lastSeenNotificationDate }, input, ctx) => { + if (!ctx.user) { + return false; + } + + return ctx.loaders.Notifications.hasNewNotifications( + ctx.user.id, + ctx.user.lastSeenNotificationDate ?? new Date(0) + ); + }, + lastSeenNotificationDate: ({ lastSeenNotificationDate }) => { + return lastSeenNotificationDate ?? new Date(0); + }, }; diff --git a/server/src/core/server/graph/resolvers/index.ts b/server/src/core/server/graph/resolvers/index.ts index 3d25555cce..58df174566 100644 --- a/server/src/core/server/graph/resolvers/index.ts +++ b/server/src/core/server/graph/resolvers/index.ts @@ -45,6 +45,8 @@ import { ModMessageStatus } from "./ModMessageStatus"; import { ModMessageStatusHistory } from "./ModMessageStatusHistory"; import { Mutation } from "./Mutation"; import { NewCommentersConfiguration } from "./NewCommentersConfiguration"; +import { NotificationResolver as Notification } from "./Notification"; +import { NotificationDSAReportDetailsResolver as NotificationDSAReportDetails } from "./NotificationDSAReportDetails"; import { OIDCAuthIntegration } from "./OIDCAuthIntegration"; import { PremodStatus } from "./PremodStatus"; import { PremodStatusHistory } from "./PremodStatusHistory"; @@ -165,6 +167,8 @@ const Resolvers: GQLResolver = { YouTubeMediaConfiguration, LocalAuthIntegration, AuthenticationTargetFilter, + Notification, + NotificationDSAReportDetails, }; export default Resolvers; diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 8bc344577e..1cd096a231 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -3170,6 +3170,27 @@ type User { bio is a user-defined biography. """ bio: String + + """ + hasNewNotifications returns true if the user has received new notifications + since they last viewed their notifications tab. + """ + hasNewNotifications: Boolean! + @auth( + userIDField: "id" + roles: [ADMIN, MODERATOR] + permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED] + ) + + """ + lastSeenNotificationDate is the date of the last notification the user viewed. + """ + lastSeenNotificationDate: Time + @auth( + userIDField: "id" + roles: [ADMIN, MODERATOR] + permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED] + ) } """ @@ -4535,6 +4556,128 @@ type Queues { unarchiver: Queue! } +################################################################################ +## Notifications +################################################################################ + +type Notification { + """ + id is the uuid identifier for the notification. + """ + id: ID! + + """ + ownerID is the string identifier for who this notification is directed to. + """ + ownerID: ID! + + """ + createdAt is when this notification was created, used for sorting. + """ + createdAt: Time! + + """ + title is the title text of this notification. + """ + title: String + + """ + body is the text content of this notification. + """ + body: String + + """ + comment is the optional comment that is linked to this notification. + """ + comment: Comment + + """ + commentStatus is the optional status of the comment when the notification + was created. This allows for the context of the state of the comment to be + persisted even if the comment reference undergoes multiple moderation actions + since the notification was created. + """ + commentStatus: COMMENT_STATUS + + """ + dsaReport is the details of the DSA Report related to the notification. + This is usually in reference to the comment that is also related to + the notification. + """ + dsaReport: NotificationDSAReportDetails +} + +type NotificationEdge { + """ + node is the Flag for this edge. + """ + node: Notification! + + """ + cursor is used in pagination. + """ + cursor: Cursor! +} + +type NotificationsConnection { + """ + edges are a subset of FlagEdge's. + """ + edges: [NotificationEdge!]! + + """ + nodes is a list of Flags. + """ + nodes: [Notification!]! + + """ + pageInfo is information to aid in pagination. + """ + pageInfo: PageInfo! +} + +type NotificationDSAReportDetails { + """ + id is the primary identifier of the DSA report. + """ + id: ID! + + """ + referenceID is the friendly identifier that is human readable + for the DSA report. + """ + referenceID: String! + + """ + comment is the comment associated with the DSA report. + """ + comment: Comment + + """ + user is the target user (of the comment) the DSA report pertains to. + """ + user: User + + """ + lawBrokenDescription describes the law that was allegedly broken in the + DSA report. + """ + lawBrokenDescription: String @auth(roles: [ADMIN, MODERATOR]) + + """ + additionalInformation is any further relevant details added by the user + who filed the DSA report. + """ + additionalInformation: String @auth(roles: [ADMIN, MODERATOR]) + + """ + submissionID is the linking id that connects this DSA report to other reports + that may have been submitted at the same time by the same reporting user as + a collection of co-related DSA reports. + """ + submissionID: ID @auth(roles: [ADMIN, MODERATOR]) +} + ################################################################################ ## Query ################################################################################ @@ -4701,6 +4844,15 @@ type Query { emailDomain will return a specific emailDomain configuration if it exists. """ emailDomain(id: ID!): EmailDomain @auth(roles: [ADMIN]) + + """ + notifications will return a list of notifications for an ownerID. + """ + notifications( + ownerID: ID! + first: Int = 10 @constraint(max: 50) + after: Cursor + ): NotificationsConnection! } ################################################################################ diff --git a/server/src/core/server/index.ts b/server/src/core/server/index.ts index 74567c21d7..777dbaf4af 100644 --- a/server/src/core/server/index.ts +++ b/server/src/core/server/index.ts @@ -49,6 +49,7 @@ import { retrieveAllTenants, retrieveTenant, Tenant } from "./models/tenant"; import { WordListCategory } from "./services/comments/pipeline/phases/wordList/message"; import { WordListService } from "./services/comments/pipeline/phases/wordList/service"; import { ErrorReporter, SentryErrorReporter } from "./services/errors"; +import { InternalNotificationContext } from "./services/notifications/internal/context"; import { isInstalled } from "./services/tenant"; export interface ServerOptions { @@ -263,6 +264,11 @@ class Server { tenantCache: this.tenantCache, i18n: this.i18n, signingConfig: this.signingConfig, + notifications: new InternalNotificationContext( + this.mongo, + this.i18n, + logger + ), }); // Create the pubsub client. diff --git a/server/src/core/server/locales/en-US/common.ftl b/server/src/core/server/locales/en-US/common.ftl index 1d5891bd36..ed7394cb85 100644 --- a/server/src/core/server/locales/en-US/common.ftl +++ b/server/src/core/server/locales/en-US/common.ftl @@ -18,3 +18,10 @@ comment-counts-ratings-and-reviews = } staff-label = Staff + +notifications-commentWasFeatured-title = Comment was featured +notifications-commentWasFeatured-body = The comment { $commentID } was featured. +notifications-commentWasApproved-title = Comment was approved +notifications-commentWasApproved-body = The comment { $commentID } was approved. +notifications-commentWasRejected-title = Comment was rejected +notifications-commentWasRejected-body = The comment { $commentID } was rejected. diff --git a/server/src/core/server/models/dsaReport/report.ts b/server/src/core/server/models/dsaReport/report.ts index a18f4baadb..0f5ec0f82e 100644 --- a/server/src/core/server/models/dsaReport/report.ts +++ b/server/src/core/server/models/dsaReport/report.ts @@ -6,8 +6,9 @@ import { CommentNotFoundError, DuplicateDSAReportError, } from "coral-server/errors"; +import { FindDSAReportInput } from "coral-server/graph/loaders/DSAReports"; import { FilterQuery } from "coral-server/models/helpers"; -import { TenantResource } from "coral-server/models/tenant"; +import { Tenant, TenantResource } from "coral-server/models/tenant"; import { GQLDSAReportStatus } from "coral-server/graph/schema/__generated__/types"; @@ -145,3 +146,24 @@ export async function createDSAReport( dsaReport: report, }; } + +export async function find( + mongo: MongoContext, + tenant: Tenant, + input: FindDSAReportInput +) { + return findDSAReport(mongo, tenant.id, input.id); +} + +export async function findDSAReport( + mongo: MongoContext, + tenantID: string, + id: string +): Promise { + const result = await mongo.dsaReports().findOne({ + tenantID, + id, + }); + + return result ?? null; +} diff --git a/server/src/core/server/models/notifications/notification.ts b/server/src/core/server/models/notifications/notification.ts new file mode 100644 index 0000000000..f3e40d4f0f --- /dev/null +++ b/server/src/core/server/models/notifications/notification.ts @@ -0,0 +1,113 @@ +import { MongoContext } from "coral-server/data/context"; + +import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types"; + +import { ConnectionInput, Query, resolveConnection } from "../helpers"; +import { TenantResource } from "../tenant"; +import { User } from "../user"; + +export interface Notification extends TenantResource { + readonly id: string; + + readonly tenantID: string; + + createdAt: Date; + + ownerID: string; + + reportID?: string; + + commentID?: string; + commentStatus?: GQLCOMMENT_STATUS; + + title?: string; + body?: string; +} + +type BaseConnectionInput = ConnectionInput; + +export interface NotificationsConnectionInput extends BaseConnectionInput { + ownerID: string; +} + +export const retrieveNotificationsConnection = async ( + mongo: MongoContext, + tenantID: string, + input: NotificationsConnectionInput +) => { + const query = new Query(mongo.notifications()).where({ + tenantID, + ownerID: input.ownerID, + }); + + query.orderBy({ createdAt: -1 }); + + if (input.after) { + query.where({ createdAt: { $lt: input.after as Date } }); + } + + return resolveConnection(query, input, (n) => n.createdAt); +}; + +export const createNotification = async ( + mongo: MongoContext, + notification: Notification +) => { + const op = await mongo.notifications().insertOne(notification); + + return op.result.ok ? notification : null; +}; + +interface LastSeenNotificationChange { + $set: { + lastSeenNotificationDate?: Date | null; + }; +} + +export const markLastSeenNotification = async ( + tenantID: string, + mongo: MongoContext, + user: Readonly, + notificationDates: Date[] +) => { + if (!notificationDates || notificationDates.length === 0) { + return; + } + + let max = new Date(0); + for (const date of notificationDates) { + if (max.getTime() < date.getTime()) { + max = date; + } + } + + const change: LastSeenNotificationChange = { + $set: { lastSeenNotificationDate: user.lastSeenNotificationDate }, + }; + + const thereAreNewNotifications = + user.lastSeenNotificationDate && + user.lastSeenNotificationDate.getTime() < max.getTime(); + const userHasNeverSeenNotifications = + user.lastSeenNotificationDate === null || + user.lastSeenNotificationDate === undefined; + + if (thereAreNewNotifications || userHasNeverSeenNotifications) { + change.$set.lastSeenNotificationDate = max; + } + + await mongo.users().findOneAndUpdate({ tenantID, id: user.id }, change); +}; + +export const hasNewNotifications = async ( + tenantID: string, + mongo: MongoContext, + ownerID: string, + lastSeen: Date +) => { + const exists = await mongo + .notifications() + .findOne({ tenantID, ownerID, createdAt: { $gt: lastSeen } }); + + return exists !== null; +}; diff --git a/server/src/core/server/models/user/user.ts b/server/src/core/server/models/user/user.ts index aa1b854db6..4488781bee 100644 --- a/server/src/core/server/models/user/user.ts +++ b/server/src/core/server/models/user/user.ts @@ -616,6 +616,12 @@ export interface User extends TenantResource { * bio is a user deifned biography */ bio?: string; + + /** + * lastSeenNotificationDate is the date of the last notification the user loaded (viewed) + * in their notification tab. + */ + lastSeenNotificationDate?: Date | null; } function hashPassword(password: string): Promise { diff --git a/server/src/core/server/queue/index.ts b/server/src/core/server/queue/index.ts index 00b27bb94f..0c6364a550 100644 --- a/server/src/core/server/queue/index.ts +++ b/server/src/core/server/queue/index.ts @@ -8,6 +8,7 @@ import { } from "coral-server/queue/tasks/loadCache"; import { I18n } from "coral-server/services/i18n"; import { JWTSigningConfig } from "coral-server/services/jwt"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis, createRedisClient, @@ -58,6 +59,7 @@ export interface QueueOptions { i18n: I18n; signingConfig: JWTSigningConfig; redis: AugmentedRedis; + notifications: InternalNotificationContext; } export interface TaskQueue { diff --git a/server/src/core/server/queue/tasks/notifier/index.ts b/server/src/core/server/queue/tasks/notifier/index.ts index f9cdff2eb5..ba2d15dee0 100644 --- a/server/src/core/server/queue/tasks/notifier/index.ts +++ b/server/src/core/server/queue/tasks/notifier/index.ts @@ -9,7 +9,7 @@ import { JWTSigningConfig } from "coral-server/services/jwt"; import { categories, NotificationCategory, -} from "coral-server/services/notifications/categories"; +} from "coral-server/services/notifications/email/categories"; import { TenantCache } from "coral-server/services/tenant/cache"; import { createJobProcessor, JOB_NAME, NotifierData } from "./processor"; diff --git a/server/src/core/server/queue/tasks/notifier/messages.ts b/server/src/core/server/queue/tasks/notifier/messages.ts index 9ab4de8151..76c1cdcfe1 100644 --- a/server/src/core/server/queue/tasks/notifier/messages.ts +++ b/server/src/core/server/queue/tasks/notifier/messages.ts @@ -1,8 +1,8 @@ import { CoralEventPayload } from "coral-server/events/event"; import logger from "coral-server/logger"; -import { NotificationCategory } from "coral-server/services/notifications/categories"; -import NotificationContext from "coral-server/services/notifications/context"; -import { Notification } from "coral-server/services/notifications/notification"; +import { NotificationCategory } from "coral-server/services/notifications/email/categories"; +import NotificationContext from "coral-server/services/notifications/email/context"; +import { Notification } from "coral-server/services/notifications/email/notification"; import { GQLDIGEST_FREQUENCY } from "coral-server/graph/schema/__generated__/types"; diff --git a/server/src/core/server/queue/tasks/notifier/processor.ts b/server/src/core/server/queue/tasks/notifier/processor.ts index 07df2d1824..57b1afd647 100644 --- a/server/src/core/server/queue/tasks/notifier/processor.ts +++ b/server/src/core/server/queue/tasks/notifier/processor.ts @@ -6,9 +6,9 @@ import logger from "coral-server/logger"; import { JobProcessor } from "coral-server/queue/Task"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { JWTSigningConfig } from "coral-server/services/jwt"; -import { NotificationCategory } from "coral-server/services/notifications/categories"; -import NotificationContext from "coral-server/services/notifications/context"; -import { Notification } from "coral-server/services/notifications/notification"; +import { NotificationCategory } from "coral-server/services/notifications/email/categories"; +import NotificationContext from "coral-server/services/notifications/email/context"; +import { Notification } from "coral-server/services/notifications/email/notification"; import { TenantCache } from "coral-server/services/tenant/cache"; import { diff --git a/server/src/core/server/queue/tasks/rejector.ts b/server/src/core/server/queue/tasks/rejector.ts index fc1840bb03..7f54d73ea1 100644 --- a/server/src/core/server/queue/tasks/rejector.ts +++ b/server/src/core/server/queue/tasks/rejector.ts @@ -14,6 +14,7 @@ import { retrieveAllCommentsUserConnection, retrieveCommentsBySitesUserConnection, } from "coral-server/services/comments"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { TenantCache } from "coral-server/services/tenant/cache"; import { rejectComment } from "coral-server/stacks"; @@ -30,6 +31,7 @@ export interface RejectorProcessorOptions { redis: AugmentedRedis; tenantCache: TenantCache; config: Config; + notifications: InternalNotificationContext; } export interface RejectorData { @@ -149,6 +151,7 @@ const rejectLiveComments = async ( redis: AugmentedRedis, cache: DataCache, config: Config, + notifications: InternalNotificationContext, tenant: Readonly, authorID: string, moderatorID: string, @@ -169,6 +172,7 @@ const rejectLiveComments = async ( cache, config, null, + notifications, tenant, comment.id, revision.id, @@ -197,6 +201,7 @@ const createJobProcessor = redis, tenantCache, config, + notifications, }: RejectorProcessorOptions): JobProcessor => async (job) => { // Pull out the job data. @@ -238,6 +243,7 @@ const createJobProcessor = redis, cache, config, + notifications, tenant, authorID, moderatorID, diff --git a/server/src/core/server/services/notifications/categories/categories.ts b/server/src/core/server/services/notifications/email/categories/categories.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/categories.ts rename to server/src/core/server/services/notifications/email/categories/categories.ts diff --git a/server/src/core/server/services/notifications/categories/category.ts b/server/src/core/server/services/notifications/email/categories/category.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/category.ts rename to server/src/core/server/services/notifications/email/categories/category.ts diff --git a/server/src/core/server/services/notifications/categories/featured.ts b/server/src/core/server/services/notifications/email/categories/featured.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/featured.ts rename to server/src/core/server/services/notifications/email/categories/featured.ts diff --git a/server/src/core/server/services/notifications/categories/index.ts b/server/src/core/server/services/notifications/email/categories/index.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/index.ts rename to server/src/core/server/services/notifications/email/categories/index.ts diff --git a/server/src/core/server/services/notifications/categories/moderation.ts b/server/src/core/server/services/notifications/email/categories/moderation.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/moderation.ts rename to server/src/core/server/services/notifications/email/categories/moderation.ts diff --git a/server/src/core/server/services/notifications/categories/reply.ts b/server/src/core/server/services/notifications/email/categories/reply.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/reply.ts rename to server/src/core/server/services/notifications/email/categories/reply.ts diff --git a/server/src/core/server/services/notifications/categories/staffReply.ts b/server/src/core/server/services/notifications/email/categories/staffReply.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/staffReply.ts rename to server/src/core/server/services/notifications/email/categories/staffReply.ts diff --git a/server/src/core/server/services/notifications/categories/unsubscribe.ts b/server/src/core/server/services/notifications/email/categories/unsubscribe.ts similarity index 100% rename from server/src/core/server/services/notifications/categories/unsubscribe.ts rename to server/src/core/server/services/notifications/email/categories/unsubscribe.ts diff --git a/server/src/core/server/services/notifications/context.ts b/server/src/core/server/services/notifications/email/context.ts similarity index 100% rename from server/src/core/server/services/notifications/context.ts rename to server/src/core/server/services/notifications/email/context.ts diff --git a/server/src/core/server/services/notifications/notification.ts b/server/src/core/server/services/notifications/email/notification.ts similarity index 100% rename from server/src/core/server/services/notifications/notification.ts rename to server/src/core/server/services/notifications/email/notification.ts diff --git a/server/src/core/server/services/notifications/internal/context.ts b/server/src/core/server/services/notifications/internal/context.ts new file mode 100644 index 0000000000..94dd66ec04 --- /dev/null +++ b/server/src/core/server/services/notifications/internal/context.ts @@ -0,0 +1,175 @@ +import { v4 as uuid } from "uuid"; + +import { LanguageCode } from "coral-common/common/lib/helpers"; +import { MongoContext } from "coral-server/data/context"; +import { Logger } from "coral-server/logger"; +import { Comment } from "coral-server/models/comment"; +import { DSAReport } from "coral-server/models/dsaReport/report"; +import { + createNotification, + Notification, +} from "coral-server/models/notifications/notification"; +import { retrieveUser } from "coral-server/models/user"; +import { I18n, translate } from "coral-server/services/i18n"; + +export enum NotificationType { + COMMENT_FEATURED = "COMMENT_FEATURED", + COMMENT_APPROVED = "COMMENT_APPROVED", + COMMENT_REJECTED = "COMMENT_REJECTED", +} + +export interface CreateNotificationInput { + targetUserID: string; + type: NotificationType; + + comment?: Readonly; + report?: Readonly; +} + +interface CreationResult { + notification: Notification | null; + attempted: boolean; +} + +export class InternalNotificationContext { + private mongo: MongoContext; + private log: Logger; + private i18n: I18n; + + constructor(mongo: MongoContext, i18n: I18n, log: Logger) { + this.mongo = mongo; + this.i18n = i18n; + this.log = log; + } + + public async create( + tenantID: string, + lang: LanguageCode, + input: CreateNotificationInput + ) { + const { type, targetUserID, comment } = input; + + const existingUser = retrieveUser(this.mongo, tenantID, targetUserID); + if (!existingUser) { + this.log.warn( + { userID: targetUserID }, + "attempted to create notification for user that does not exist, ignoring" + ); + return; + } + + const now = new Date(); + + const result: CreationResult = { + notification: null, + attempted: false, + }; + + if (type === NotificationType.COMMENT_FEATURED && comment) { + result.notification = await createNotification(this.mongo, { + id: uuid(), + tenantID, + createdAt: now, + ownerID: targetUserID, + title: this.translatePhrase( + lang, + "notifications-commentWasFeatured-title", + "Comment was featured" + ), + body: this.translatePhrase( + lang, + "notifications-commentWasFeatured-body", + `The comment ${comment.id} was featured.`, + { + commentID: comment.id, + } + ), + commentID: comment.id, + commentStatus: comment.status, + }); + result.attempted = true; + } else if (type === NotificationType.COMMENT_APPROVED && comment) { + result.notification = await createNotification(this.mongo, { + id: uuid(), + tenantID, + createdAt: now, + ownerID: targetUserID, + title: this.translatePhrase( + lang, + "notifications-commentWasApproved-title", + "Comment was approved" + ), + body: this.translatePhrase( + lang, + "notifications-commentWasApproved-body", + `The comment ${comment.id} was approved.`, + { + commentID: comment.id, + } + ), + commentID: comment.id, + commentStatus: comment.status, + }); + result.attempted = true; + } else if (type === NotificationType.COMMENT_REJECTED && comment) { + result.notification = await createNotification(this.mongo, { + id: uuid(), + tenantID, + createdAt: now, + ownerID: targetUserID, + title: this.translatePhrase( + lang, + "notifications-commentWasRejected-title", + "Comment was rejected" + ), + body: this.translatePhrase( + lang, + "notifications-commentWasRejected-body", + `The comment ${comment.id} was rejected.`, + { + commentID: comment.id, + } + ), + commentID: comment.id, + commentStatus: comment.status, + }); + result.attempted = true; + } + + if (!result.notification && result.attempted) { + this.logCreateNotificationError(tenantID, input); + } + } + + private translatePhrase( + lang: LanguageCode, + key: string, + text: string, + args?: object | undefined + ) { + const bundle = this.i18n.getBundle(lang); + + const result = translate(bundle, text, key, args); + + // eslint-disable-next-line no-console + console.log(result, args); + + return result; + } + + private logCreateNotificationError( + tenantID: string, + input: CreateNotificationInput + ) { + this.log.error( + { + tenantID, + userID: input.targetUserID, + commentID: input.comment ? input.comment.id : null, + reportID: input.report ? input.report.id : null, + type: input.type, + }, + "failed to create internal notification" + ); + } +} diff --git a/server/src/core/server/stacks/approveComment.ts b/server/src/core/server/stacks/approveComment.ts index 58a50394f6..06762a569c 100644 --- a/server/src/core/server/stacks/approveComment.ts +++ b/server/src/core/server/stacks/approveComment.ts @@ -5,6 +5,10 @@ import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import { getLatestRevision } from "coral-server/models/comment"; import { Tenant } from "coral-server/models/tenant"; import { moderate } from "coral-server/services/comments/moderation"; +import { + InternalNotificationContext, + NotificationType, +} from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { submitCommentAsNotSpam } from "coral-server/services/spam"; import { Request } from "coral-server/types/express"; @@ -19,6 +23,7 @@ const approveComment = async ( cache: DataCache, config: Config, broker: CoralEventPublisherBroker, + notifications: InternalNotificationContext, tenant: Tenant, commentID: string, commentRevisionID: string, @@ -78,6 +83,12 @@ const approveComment = async ( } } + await notifications.create(tenant.id, tenant.locale, { + targetUserID: result.after.authorID!, + comment: result.after, + type: NotificationType.COMMENT_APPROVED, + }); + // Return the resulting comment. return result.after; }; diff --git a/server/src/core/server/stacks/createComment.ts b/server/src/core/server/stacks/createComment.ts index 7fc240dd27..feb176ae88 100644 --- a/server/src/core/server/stacks/createComment.ts +++ b/server/src/core/server/stacks/createComment.ts @@ -57,6 +57,7 @@ import { processForModeration, } from "coral-server/services/comments/pipeline"; import { WordListService } from "coral-server/services/comments/pipeline/phases/wordList/service"; +import { InternalNotificationContext } from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { updateUserLastCommentID } from "coral-server/services/users"; import { Request } from "coral-server/types/express"; @@ -96,6 +97,7 @@ const markCommentAsAnswered = async ( cache: DataCache, config: Config, broker: CoralEventPublisherBroker, + notifications: InternalNotificationContext, tenant: Tenant, comment: Readonly, story: Story, @@ -145,6 +147,7 @@ const markCommentAsAnswered = async ( cache, config, broker, + notifications, tenant, comment.parentID, comment.parentRevisionID, @@ -202,6 +205,7 @@ export default async function create( cache: DataCache, config: Config, broker: CoralEventPublisherBroker, + notifications: InternalNotificationContext, tenant: Tenant, author: User, input: CreateComment, @@ -387,6 +391,7 @@ export default async function create( cache, config, broker, + notifications, tenant, comment, story, diff --git a/server/src/core/server/stacks/rejectComment.ts b/server/src/core/server/stacks/rejectComment.ts index 3b98ba01e8..e7db4a378d 100644 --- a/server/src/core/server/stacks/rejectComment.ts +++ b/server/src/core/server/stacks/rejectComment.ts @@ -11,6 +11,10 @@ import { import { Tenant } from "coral-server/models/tenant"; import { removeTag } from "coral-server/services/comments"; import { moderate } from "coral-server/services/comments/moderation"; +import { + InternalNotificationContext, + NotificationType, +} from "coral-server/services/notifications/internal/context"; import { AugmentedRedis } from "coral-server/services/redis"; import { submitCommentAsSpam } from "coral-server/services/spam"; import { Request } from "coral-server/types/express"; @@ -69,6 +73,7 @@ const rejectComment = async ( cache: DataCache, config: Config, broker: CoralEventPublisherBroker | null, + notifications: InternalNotificationContext, tenant: Tenant, commentID: string, commentRevisionID: string, @@ -149,6 +154,12 @@ const rejectComment = async ( }); } + await notifications.create(tenant.id, tenant.locale, { + targetUserID: result.after.authorID!, + comment: result.after, + type: NotificationType.COMMENT_REJECTED, + }); + // Return the resulting comment. return rollingResult; };