diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index 8bfbb593d8..8bf5f9a740 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -21,17 +21,6 @@ import { PluginEnvironment } from '../types'; export default async function createPlugin( env: PluginEnvironment, ): Promise { - // TODO: Remove this test code - setInterval(() => { - env.notificationService.send({ - receivers: { type: 'broadcast' }, - title: 'Test', - description: 'Test', - link: '/catalog', - topic: 'topic', - }); - }, 60000); - return await createRouter({ logger: env.logger, identity: env.identity, diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index f1915d233b..a0a262b95b 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -72,6 +72,29 @@ export class DatabaseNotificationsStore implements NotificationsStore { query.where('saved', true); } + if (options.limit) { + query.limit(options.limit); + } + + if (options.offset) { + query.offset(options.offset); + } + + if (options.search) { + if (this.db.client.config.client === 'pg') { + query.whereRaw( + `(to_tsvector('english', notifications.title || ' ' || notifications.description) @@ websearch_to_tsquery('english', quote_literal(?)) + or to_tsvector('english', notifications.title || ' ' || notifications.description) @@ to_tsquery('english',quote_literal(?)))`, + [`${options.search}`, `${options.search.replaceAll(/\s/g, '+')}:*`], + ); + } else { + query.whereRaw( + `LOWER(notifications.title || ' ' || notifications.description) LIKE LOWER(?)`, + [`%${options.search}%`], + ); + } + } + if ('ids' in options && options.ids) { query.whereIn('id', options.ids); } diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index 457b08489a..ec13bc42b0 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -24,6 +24,9 @@ import { export type NotificationGetOptions = { user_ref: string; type?: NotificationType; + offset?: number; + limit?: number; + search?: string; }; /** @public */ diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 5dd7350c33..e28026abf4 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -41,7 +41,10 @@ import { NotificationProcessor } from '../types'; import { AuthenticationError } from '@backstage/errors'; import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; import { SignalService } from '@backstage/plugin-signals-node'; -import { Notification } from '@backstage/plugin-notifications-common'; +import { + Notification, + NotificationType, +} from '@backstage/plugin-notifications-common'; /** @public */ export interface RouterOptions { @@ -94,13 +97,9 @@ export async function createRouter( ): Promise => { const { token } = await tokenManager.getToken(); - // Broadcast + // TODO: Support for broadcast if (entityRef === null) { - const users = await catalogClient.getEntities({ - filter: { kind: 'User' }, - fields: ['kind', 'metadata.name', 'metadata.namespace'], - }); - return users.items.map(stringifyEntityRef); + return []; } const refs = Array.isArray(entityRef) ? entityRef : [entityRef]; @@ -119,12 +118,23 @@ export async function createRouter( if (isUserEntity(entity)) { return [stringifyEntityRef(entity)]; } else if (isGroupEntity(entity) && entity.relations) { - return entity.relations + const users = entity.relations .filter( relation => relation.type === RELATION_HAS_MEMBER && relation.targetRef, ) .map(r => r.targetRef); + const childGroups = await catalogClient.getEntitiesByRefs( + { + entityRefs: entity.spec.children, + fields: ['kind', 'metadata.name', 'metadata.namespace'], + }, + { token }, + ); + const childGroupUsers = await Promise.all( + childGroups.items.map(mapEntity), + ); + return [...users, ...childGroupUsers.flat(2)]; } else if (!isGroupEntity(entity) && entity.spec?.owner) { const owner = await catalogClient.getEntityByRef( entity.spec.owner as string, @@ -162,6 +172,7 @@ export async function createRouter( } }; + // TODO: Move to use OpenAPI router instead const router = Router(); router.use(express.json()); @@ -176,7 +187,16 @@ export async function createRouter( user_ref: user, }; if (req.query.type) { - opts.type = req.query.type as any; + opts.type = req.query.type.toString() as NotificationType; + } + if (req.query.offset) { + opts.offset = Number.parseInt(req.query.offset.toString(), 10); + } + if (req.query.limit) { + opts.limit = Number.parseInt(req.query.limit.toString(), 10); + } + if (req.query.search) { + opts.search = req.query.search.toString(); } const notifications = await store.getNotifications(opts); @@ -201,7 +221,7 @@ export async function createRouter( if (signalService) { await signalService.publish({ recipients: [user], - message: { action: 'refresh' }, + message: { action: 'done', notification_ids: ids }, channel: 'notifications', }); } @@ -219,7 +239,7 @@ export async function createRouter( if (signalService) { await signalService.publish({ recipients: [user], - message: { action: 'refresh' }, + message: { action: 'undone', notification_ids: ids }, channel: 'notifications', }); } @@ -238,7 +258,7 @@ export async function createRouter( if (signalService) { await signalService.publish({ recipients: [user], - message: { action: 'refresh' }, + message: { action: 'mark_read', notification_ids: ids }, channel: 'notifications', }); } @@ -256,7 +276,7 @@ export async function createRouter( if (signalService) { await signalService.publish({ recipients: [user], - message: { action: 'refresh' }, + message: { action: 'mark_unread', notification_ids: ids }, channel: 'notifications', }); } @@ -354,7 +374,10 @@ export async function createRouter( if (signalService) { await signalService.publish({ recipients: entityRef === null ? null : users, - message: { action: 'refresh', title, description, link }, + message: { + action: 'new_notification', + notification: { title, description, link }, + }, channel: 'notifications', }); } diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 58e0df5603..6f655d9f18 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -23,14 +23,10 @@ export class DefaultNotificationService implements NotificationService { } // @public (undocumented) -export type NotificationReceivers = - | { - type: 'entity'; - entityRef: string | string[]; - } - | { - type: 'broadcast'; - }; +export type NotificationReceivers = { + type: 'entity'; + entityRef: string | string[]; +}; // @public (undocumented) export type NotificationSendOptions = { diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts index bffac9e725..d92e92f61c 100644 --- a/plugins/notifications-node/src/service/DefaultNotificationService.ts +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -28,9 +28,13 @@ export type NotificationServiceOptions = { }; /** @public */ -export type NotificationReceivers = - | { type: 'entity'; entityRef: string | string[] } - | { type: 'broadcast' }; +export type NotificationReceivers = { + type: 'entity'; + entityRef: string | string[]; +}; + +// TODO: Support for broadcast messages +// | { type: 'broadcast' }; /** @public */ export type NotificationSendOptions = { diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index efe9ba000e..93c6eaedf7 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -20,6 +20,9 @@ import { RouteRef } from '@backstage/core-plugin-api'; // @public (undocumented) export type GetNotificationsOptions = { type?: NotificationType; + offset?: number; + limit?: number; + search?: string; }; // @public (undocumented) diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index f5a2f9199a..ae641439d9 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -29,6 +29,7 @@ "@backstage/plugin-notifications-common": "workspace:^", "@backstage/plugin-signals-react": "workspace:^", "@backstage/theme": "workspace:^", + "@backstage/types": "workspace:^", "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts index 462787d87f..95d8cd6297 100644 --- a/plugins/notifications/src/api/NotificationsApi.ts +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -29,6 +29,9 @@ export const notificationsApiRef = createApiRef({ /** @public */ export type GetNotificationsOptions = { type?: NotificationType; + offset?: number; + limit?: number; + search?: string; }; /** @public */ diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index e3225843ae..a4398e4cb8 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -42,6 +42,15 @@ export class NotificationsClient implements NotificationsApi { if (options?.type) { queryString.append('type', options.type); } + if (options?.limit) { + queryString.append('limit', options.limit.toString(10)); + } + if (options?.offset) { + queryString.append('offset', options.offset.toString(10)); + } + if (options?.search) { + queryString.append('search', options.search); + } const urlSegment = `notifications?${queryString}`; diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index 9ed74b5c3d..85e0937ca3 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -54,7 +54,7 @@ export const NotificationsPage = () => { const { lastSignal } = useSignal('notifications'); useEffect(() => { - if (lastSignal && lastSignal.action === 'refresh') { + if (lastSignal && lastSignal.action) { setRefresh(true); } }, [lastSignal]); @@ -68,7 +68,6 @@ export const NotificationsPage = () => { return ; } - // TODO: Add signals listener and refresh data on message return ( diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx index f3034f6b26..e13956d7d6 100644 --- a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx +++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx @@ -22,6 +22,7 @@ import { rootRouteRef } from '../../routes'; import { useSignal } from '@backstage/plugin-signals-react'; import { useWebNotifications } from '../../hooks/useWebNotifications'; import { useTitleCounter } from '../../hooks/useTitleCounter'; +import { JsonObject } from '@backstage/types'; /** @public */ export const NotificationsSidebarItem = () => { @@ -31,6 +32,8 @@ export const NotificationsSidebarItem = () => { const config = useApi(configApiRef); const [unreadCount, setUnreadCount] = React.useState(0); const notificationsRoute = useRouteRef(rootRouteRef); + // TODO: Add signal type support to `useSignal` to make it a bit easier to use + // TODO: Do we want to add long polling in case signals are not available const { lastSignal } = useSignal('notifications'); const { sendWebNotification } = useWebNotifications(); const [refresh, setRefresh] = React.useState(false); @@ -54,25 +57,36 @@ export const NotificationsSidebarItem = () => { }, [refresh, retry]); useEffect(() => { - if (lastSignal && lastSignal.action === 'refresh') { - if ( - webNotificationsEnabled && - 'title' in lastSignal && - 'description' in lastSignal && - 'link' in lastSignal - ) { - const notification = sendWebNotification({ - title: lastSignal.title as string, - description: lastSignal.description as string, - }); - if (notification) { - notification.onclick = event => { - event.preventDefault(); - notification.close(); - window.open(lastSignal.link as string, '_blank'); - }; - } + const handleWebNotification = (signal: JsonObject) => { + if (!webNotificationsEnabled || !('notification' in signal)) { + return; } + + const notificationData = signal.notification as JsonObject; + + if ( + !notificationData || + !('title' in notificationData) || + !('description' in notificationData) || + !('title' in notificationData) + ) { + return; + } + const notification = sendWebNotification({ + title: notificationData.title as string, + description: notificationData.description as string, + }); + if (notification) { + notification.onclick = event => { + event.preventDefault(); + notification.close(); + window.open(notificationData.link as string, '_blank'); + }; + } + }; + + if (lastSignal && lastSignal.action) { + handleWebNotification(lastSignal); setRefresh(true); } }, [lastSignal, sendWebNotification, webNotificationsEnabled]); diff --git a/yarn.lock b/yarn.lock index b9f2803f18..8a1035010a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7848,6 +7848,7 @@ __metadata: "@backstage/plugin-signals-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" + "@backstage/types": "workspace:^" "@material-ui/core": ^4.9.13 "@material-ui/icons": ^4.9.1 "@material-ui/lab": ^4.0.0-alpha.61