Skip to content
1 change: 1 addition & 0 deletions functions/src/aggregations/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
exports.user = require('./user.aggregations')
exports.userNotifications = require('./userNotifications.aggregations')
42 changes: 42 additions & 0 deletions functions/src/aggregations/userNotifications.aggregations.ts
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we be using the notification_settings.enabled field here instead/also to filter on type of notif?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @evakill , I hadn't thought about how it relates to filtering the specific types of notifications users are subscribed to or not. I'm assuming it might be a future PR anyway that handles filtering, so for now I'd say probably better to keep this simple and let all notifications pass through, and then filter out at a later stage (likely before hitting the user profile notifications, so that they don't receive on web and before we reach this aggregation step)

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)
})
1 change: 1 addition & 0 deletions shared/models/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
8 changes: 7 additions & 1 deletion src/modules/admin/admin.routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
Expand All @@ -89,5 +96,4 @@ const routes = () => (
</Switch>
</Suspense>
)

export default withRouter(routes)
99 changes: 99 additions & 0 deletions src/modules/admin/pages/adminNotifications.tsx
Original file line number Diff line number Diff line change
@@ -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<string, IPendingEmails>

const EMAILS_PENDING_COLUMNS: ITableProps<IPendingEmails>['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<IPendingEmails[]>([])

// Load list of pending approvals on mount only, dependencies empty to avoid reloading
useEffect(() => {
getPendingEmails()
}, [])

const getPendingEmails = useCallback(async () => {
db.collection<IPendingEmailsDBDoc>('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<ICellRenderProps> = (
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 (
<NotificationListContainer>
{notifications.map((n) => (
<NotificationItem key={n._id} type={n.type}>
{getFormattedNotificationMessage(n)}
</NotificationItem>
))}
</NotificationListContainer>
)
}
return <Text>{`${value}`}</Text>
}

return (
<>
<h2>Admin Notifictions</h2>
<h4>Pending Emails</h4>
<Table
data={emailsPending}
columns={EMAILS_PENDING_COLUMNS}
rowComponent={RenderContent}
/>
</>
)
})
export default AdminNotifictions
4 changes: 2 additions & 2 deletions src/pages/common/Header/getFormattedNotifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -86,6 +86,6 @@ export function getFormattedNotifications(
.getUnreadNotifications()
.map((notification) => ({
type: notification.type,
children: getFormattedMessage(notification),
children: getFormattedNotificationMessage(notification),
}))
}
5 changes: 4 additions & 1 deletion src/stores/databaseV2/clients/dexie.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDBEndpoint, 'user_notifications'>

// 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'
Expand Down