diff --git a/functions/src/aggregations/index.ts b/functions/src/aggregations/index.ts index 4189c32999..9d5c1f8874 100644 --- a/functions/src/aggregations/index.ts +++ b/functions/src/aggregations/index.ts @@ -1 +1,2 @@ exports.user = require('./user.aggregations') +exports.userNotifications = require('./userNotifications.aggregations') diff --git a/functions/src/aggregations/userNotifications.aggregations.ts b/functions/src/aggregations/userNotifications.aggregations.ts new file mode 100644 index 0000000000..dbc2c35ae1 --- /dev/null +++ b/functions/src/aggregations/userNotifications.aggregations.ts @@ -0,0 +1,42 @@ +import * as functions from 'firebase-functions' +import { DB_ENDPOINTS, IUserDB } from '../models' +import { handleDBAggregations, VALUE_MODIFIERS } from './common.aggregations' +import type { IAggregation } from './common.aggregations' + +interface INotificationAggregation extends IAggregation { + sourceFields: (keyof IUserDB)[] +} + +const aggregations: INotificationAggregation[] = [ + // When a user's list of notifications changes reflect to aggregation + { + sourceCollection: 'users', + sourceFields: ['notifications', 'notification_settings'], + changeType: 'updated', + targetCollection: 'user_notifications', + targetDocId: 'emails_pending', + process: ({ dbChange }) => { + const user: IUserDB = dbChange.after.data() as any + const { _id, _authID, notification_settings, notifications } = user + const emailFrequency = notification_settings?.emailFrequency || null + const pending = notifications.filter((n) => !n.notified && !n.read) + // remove user from list if they do not have emails enabled or no pending notifications + if (!emailFrequency || pending.length === 0) { + return { + [_id]: VALUE_MODIFIERS.delete(), + } + } + // return list of pending notifications alongside metadata + return { + [_id]: { _authID, emailFrequency, notifications: pending }, + } + }, + }, +] + +/** Watch changes to all user docs and apply aggregations */ +exports.default = functions.firestore + .document(`${DB_ENDPOINTS.users}/{id}`) + .onUpdate((change) => { + return handleDBAggregations(change, aggregations) + }) diff --git a/shared/models/db.ts b/shared/models/db.ts index f7db72dc38..4222eff703 100644 --- a/shared/models/db.ts +++ b/shared/models/db.ts @@ -11,6 +11,7 @@ export const generateDBEndpoints = (DB_PREFIX = '') => ({ howtos: `${DB_PREFIX}v3_howtos`, users: `${DB_PREFIX}v3_users`, + user_notifications: `${DB_PREFIX}user_notifications_rev20221209`, tags: `${DB_PREFIX}v3_tags`, categories: `${DB_PREFIX}v3_categories`, researchCategories: `${DB_PREFIX}research_categories_rev20221224`, diff --git a/src/modules/admin/admin.routes.tsx b/src/modules/admin/admin.routes.tsx index 7604e04d76..59c6d6581b 100644 --- a/src/modules/admin/admin.routes.tsx +++ b/src/modules/admin/admin.routes.tsx @@ -73,6 +73,13 @@ export const ADMIN_PAGES: IAdminPageMeta[] = [ moduleName, disabled: true, }, + { + component: lazy(() => import('./pages/adminNotifications')), + title: 'Notifications', + description: 'Manage Notifications', + path: '/notifications', + moduleName, + }, ] const routes = () => ( @@ -89,5 +96,4 @@ const routes = () => ( ) - export default withRouter(routes) diff --git a/src/modules/admin/pages/adminNotifications.tsx b/src/modules/admin/pages/adminNotifications.tsx new file mode 100644 index 0000000000..c0ce1c3ca3 --- /dev/null +++ b/src/modules/admin/pages/adminNotifications.tsx @@ -0,0 +1,99 @@ +import styled from '@emotion/styled' +import { observer } from 'mobx-react' +import { NotificationItem } from 'oa-components' +import { useCallback, useEffect, useState } from 'react' +import { Box, Text } from 'theme-ui' +import { useDB } from 'src/App' +import type { INotification, INotificationSettings } from 'src/models' +import { getFormattedNotificationMessage } from 'src/pages/common/Header/getFormattedNotifications' +import Table from '../components/Table/Table' +import type { ICellRenderProps, ITableProps } from '../components/Table/Table' + +interface IPendingEmails { + _authID: 'string' + emailFrequency?: INotificationSettings['emailFrequency'] + notifications: INotification[] +} +type IPendingEmailsDBDoc = Record + +const EMAILS_PENDING_COLUMNS: ITableProps['columns'] = [ + { + Header: 'Auth ID', + accessor: '_authID', + minWidth: 100, + }, + { + Header: 'Frequency', + accessor: 'emailFrequency', + minWidth: 100, + }, + { + Header: 'Notifications', + accessor: 'notifications', + minWidth: 140, + }, +] +const NotificationListContainer = styled(Box)` + overflow: auto; + height: 6rem; +` + +const AdminNotifictions = observer(() => { + const { db } = useDB() + const [emailsPending, setEmailsPending] = useState([]) + + // Load list of pending approvals on mount only, dependencies empty to avoid reloading + useEffect(() => { + getPendingEmails() + }, []) + + const getPendingEmails = useCallback(async () => { + db.collection('user_notifications') + .doc('emails_pending') + .stream() + .subscribe((emailsPending) => { + if (emailsPending) { + const values = Object.values(emailsPending) + if (values.length > 0) { + setEmailsPending(values) + } + } + }) + }, []) + + /** Function applied to render each table row cell */ + const RenderContent: React.FC = ( + props: ICellRenderProps, + ) => { + const { col } = props + const { field, value } = col + const tableField = field as keyof IPendingEmails + if (!value) return null + if (tableField === 'notifications') { + const notifications = value as INotification[] + return ( + + {notifications.map((n) => ( + + {getFormattedNotificationMessage(n)} + + ))} + + ) + } + return {`${value}`} + } + + return ( + <> +

Admin Notifictions

+

Pending Emails

+ + + ) +}) +export default AdminNotifictions diff --git a/src/pages/common/Header/getFormattedNotifications.tsx b/src/pages/common/Header/getFormattedNotifications.tsx index 2f0f903e40..f3df494b4f 100644 --- a/src/pages/common/Header/getFormattedNotifications.tsx +++ b/src/pages/common/Header/getFormattedNotifications.tsx @@ -4,7 +4,7 @@ import type { INotification } from 'src/models' import type { UserNotificationsStore } from 'src/stores/User/notifications.store' import { Box } from 'theme-ui' -function getFormattedMessage(notification: INotification) { +export function getFormattedNotificationMessage(notification: INotification) { switch (notification.type) { case 'new_comment': return ( @@ -86,6 +86,6 @@ export function getFormattedNotifications( .getUnreadNotifications() .map((notification) => ({ type: notification.type, - children: getFormattedMessage(notification), + children: getFormattedNotificationMessage(notification), })) } diff --git a/src/stores/databaseV2/clients/dexie.tsx b/src/stores/databaseV2/clients/dexie.tsx index 110fa1121e..90204ddac7 100644 --- a/src/stores/databaseV2/clients/dexie.tsx +++ b/src/stores/databaseV2/clients/dexie.tsx @@ -143,9 +143,12 @@ export class DexieClient implements AbstractDBClient { /************************************************************************ * Interfaces and constants ***********************************************************************/ +// Frontend code does not access all database endpoints, exclude here +type IFrontendEndpoints = Exclude + // When dexie is initialised it requires explicit knowledge of the database structures and any keys to // index on. The below interface and constant ensures this is done for the current db api version -type IDexieSchema = { [key in IDBEndpoint]: string } +type IDexieSchema = { [key in IFrontendEndpoints]: string } // by default _id will serve as primary key and additional index created on _modified for faster querying const DEFAULT_SCHEMA = '_id,_modified'