From f24a0c1f6a784bc164f78b3d7bb5246f7b90d6ce Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 15 Dec 2023 14:05:35 +0200 Subject: [PATCH 01/25] feat: initial notifications support Signed-off-by: Heikki Hellgren --- packages/app/package.json | 1 + packages/app/src/App.tsx | 6 +- packages/app/src/components/Root/Root.tsx | 7 +- packages/backend/package.json | 2 + packages/backend/src/index.ts | 11 ++ packages/backend/src/plugins/notifications.ts | 39 +++++ packages/backend/src/types.ts | 6 +- plugins/notifications-backend/.eslintrc.js | 1 + plugins/notifications-backend/README.md | 14 ++ plugins/notifications-backend/api-report.md | 25 ++++ plugins/notifications-backend/package.json | 46 ++++++ plugins/notifications-backend/src/index.ts | 16 +++ plugins/notifications-backend/src/run.ts | 32 +++++ .../src/service/router.test.ts | 66 +++++++++ .../src/service/router.ts | 67 +++++++++ .../src/service/standaloneServer.ts | 99 +++++++++++++ .../notifications-backend/src/setupTests.ts | 16 +++ plugins/notifications-common/.eslintrc.js | 1 + plugins/notifications-common/README.md | 5 + plugins/notifications-common/api-report.md | 24 ++++ plugins/notifications-common/package.json | 32 +++++ plugins/notifications-common/src/index.ts | 23 +++ .../notifications-common/src/setupTests.ts | 16 +++ plugins/notifications-common/src/types.ts | 34 +++++ plugins/notifications-node/.eslintrc.js | 1 + plugins/notifications-node/README.md | 5 + plugins/notifications-node/api-report.md | 70 +++++++++ .../migrations/20231215_init.js | 34 +++++ plugins/notifications-node/package.json | 38 +++++ .../database/DatabaseNotificationsStore.ts | 97 +++++++++++++ .../src/database/NotificationsStore.ts | 36 +++++ .../notifications-node/src/database/index.ts | 17 +++ plugins/notifications-node/src/index.ts | 24 ++++ .../src/service/NotificationService.ts | 136 ++++++++++++++++++ .../notifications-node/src/service/index.ts | 16 +++ plugins/notifications-node/src/setupTests.ts | 16 +++ plugins/notifications/.eslintrc.js | 1 + plugins/notifications/README.md | 13 ++ plugins/notifications/api-report.md | 88 ++++++++++++ plugins/notifications/dev/index.tsx | 27 ++++ plugins/notifications/package.json | 53 +++++++ .../notifications/src/api/NotificationsApi.ts | 34 +++++ .../src/api/NotificationsClient.ts | 57 ++++++++ plugins/notifications/src/api/index.ts | 17 +++ .../NotificationsPage/NotificationsPage.tsx | 82 +++++++++++ .../src/components/NotificationsPage/index.ts | 16 +++ .../NotificationsSideBarItem.tsx | 49 +++++++ .../NotificationsSideBarItem/index.ts | 16 +++ .../NotificationsTable/NotificationsTable.tsx | 100 +++++++++++++ .../components/NotificationsTable/index.ts | 16 +++ plugins/notifications/src/components/index.ts | 17 +++ plugins/notifications/src/hooks/index.ts | 16 +++ .../src/hooks/useNotificationsApi.ts | 31 ++++ plugins/notifications/src/index.ts | 19 +++ plugins/notifications/src/plugin.test.ts | 22 +++ plugins/notifications/src/plugin.ts | 52 +++++++ plugins/notifications/src/routes.ts | 20 +++ plugins/notifications/src/setupTests.ts | 16 +++ yarn.lock | 130 ++++++++++++++++- 59 files changed, 1964 insertions(+), 7 deletions(-) create mode 100644 packages/backend/src/plugins/notifications.ts create mode 100644 plugins/notifications-backend/.eslintrc.js create mode 100644 plugins/notifications-backend/README.md create mode 100644 plugins/notifications-backend/api-report.md create mode 100644 plugins/notifications-backend/package.json create mode 100644 plugins/notifications-backend/src/index.ts create mode 100644 plugins/notifications-backend/src/run.ts create mode 100644 plugins/notifications-backend/src/service/router.test.ts create mode 100644 plugins/notifications-backend/src/service/router.ts create mode 100644 plugins/notifications-backend/src/service/standaloneServer.ts create mode 100644 plugins/notifications-backend/src/setupTests.ts create mode 100644 plugins/notifications-common/.eslintrc.js create mode 100644 plugins/notifications-common/README.md create mode 100644 plugins/notifications-common/api-report.md create mode 100644 plugins/notifications-common/package.json create mode 100644 plugins/notifications-common/src/index.ts create mode 100644 plugins/notifications-common/src/setupTests.ts create mode 100644 plugins/notifications-common/src/types.ts create mode 100644 plugins/notifications-node/.eslintrc.js create mode 100644 plugins/notifications-node/README.md create mode 100644 plugins/notifications-node/api-report.md create mode 100644 plugins/notifications-node/migrations/20231215_init.js create mode 100644 plugins/notifications-node/package.json create mode 100644 plugins/notifications-node/src/database/DatabaseNotificationsStore.ts create mode 100644 plugins/notifications-node/src/database/NotificationsStore.ts create mode 100644 plugins/notifications-node/src/database/index.ts create mode 100644 plugins/notifications-node/src/index.ts create mode 100644 plugins/notifications-node/src/service/NotificationService.ts create mode 100644 plugins/notifications-node/src/service/index.ts create mode 100644 plugins/notifications-node/src/setupTests.ts create mode 100644 plugins/notifications/.eslintrc.js create mode 100644 plugins/notifications/README.md create mode 100644 plugins/notifications/api-report.md create mode 100644 plugins/notifications/dev/index.tsx create mode 100644 plugins/notifications/package.json create mode 100644 plugins/notifications/src/api/NotificationsApi.ts create mode 100644 plugins/notifications/src/api/NotificationsClient.ts create mode 100644 plugins/notifications/src/api/index.ts create mode 100644 plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx create mode 100644 plugins/notifications/src/components/NotificationsPage/index.ts create mode 100644 plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx create mode 100644 plugins/notifications/src/components/NotificationsSideBarItem/index.ts create mode 100644 plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx create mode 100644 plugins/notifications/src/components/NotificationsTable/index.ts create mode 100644 plugins/notifications/src/components/index.ts create mode 100644 plugins/notifications/src/hooks/index.ts create mode 100644 plugins/notifications/src/hooks/useNotificationsApi.ts create mode 100644 plugins/notifications/src/index.ts create mode 100644 plugins/notifications/src/plugin.test.ts create mode 100644 plugins/notifications/src/plugin.ts create mode 100644 plugins/notifications/src/routes.ts create mode 100644 plugins/notifications/src/setupTests.ts diff --git a/packages/app/package.json b/packages/app/package.json index 31be0e1610..b960712550 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -59,6 +59,7 @@ "@backstage/plugin-newrelic": "workspace:^", "@backstage/plugin-newrelic-dashboard": "workspace:^", "@backstage/plugin-nomad": "workspace:^", + "@backstage/plugin-notifications": "^0.0.0", "@backstage/plugin-octopus-deploy": "workspace:^", "@backstage/plugin-org": "workspace:^", "@backstage/plugin-pagerduty": "workspace:^", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 90190b50be..3d8bd45e5a 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -67,15 +67,15 @@ import { SearchPage } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechDocsIndexPage, - TechDocsReaderPage, techdocsPlugin, + TechDocsReaderPage, } from '@backstage/plugin-techdocs'; import { TechDocsAddons } from '@backstage/plugin-techdocs-react'; import { ExpandableNavigation, + LightBox, ReportIssue, TextSize, - LightBox, } from '@backstage/plugin-techdocs-module-addons-contrib'; import { SettingsLayout, @@ -107,6 +107,7 @@ import { PuppetDbPage } from '@backstage/plugin-puppetdb'; import { DevToolsPage } from '@backstage/plugin-devtools'; import { customDevToolsPage } from './components/devtools/CustomDevToolsPage'; import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities'; +import { NotificationsPage } from '@backstage/plugin-notifications'; const app = createApp({ apis, @@ -272,6 +273,7 @@ const routes = ( }> {customDevToolsPage} + } /> ); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 5f11218bba..6294aa7856 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -34,6 +34,7 @@ import { import { SidebarSearchModal } from '@backstage/plugin-search'; import { Shortcuts } from '@backstage/plugin-shortcuts'; import { + Link, Sidebar, sidebarConfig, SidebarDivider, @@ -42,16 +43,16 @@ import { SidebarPage, SidebarScrollWrapper, SidebarSpace, - Link, - useSidebarOpenState, SidebarSubmenu, SidebarSubmenuItem, + useSidebarOpenState, } from '@backstage/core-components'; import { MyGroupsSidebarItem } from '@backstage/plugin-org'; import { SearchModal } from '../search/SearchModal'; import Score from '@material-ui/icons/Score'; import { useApp } from '@backstage/core-plugin-api'; import BuildIcon from '@material-ui/icons/Build'; +import { NotificationsSidebarItem } from '@backstage/plugin-notifications'; const useSidebarLogoStyles = makeStyles({ root: { @@ -166,6 +167,8 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( + + diff --git a/packages/backend/package.json b/packages/backend/package.json index 2386d799a9..1606158a7e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -55,6 +55,8 @@ "@backstage/plugin-lighthouse-backend": "workspace:^", "@backstage/plugin-linguist-backend": "workspace:^", "@backstage/plugin-nomad-backend": "workspace:^", + "@backstage/plugin-notifications-backend": "^0.0.0", + "@backstage/plugin-notifications-node": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-permission-node": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index bade028597..fd29c4a8c7 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -66,6 +66,7 @@ import linguist from './plugins/linguist'; import devTools from './plugins/devtools'; import nomad from './plugins/nomad'; import signals from './plugins/signals'; +import notifications from './plugins/notifications'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; @@ -74,6 +75,7 @@ import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { metrics } from '@opentelemetry/api'; import { DefaultSignalService } from '@backstage/plugin-signals-node'; +import { NotificationService } from '@backstage/plugin-notifications-node'; // Expose opentelemetry metrics using a Prometheus exporter on // http://localhost:9464/metrics . See prometheus.yml in packages/backend for @@ -103,6 +105,10 @@ function makeCreateEnv(config: Config) { const signalService = DefaultSignalService.create({ eventBroker, }); + const notificationService = NotificationService.create({ + database: databaseManager.forPlugin('notifications'), + discovery, + }); root.info(`Created UrlReader ${reader}`); @@ -125,6 +131,7 @@ function makeCreateEnv(config: Config) { scheduler, identity, signalService, + notificationService, }; }; } @@ -179,6 +186,9 @@ async function main() { const devToolsEnv = useHotMemoize(module, () => createEnv('devtools')); const nomadEnv = useHotMemoize(module, () => createEnv('nomad')); const signalsEnv = useHotMemoize(module, () => createEnv('signals')); + const notificationsEnv = useHotMemoize(module, () => + createEnv('notifications'), + ); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -206,6 +216,7 @@ async function main() { apiRouter.use('/devtools', await devTools(devToolsEnv)); apiRouter.use('/nomad', await nomad(nomadEnv)); apiRouter.use('/signals', await signals(signalsEnv)); + apiRouter.use('/notifications', await notifications(notificationsEnv)); apiRouter.use(notFoundHandler()); await lighthouse(lighthouseEnv); diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts new file mode 100644 index 0000000000..91c01fb860 --- /dev/null +++ b/packages/backend/src/plugins/notifications.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouter } from '@backstage/plugin-notifications-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + // TODO: Remove this test code + // setInterval(() => { + // env.notificationService.send( + // 'user:default/guest', + // 'Test', + // 'This is test notification', + // '/catalog', + // ); + // }, 60000); + + return await createRouter({ + logger: env.logger, + identity: env.identity, + notificationService: env.notificationService, + }); +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 3dad2f739b..6a48804d1c 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -27,7 +27,8 @@ import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { EventBroker } from '@backstage/plugin-events-node'; -import { DefaultSignalService } from '@backstage/plugin-signals-node'; +import { SignalService } from '@backstage/plugin-signals-node'; +import { NotificationService } from '@backstage/plugin-notifications-node'; export type PluginEnvironment = { logger: Logger; @@ -41,5 +42,6 @@ export type PluginEnvironment = { scheduler: PluginTaskScheduler; identity: IdentityApi; eventBroker: EventBroker; - signalService: DefaultSignalService; + signalService: SignalService; + notificationService: NotificationService; }; diff --git a/plugins/notifications-backend/.eslintrc.js b/plugins/notifications-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/notifications-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md new file mode 100644 index 0000000000..e90d287049 --- /dev/null +++ b/plugins/notifications-backend/README.md @@ -0,0 +1,14 @@ +# notifications + +Welcome to the notifications backend plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn +start` in the root directory, and then navigating to [/notifications](http://localhost:3000/notifications). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. diff --git a/plugins/notifications-backend/api-report.md b/plugins/notifications-backend/api-report.md new file mode 100644 index 0000000000..e571fd521a --- /dev/null +++ b/plugins/notifications-backend/api-report.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/plugin-notifications-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import express from 'express'; +import { IdentityApi } from '@backstage/plugin-auth-node'; +import { Logger } from 'winston'; +import { NotificationService } from '@backstage/plugin-notifications-node'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + identity: IdentityApi; + // (undocumented) + logger: Logger; + // (undocumented) + notificationService: NotificationService; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json new file mode 100644 index 0000000000..ce67513669 --- /dev/null +++ b/plugins/notifications-backend/package.json @@ -0,0 +1,46 @@ +{ + "name": "@backstage/plugin-notifications-backend", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "@backstage/plugin-notifications-node": "workspace:^", + "@types/express": "*", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "knex": "^3.0.0", + "node-fetch": "^2.6.7", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/supertest": "^2.0.8", + "msw": "^1.0.0", + "supertest": "^6.2.4" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/notifications-backend/src/index.ts b/plugins/notifications-backend/src/index.ts new file mode 100644 index 0000000000..d2e8d61bad --- /dev/null +++ b/plugins/notifications-backend/src/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './service/router'; diff --git a/plugins/notifications-backend/src/run.ts b/plugins/notifications-backend/src/run.ts new file mode 100644 index 0000000000..d299ed23e9 --- /dev/null +++ b/plugins/notifications-backend/src/run.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts new file mode 100644 index 0000000000..c0f09b3ef9 --- /dev/null +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; +import { IdentityApi } from '@backstage/plugin-auth-node'; +import { NotificationService } from '@backstage/plugin-notifications-node'; + +describe('createRouter', () => { + let app: express.Express; + + const identityMock: IdentityApi = { + async getIdentity() { + return { + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'user:default/guest', + }, + token: 'no-token', + }; + }, + }; + + const notificationServiceMock: jest.Mocked> = { + getStore: jest.fn(), + send: jest.fn(), + }; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + identity: identityMock, + notificationService: notificationServiceMock as NotificationService, + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts new file mode 100644 index 0000000000..8d9a7472bb --- /dev/null +++ b/plugins/notifications-backend/src/service/router.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { errorHandler } from '@backstage/backend-common'; +import express, { Request } from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { NotificationService } from '@backstage/plugin-notifications-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; + +/** @public */ +export interface RouterOptions { + logger: Logger; + identity: IdentityApi; + notificationService: NotificationService; +} + +/** @public */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger, notificationService, identity } = options; + + const store = await notificationService.getStore(); + + const getUser = async (req: Request) => { + const user = await identity.getIdentity({ request: req }); + return user ? user.identity.userEntityRef : 'user:default/guest'; + }; + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.json({ status: 'ok' }); + }); + + router.get('/notifications', async (req, res) => { + const user = await getUser(req); + const notifications = await store.getNotifications({ user_ref: user }); + res.send(notifications); + }); + + router.get('/status', async (req, res) => { + const user = await getUser(req); + const status = await store.getStatus({ user_ref: user }); + res.send(status); + }); + + // TODO: Add endpoint to set read/unread by notification id(s) + + router.use(errorHandler()); + return router; +} diff --git a/plugins/notifications-backend/src/service/standaloneServer.ts b/plugins/notifications-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..c19ae01b74 --- /dev/null +++ b/plugins/notifications-backend/src/service/standaloneServer.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + createServiceBuilder, + loadBackendConfig, + PluginDatabaseManager, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; +import Knex from 'knex'; +import { IdentityApi } from '@backstage/plugin-auth-node'; +import { Request } from 'express'; +import { NotificationService } from '@backstage/plugin-notifications-node'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'notifications-backend' }); + logger.debug('Starting application server...'); + + const config = await loadBackendConfig({ logger, argv: process.argv }); + const db = Knex(config.get('backend.database')); + + const dbMock: PluginDatabaseManager = { + async getClient() { + return db; + }, + }; + + const discoveryMock: PluginEndpointDiscovery = { + async getBaseUrl(pluginId: string) { + return `http://localhost:7007/api/${pluginId}`; + }, + + async getExternalBaseUrl(pluginId: string) { + return `http://localhost:7007/api/${pluginId}`; + }, + }; + + const notificationService = NotificationService.create({ + database: dbMock, + discovery: discoveryMock, + }); + + const identityMock: IdentityApi = { + async getIdentity({ request }: { request: Request }) { + const token = request.headers.authorization?.split(' ')[1]; + return { + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'user:default/guest', + }, + token: token || 'no-token', + }; + }, + }; + + const router = await createRouter({ + logger, + identity: identityMock, + notificationService, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/notifications', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/notifications-backend/src/setupTests.ts b/plugins/notifications-backend/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/notifications-backend/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/plugins/notifications-common/.eslintrc.js b/plugins/notifications-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/notifications-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/notifications-common/README.md b/plugins/notifications-common/README.md new file mode 100644 index 0000000000..673e30288f --- /dev/null +++ b/plugins/notifications-common/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-notifications-common + +Welcome to the common package for the notifications plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md new file mode 100644 index 0000000000..6d7d74b64b --- /dev/null +++ b/plugins/notifications-common/api-report.md @@ -0,0 +1,24 @@ +## API Report File for "@backstage/plugin-notifications-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +// @public (undocumented) +type Notification_2 = { + id: string; + userRef: string; + title: string; + description: string; + link: string; + icon?: string; + created: Date; + read?: Date; +}; +export { Notification_2 as Notification }; + +// @public (undocumented) +export type NotificationStatus = { + unread: number; + read: number; +}; +``` diff --git a/plugins/notifications-common/package.json b/plugins/notifications-common/package.json new file mode 100644 index 0000000000..91acf171f7 --- /dev/null +++ b/plugins/notifications-common/package.json @@ -0,0 +1,32 @@ +{ + "name": "@backstage/plugin-notifications-common", + "description": "Common functionalities for the notifications plugin", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "common-library" + }, + "sideEffects": false, + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/notifications-common/src/index.ts b/plugins/notifications-common/src/index.ts new file mode 100644 index 0000000000..8d9f26f07a --- /dev/null +++ b/plugins/notifications-common/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Common functionalities for the notifications plugin. + * + * @packageDocumentation + */ + +export * from './types'; diff --git a/plugins/notifications-common/src/setupTests.ts b/plugins/notifications-common/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/notifications-common/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts new file mode 100644 index 0000000000..c9df78e1ff --- /dev/null +++ b/plugins/notifications-common/src/types.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** @public */ +export type Notification = { + id: string; + userRef: string; + title: string; + description: string; + link: string; + // TODO: Icon should be typed so that we know what to render + icon?: string; + created: Date; + read?: Date; +}; + +/** @public */ +export type NotificationStatus = { + unread: number; + read: number; +}; diff --git a/plugins/notifications-node/.eslintrc.js b/plugins/notifications-node/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/notifications-node/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/notifications-node/README.md b/plugins/notifications-node/README.md new file mode 100644 index 0000000000..4cf460c3fe --- /dev/null +++ b/plugins/notifications-node/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-notifications-node + +Welcome to the Node.js library package for the notifications plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md new file mode 100644 index 0000000000..68a53cd535 --- /dev/null +++ b/plugins/notifications-node/api-report.md @@ -0,0 +1,70 @@ +## API Report File for "@backstage/plugin-notifications-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { NotificationStatus } from '@backstage/plugin-notifications-common'; +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +// @public (undocumented) +export class DatabaseNotificationsStore implements NotificationsStore { + // (undocumented) + static create({ + database, + skipMigrations, + }: { + database: PluginDatabaseManager; + skipMigrations?: boolean; + }): Promise; + // (undocumented) + getNotifications(options: NotificationGetOptions): Promise; + // (undocumented) + getStatus(options: NotificationGetOptions): Promise<{ + unread: number; + read: number; + }>; + // (undocumented) + saveNotification(notification: Notification_2): Promise; +} + +// @public (undocumented) +export type NotificationGetOptions = { + user_ref: string; +}; + +// @public (undocumented) +export class NotificationService { + // (undocumented) + static create({ + database, + discovery, + }: NotificationServiceOptions): NotificationService; + // (undocumented) + getStore(): Promise; + // (undocumented) + send( + entityRef: string | string[], + title: string, + description: string, + link: string, + ): Promise; +} + +// @public (undocumented) +export type NotificationServiceOptions = { + database: PluginDatabaseManager; + discovery: PluginEndpointDiscovery; +}; + +// @public (undocumented) +export interface NotificationsStore { + // (undocumented) + getNotifications(options: NotificationGetOptions): Promise; + // (undocumented) + getStatus(options: NotificationGetOptions): Promise; + // (undocumented) + saveNotification(notification: Notification_2): Promise; +} +``` diff --git a/plugins/notifications-node/migrations/20231215_init.js b/plugins/notifications-node/migrations/20231215_init.js new file mode 100644 index 0000000000..a76a8c3cbd --- /dev/null +++ b/plugins/notifications-node/migrations/20231215_init.js @@ -0,0 +1,34 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + await knex.schema.createTable('notifications', table => { + table.uuid('id').primary(); + table.string('userRef').notNullable(); + table.string('title').notNullable(); + table.text('description').notNullable(); + table.text('link').notNullable(); + table.datetime('created').notNullable(); + table.datetime('read').nullable(); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('notifications'); +}; diff --git a/plugins/notifications-node/package.json b/plugins/notifications-node/package.json new file mode 100644 index 0000000000..4a9e9a2319 --- /dev/null +++ b/plugins/notifications-node/package.json @@ -0,0 +1,38 @@ +{ + "name": "@backstage/plugin-notifications-node", + "description": "Node.js library for the notifications plugin", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "node-library" + }, + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + }, + "files": [ + "dist" + ], + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/catalog-client": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/plugin-notifications-common": "workspace:^", + "knex": "^3.0.0", + "uuid": "^8.0.0" + } +} diff --git a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts new file mode 100644 index 0000000000..450450d998 --- /dev/null +++ b/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + PluginDatabaseManager, + resolvePackagePath, +} from '@backstage/backend-common'; +import { + NotificationGetOptions, + NotificationsStore, +} from './NotificationsStore'; +import { Notification } from '@backstage/plugin-notifications-common'; +import { Knex } from 'knex'; + +const migrationsDir = resolvePackagePath( + '@backstage/plugin-notifications-node', + 'migrations', +); + +/** @public */ +export class DatabaseNotificationsStore implements NotificationsStore { + private constructor(private readonly db: Knex) {} + + static async create({ + database, + skipMigrations, + }: { + database: PluginDatabaseManager; + skipMigrations?: boolean; + }): Promise { + const client = await database.getClient(); + + if (!database.migrations?.skip && !skipMigrations) { + await client.migrate.latest({ + directory: migrationsDir, + }); + } + + return new DatabaseNotificationsStore(client); + } + + private mapToInteger = (val: string | number | undefined): number => { + return typeof val === 'string' ? Number.parseInt(val, 10) : val ?? 0; + }; + + async getNotifications(options: NotificationGetOptions) { + const { user_ref } = options; + const notificationQuery = this.db('notifications').where( + 'userRef', + user_ref, + ); + + const notifications = await notificationQuery.select('*'); + + return notifications; + } + + async saveNotification(notification: Notification) { + await this.db.insert(notification).into('notifications'); + } + + async getStatus(options: NotificationGetOptions) { + const { user_ref } = options; + const notificationQuery = this.db('notifications').where( + 'userRef', + user_ref, + ); + + const unreadQuery = await notificationQuery + .clone() + .whereNull('read') + .count('id as UNREAD') + .first(); + const readQuery = await notificationQuery + .clone() + .whereNotNull('read') + .count('id as READ') + .first(); + + return { + unread: this.mapToInteger((unreadQuery as any)?.UNREAD), + read: this.mapToInteger((readQuery as any)?.READ), + }; + } +} diff --git a/plugins/notifications-node/src/database/NotificationsStore.ts b/plugins/notifications-node/src/database/NotificationsStore.ts new file mode 100644 index 0000000000..349e2cd7fa --- /dev/null +++ b/plugins/notifications-node/src/database/NotificationsStore.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Notification, + NotificationStatus, +} from '@backstage/plugin-notifications-common'; + +/** @public */ +export type NotificationGetOptions = { + user_ref: string; +}; + +/** @public */ +export interface NotificationsStore { + getNotifications(options: NotificationGetOptions): Promise; + + saveNotification(notification: Notification): Promise; + + getStatus(options: NotificationGetOptions): Promise; + + // TODO: Mark as read/unread by notification id(s) +} diff --git a/plugins/notifications-node/src/database/index.ts b/plugins/notifications-node/src/database/index.ts new file mode 100644 index 0000000000..6d2f549f38 --- /dev/null +++ b/plugins/notifications-node/src/database/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './DatabaseNotificationsStore'; +export * from './NotificationsStore'; diff --git a/plugins/notifications-node/src/index.ts b/plugins/notifications-node/src/index.ts new file mode 100644 index 0000000000..c686e95d3c --- /dev/null +++ b/plugins/notifications-node/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Node.js library for the notifications plugin. + * + * @packageDocumentation + */ + +export * from './database'; +export * from './service'; diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts new file mode 100644 index 0000000000..55dcff546f --- /dev/null +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -0,0 +1,136 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Notification } from '@backstage/plugin-notifications-common'; +import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; +import { NotificationsStore } from '../database/NotificationsStore'; +import { v4 as uuid } from 'uuid'; +import { + Entity, + isGroupEntity, + isUserEntity, + RELATION_HAS_MEMBER, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { + PluginDatabaseManager, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { DatabaseNotificationsStore } from '../database'; + +/** @public */ +export type NotificationServiceOptions = { + database: PluginDatabaseManager; + discovery: PluginEndpointDiscovery; +}; + +/** @public */ +export class NotificationService { + private store: NotificationsStore | null = null; + + private constructor( + private readonly database: PluginDatabaseManager, + private readonly catalog: CatalogApi, + ) {} + + static create({ + database, + discovery, + }: NotificationServiceOptions): NotificationService { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + + return new NotificationService(database, catalogClient); + } + + async send( + entityRef: string | string[], + title: string, + description: string, + link: string, + ): Promise { + const users = await this.getUsersForEntityRef(entityRef); + const notifications = []; + const store = await this.getStore(); + for (const user of users) { + const notification = { + id: uuid(), + userRef: user, + title, + description, + link, + created: new Date(), + }; + + await store.saveNotification(notification); + notifications.push(notification); + } + + // TODO: Signal service + // TODO: Other senders + + return notifications; + } + + async getStore(): Promise { + if (!this.store) { + this.store = await DatabaseNotificationsStore.create({ + database: this.database, + }); + } + return this.store; + } + + private async getUsersForEntityRef( + entityRef: string | string[], + ): Promise { + const refs = Array.isArray(entityRef) ? entityRef : [entityRef]; + const entities = await this.catalog.getEntitiesByRefs({ entityRefs: refs }); + + const mapEntity = async (entity: Entity | undefined): Promise => { + if (!entity) { + return []; + } + + if (isUserEntity(entity)) { + return [stringifyEntityRef(entity)]; + } else if (isGroupEntity(entity) && entity.relations) { + return entity.relations + .filter( + relation => + relation.type === RELATION_HAS_MEMBER && relation.targetRef, + ) + .map(r => r.targetRef); + } else if (!isGroupEntity(entity) && entity.spec?.owner) { + const owner = await this.catalog.getEntityByRef( + entity.spec.owner as string, + ); + if (owner) { + return mapEntity(owner); + } + } + + return []; + }; + + const users: string[] = []; + for (const entity of entities.items) { + const u = await mapEntity(entity); + users.push(...u); + } + return users; + } +} diff --git a/plugins/notifications-node/src/service/index.ts b/plugins/notifications-node/src/service/index.ts new file mode 100644 index 0000000000..450f4f7dcc --- /dev/null +++ b/plugins/notifications-node/src/service/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './NotificationService'; diff --git a/plugins/notifications-node/src/setupTests.ts b/plugins/notifications-node/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/notifications-node/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/plugins/notifications/.eslintrc.js b/plugins/notifications/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/notifications/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/notifications/README.md b/plugins/notifications/README.md new file mode 100644 index 0000000000..3eeac7a647 --- /dev/null +++ b/plugins/notifications/README.md @@ -0,0 +1,13 @@ +# notifications + +Welcome to the notifications plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/notifications](http://localhost:3000/notifications). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md new file mode 100644 index 0000000000..00ff5f4d4c --- /dev/null +++ b/plugins/notifications/api-report.md @@ -0,0 +1,88 @@ +## API Report File for "@backstage/plugin-notifications" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { ApiRef } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { FetchApi } from '@backstage/core-plugin-api'; +import { JSX as JSX_2 } from 'react'; +import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { NotificationStatus } from '@backstage/plugin-notifications-common'; +import { default as React_2 } from 'react'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public (undocumented) +export interface NotificationsApi { + // (undocumented) + getNotifications(): Promise; + // (undocumented) + getStatus(): Promise; +} + +// @public (undocumented) +export const notificationsApiRef: ApiRef; + +// @public (undocumented) +export class NotificationsClient implements NotificationsApi { + constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }); + // (undocumented) + getNotifications(): Promise; + // (undocumented) + getStatus(): Promise; +} + +// @public (undocumented) +export const NotificationsPage: () => JSX_2.Element; + +// @public (undocumented) +export const notificationsPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {} +>; + +// @public (undocumented) +export const NotificationsSidebarItem: () => React_2.JSX.Element; + +// @public (undocumented) +export const NotificationsTable: (props: { + notifications?: Notification_2[]; +}) => React_2.JSX.Element; + +// @public (undocumented) +export function useNotificationsApi( + f: (api: NotificationsApi) => Promise, + deps?: any[], +): + | { + retry: () => void; + loading: boolean; + error?: undefined; + value?: undefined; + } + | { + retry: () => void; + loading: false; + error: Error; + value?: undefined; + } + | { + retry: () => void; + loading: true; + error?: Error | undefined; + value?: T | undefined; + } + | { + retry: () => void; + loading: false; + error?: undefined; + value: T; + }; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/notifications/dev/index.tsx b/plugins/notifications/dev/index.tsx new file mode 100644 index 0000000000..f4ea31f4ab --- /dev/null +++ b/plugins/notifications/dev/index.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { notificationsPlugin } from '../src/plugin'; + +createDevApp() + .registerPlugin(notificationsPlugin) + .addPage({ + element:
, + title: 'Root Page', + path: '/notifications', + }) + .render(); diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json new file mode 100644 index 0000000000..9e78fc32de --- /dev/null +++ b/plugins/notifications/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/plugin-notifications", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-notifications-common": "workspace:^", + "@backstage/theme": "workspace:^", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.61", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "msw": "^1.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts new file mode 100644 index 0000000000..a4dcf3cd28 --- /dev/null +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createApiRef } from '@backstage/core-plugin-api'; +import { + Notification, + NotificationStatus, +} from '@backstage/plugin-notifications-common'; + +/** @public */ +export const notificationsApiRef = createApiRef({ + id: 'plugin.notifications.service', +}); + +/** @public */ +export interface NotificationsApi { + getNotifications(): Promise; + + getStatus(): Promise; + + // TODO: Mark as read/unread by notification id(s) +} diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts new file mode 100644 index 0000000000..4947900d55 --- /dev/null +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NotificationsApi } from './NotificationsApi'; +import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { + Notification, + NotificationStatus, +} from '@backstage/plugin-notifications-common'; + +/** @public */ +export class NotificationsClient implements NotificationsApi { + private readonly discoveryApi: DiscoveryApi; + private readonly fetchApi: FetchApi; + + public constructor(options: { + discoveryApi: DiscoveryApi; + fetchApi: FetchApi; + }) { + this.discoveryApi = options.discoveryApi; + this.fetchApi = options.fetchApi; + } + + async getNotifications(): Promise { + return await this.get('notifications'); + } + + async getStatus(): Promise { + return await this.get('status'); + } + + private async get(path: string): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('notifications')}/`; + const url = new URL(path, baseUrl); + + const response = await this.fetchApi.fetch(url.toString()); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return response.json() as Promise; + } +} diff --git a/plugins/notifications/src/api/index.ts b/plugins/notifications/src/api/index.ts new file mode 100644 index 0000000000..bf39e313b8 --- /dev/null +++ b/plugins/notifications/src/api/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './NotificationsApi'; +export * from './NotificationsClient'; diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx new file mode 100644 index 0000000000..e00b45df53 --- /dev/null +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Content, + ErrorPanel, + PageWithHeader, +} from '@backstage/core-components'; +import { NotificationsTable } from '../NotificationsTable'; +import { useNotificationsApi } from '../../hooks'; +import { + Button, + Grid, + makeStyles, + Paper, + TableContainer, +} from '@material-ui/core'; +import Bookmark from '@material-ui/icons/Bookmark'; +import Check from '@material-ui/icons/Check'; +import Inbox from '@material-ui/icons/Inbox'; + +const useStyles = makeStyles(_theme => ({ + filterButton: { + width: '100%', + justifyContent: 'start', + }, +})); + +export const NotificationsPage = () => { + const { + loading: _loading, + error, + value, + retry: _retry, + } = useNotificationsApi(api => api.getNotifications()); + + const styles = useStyles(); + if (error) { + return ; + } + + // TODO: Make the filter buttons work + // TODO: Add signals listener and refresh data on message + return ( + + + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/notifications/src/components/NotificationsPage/index.ts b/plugins/notifications/src/components/NotificationsPage/index.ts new file mode 100644 index 0000000000..ace54c34d0 --- /dev/null +++ b/plugins/notifications/src/components/NotificationsPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './NotificationsPage'; diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx new file mode 100644 index 0000000000..6a562b20c3 --- /dev/null +++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useEffect } from 'react'; +import { useNotificationsApi } from '../../hooks'; +import { SidebarItem } from '@backstage/core-components'; +import NotificationsIcon from '@material-ui/icons/Notifications'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { rootRouteRef } from '../../routes'; + +/** @public */ +export const NotificationsSidebarItem = () => { + const { + loading, + error, + value, + retry: _retry, + } = useNotificationsApi(api => api.getStatus()); + const [unreadCount, setUnreadCount] = React.useState(0); + const notificationsRoute = useRouteRef(rootRouteRef); + + useEffect(() => { + if (!loading && !error && value) { + setUnreadCount(value.unread); + } + }, [loading, error, value]); + + // TODO: Figure out if there count can be added to hasNotifications + return ( + + ); +}; diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/index.ts b/plugins/notifications/src/components/NotificationsSideBarItem/index.ts new file mode 100644 index 0000000000..22d6b5d881 --- /dev/null +++ b/plugins/notifications/src/components/NotificationsSideBarItem/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './NotificationsSideBarItem'; diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx new file mode 100644 index 0000000000..6e990cf3ea --- /dev/null +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { + IconButton, + makeStyles, + Table, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@material-ui/core'; +import { Notification } from '@backstage/plugin-notifications-common'; +import { useNavigate } from 'react-router-dom'; +import NotificationsIcon from '@material-ui/icons/Notifications'; +import Checkbox from '@material-ui/core/Checkbox'; +import Check from '@material-ui/icons/Check'; +import Bookmark from '@material-ui/icons/Bookmark'; + +const useStyles = makeStyles(theme => ({ + notificationRow: { + cursor: 'pointer', + '&:hover': { + backgroundColor: theme.palette.linkHover, + }, + }, + checkBox: { + padding: '0 10px 10px 0', + }, +})); + +/** @public */ +export const NotificationsTable = (props: { + notifications?: Notification[]; +}) => { + const { notifications } = props; + const navigate = useNavigate(); + const styles = useStyles(); + // TODO: Add select all + // TODO: Make mark as read work + // TODO: Check status of the notification and change to "Mark as unread" if it's already read + // TODO: Add support to save notifications (storageApi) + // TODO: Show timestamp relative time (react-relative-time npm package) + // TODO: Add signals listener and refresh data on message + // TODO: Handle no notifications properly + // TODO: Handle loading notifications + return ( + + + + + {notifications?.length ?? 0} notifications + + + + {props.notifications?.map(notification => { + return ( + + + + {notification.icon ?? } + + navigate(notification.link)}> + {notification.title} + + {notification.description} + + + + + + + + + + + + + + + + ); + })} +
+ ); +}; diff --git a/plugins/notifications/src/components/NotificationsTable/index.ts b/plugins/notifications/src/components/NotificationsTable/index.ts new file mode 100644 index 0000000000..a1fb7a25c6 --- /dev/null +++ b/plugins/notifications/src/components/NotificationsTable/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './NotificationsTable'; diff --git a/plugins/notifications/src/components/index.ts b/plugins/notifications/src/components/index.ts new file mode 100644 index 0000000000..3bca70a215 --- /dev/null +++ b/plugins/notifications/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './NotificationsSideBarItem'; +export * from './NotificationsTable'; diff --git a/plugins/notifications/src/hooks/index.ts b/plugins/notifications/src/hooks/index.ts new file mode 100644 index 0000000000..2613596f72 --- /dev/null +++ b/plugins/notifications/src/hooks/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './useNotificationsApi'; diff --git a/plugins/notifications/src/hooks/useNotificationsApi.ts b/plugins/notifications/src/hooks/useNotificationsApi.ts new file mode 100644 index 0000000000..e1afb54c8d --- /dev/null +++ b/plugins/notifications/src/hooks/useNotificationsApi.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NotificationsApi, notificationsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsyncRetry from 'react-use/lib/useAsyncRetry'; + +/** @public */ +export function useNotificationsApi( + f: (api: NotificationsApi) => Promise, + deps: any[] = [], +) { + const notificationsApi = useApi(notificationsApiRef); + + return useAsyncRetry(async () => { + return await f(notificationsApi); + }, deps); +} diff --git a/plugins/notifications/src/index.ts b/plugins/notifications/src/index.ts new file mode 100644 index 0000000000..bb5d76a0cf --- /dev/null +++ b/plugins/notifications/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { notificationsPlugin, NotificationsPage } from './plugin'; +export * from './api'; +export * from './hooks'; +export * from './components'; diff --git a/plugins/notifications/src/plugin.test.ts b/plugins/notifications/src/plugin.test.ts new file mode 100644 index 0000000000..3bf749aba9 --- /dev/null +++ b/plugins/notifications/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { notificationsPlugin } from './plugin'; + +describe('notifications', () => { + it('should export plugin', () => { + expect(notificationsPlugin).toBeDefined(); + }); +}); diff --git a/plugins/notifications/src/plugin.ts b/plugins/notifications/src/plugin.ts new file mode 100644 index 0000000000..472e519183 --- /dev/null +++ b/plugins/notifications/src/plugin.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + createApiFactory, + createPlugin, + createRoutableExtension, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; + +import { rootRouteRef } from './routes'; +import { notificationsApiRef } from './api/NotificationsApi'; +import { NotificationsClient } from './api'; + +/** @public */ +export const notificationsPlugin = createPlugin({ + id: 'notifications', + routes: { + root: rootRouteRef, + }, + apis: [ + createApiFactory({ + api: notificationsApiRef, + deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, + factory: ({ discoveryApi, fetchApi }) => + new NotificationsClient({ discoveryApi, fetchApi }), + }), + ], +}); + +/** @public */ +export const NotificationsPage = notificationsPlugin.provide( + createRoutableExtension({ + name: 'NotificationsPage', + component: () => + import('./components/NotificationsPage').then(m => m.NotificationsPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/notifications/src/routes.ts b/plugins/notifications/src/routes.ts new file mode 100644 index 0000000000..f307038701 --- /dev/null +++ b/plugins/notifications/src/routes.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'notifications', +}); diff --git a/plugins/notifications/src/setupTests.ts b/plugins/notifications/src/setupTests.ts new file mode 100644 index 0000000000..865308e634 --- /dev/null +++ b/plugins/notifications/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index 5f84cbbc51..23efe10941 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,7 @@ __metadata: languageName: node linkType: hard -"@adobe/css-tools@npm:^4.3.2": +"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.3.2": version: 4.3.2 resolution: "@adobe/css-tools@npm:4.3.2" checksum: 9667d61d55dc3b0a315c530ae84e016ce5267c4dd8ac00abb40108dc98e07b98e3090ce8b87acd51a41a68d9e84dcccb08cdf21c902572a9cf9dcaf830da4ae3 @@ -7779,6 +7779,77 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-notifications-backend@^0.0.0, @backstage/plugin-notifications-backend@workspace:plugins/notifications-backend": + version: 0.0.0-use.local + resolution: "@backstage/plugin-notifications-backend@workspace:plugins/notifications-backend" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/plugin-notifications-node": "workspace:^" + "@types/express": "*" + "@types/supertest": ^2.0.8 + express: ^4.17.1 + express-promise-router: ^4.1.0 + knex: ^3.0.0 + msw: ^1.0.0 + node-fetch: ^2.6.7 + supertest: ^6.2.4 + winston: ^3.2.1 + yn: ^4.0.0 + languageName: unknown + linkType: soft + +"@backstage/plugin-notifications-common@workspace:^, @backstage/plugin-notifications-common@workspace:plugins/notifications-common": + version: 0.0.0-use.local + resolution: "@backstage/plugin-notifications-common@workspace:plugins/notifications-common" + dependencies: + "@backstage/cli": "workspace:^" + languageName: unknown + linkType: soft + +"@backstage/plugin-notifications-node@workspace:^, @backstage/plugin-notifications-node@workspace:plugins/notifications-node": + version: 0.0.0-use.local + resolution: "@backstage/plugin-notifications-node@workspace:plugins/notifications-node" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/plugin-notifications-common": "workspace:^" + knex: ^3.0.0 + uuid: ^8.0.0 + languageName: unknown + linkType: soft + +"@backstage/plugin-notifications@^0.0.0, @backstage/plugin-notifications@workspace:plugins/notifications": + version: 0.0.0-use.local + resolution: "@backstage/plugin-notifications@workspace:plugins/notifications" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-notifications-common": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@material-ui/core": ^4.9.13 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": ^4.0.0-alpha.61 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + msw: ^1.0.0 + react-use: ^17.2.4 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + languageName: unknown + linkType: soft + "@backstage/plugin-octopus-deploy@workspace:^, @backstage/plugin-octopus-deploy@workspace:plugins/octopus-deploy": version: 0.0.0-use.local resolution: "@backstage/plugin-octopus-deploy@workspace:plugins/octopus-deploy" @@ -17586,6 +17657,22 @@ __metadata: languageName: unknown linkType: soft +"@testing-library/dom@npm:^8.0.0": + version: 8.20.1 + resolution: "@testing-library/dom@npm:8.20.1" + dependencies: + "@babel/code-frame": ^7.10.4 + "@babel/runtime": ^7.12.5 + "@types/aria-query": ^5.0.1 + aria-query: 5.1.3 + chalk: ^4.1.0 + dom-accessibility-api: ^0.5.9 + lz-string: ^1.5.0 + pretty-format: ^27.0.2 + checksum: 06fc8dc67849aadb726cbbad0e7546afdf8923bd39acb64c576d706249bd7d0d05f08e08a31913fb621162e3b9c2bd0dce15964437f030f9fa4476326fdd3007 + languageName: node + linkType: hard + "@testing-library/dom@npm:^9.0.0": version: 9.3.4 resolution: "@testing-library/dom@npm:9.3.4" @@ -17602,6 +17689,23 @@ __metadata: languageName: node linkType: hard +"@testing-library/jest-dom@npm:^5.10.1": + version: 5.17.0 + resolution: "@testing-library/jest-dom@npm:5.17.0" + dependencies: + "@adobe/css-tools": ^4.0.1 + "@babel/runtime": ^7.9.2 + "@types/testing-library__jest-dom": ^5.9.1 + aria-query: ^5.0.0 + chalk: ^3.0.0 + css.escape: ^1.5.1 + dom-accessibility-api: ^0.5.6 + lodash: ^4.17.15 + redent: ^3.0.0 + checksum: 9f28dbca8b50d7c306aae40c3aa8e06f0e115f740360004bd87d57f95acf7ab4b4f4122a7399a76dbf2bdaaafb15c99cc137fdcb0ae457a92e2de0f3fbf9b03b + languageName: node + linkType: hard + "@testing-library/jest-dom@npm:^6.0.0": version: 6.3.0 resolution: "@testing-library/jest-dom@npm:6.3.0" @@ -17657,6 +17761,20 @@ __metadata: languageName: node linkType: hard +"@testing-library/react@npm:^12.1.3": + version: 12.1.5 + resolution: "@testing-library/react@npm:12.1.5" + dependencies: + "@babel/runtime": ^7.12.5 + "@testing-library/dom": ^8.0.0 + "@types/react-dom": <18.0.0 + peerDependencies: + react: <18.0.0 + react-dom: <18.0.0 + checksum: 4abd0490405e709a7df584a0db604e508a4612398bb1326e8fa32dd9393b15badc826dcf6d2f7525437886d507871f719f127b9860ed69ddd204d1fa834f576a + languageName: node + linkType: hard + "@testing-library/react@npm:^14.0.0": version: 14.2.1 resolution: "@testing-library/react@npm:14.2.1" @@ -25191,6 +25309,13 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.6": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 005eb283caef57fc1adec4d5df4dd49189b628f2f575af45decb210e04d634459e3f1ee64f18b41e2dcf200c844bc1d9279d80807e686a30d69a4756151ad248 + languageName: node + linkType: hard + "dom-accessibility-api@npm:^0.5.9": version: 0.5.13 resolution: "dom-accessibility-api@npm:0.5.13" @@ -27007,6 +27132,7 @@ __metadata: "@backstage/plugin-newrelic": "workspace:^" "@backstage/plugin-newrelic-dashboard": "workspace:^" "@backstage/plugin-nomad": "workspace:^" + "@backstage/plugin-notifications": ^0.0.0 "@backstage/plugin-octopus-deploy": "workspace:^" "@backstage/plugin-org": "workspace:^" "@backstage/plugin-pagerduty": "workspace:^" @@ -27144,6 +27270,8 @@ __metadata: "@backstage/plugin-lighthouse-backend": "workspace:^" "@backstage/plugin-linguist-backend": "workspace:^" "@backstage/plugin-nomad-backend": "workspace:^" + "@backstage/plugin-notifications-backend": ^0.0.0 + "@backstage/plugin-notifications-node": "workspace:^" "@backstage/plugin-permission-backend": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" From 92c327211adc8ec9076bbb905d6cfe28c12c8b47 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 15 Dec 2023 14:41:04 +0200 Subject: [PATCH 02/25] feat: make notification page filters work Signed-off-by: Heikki Hellgren --- packages/backend/src/plugins/notifications.ts | 16 +++++----- .../src/service/router.ts | 14 +++++++-- plugins/notifications-common/api-report.md | 3 ++ plugins/notifications-common/src/types.ts | 3 ++ plugins/notifications-node/api-report.md | 2 ++ .../database/DatabaseNotificationsStore.ts | 28 +++++++++--------- .../src/database/NotificationsStore.ts | 2 ++ plugins/notifications/api-report.md | 14 +++++++-- .../notifications/src/api/NotificationsApi.ts | 9 +++++- .../src/api/NotificationsClient.ts | 15 ++++++++-- .../NotificationsPage/NotificationsPage.tsx | 29 +++++++++++++++---- 11 files changed, 100 insertions(+), 35 deletions(-) diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index 91c01fb860..bd6976e91b 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -22,14 +22,14 @@ export default async function createPlugin( env: PluginEnvironment, ): Promise { // TODO: Remove this test code - // setInterval(() => { - // env.notificationService.send( - // 'user:default/guest', - // 'Test', - // 'This is test notification', - // '/catalog', - // ); - // }, 60000); + setInterval(() => { + env.notificationService.send( + 'user:default/guest', + 'Test', + 'This is test notification', + '/catalog', + ); + }, 60000); return await createRouter({ logger: env.logger, diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 8d9a7472bb..482a987638 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -17,7 +17,10 @@ import { errorHandler } from '@backstage/backend-common'; import express, { Request } from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { NotificationService } from '@backstage/plugin-notifications-node'; +import { + NotificationGetOptions, + NotificationService, +} from '@backstage/plugin-notifications-node'; import { IdentityApi } from '@backstage/plugin-auth-node'; /** @public */ @@ -50,7 +53,14 @@ export async function createRouter( router.get('/notifications', async (req, res) => { const user = await getUser(req); - const notifications = await store.getNotifications({ user_ref: user }); + const opts: NotificationGetOptions = { + user_ref: user, + }; + if (req.query.type) { + opts.type = req.query.type as any; + } + + const notifications = await store.getNotifications(opts); res.send(notifications); }); diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index 6d7d74b64b..f8c5a009bc 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -21,4 +21,7 @@ export type NotificationStatus = { unread: number; read: number; }; + +// @public (undocumented) +export type NotificationType = 'read' | 'unread' | 'saved'; ``` diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index c9df78e1ff..d95ad92508 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +/** @public */ +export type NotificationType = 'read' | 'unread' | 'saved'; + /** @public */ export type Notification = { id: string; diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 68a53cd535..575c6a9b09 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -5,6 +5,7 @@ ```ts import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; import { NotificationStatus } from '@backstage/plugin-notifications-common'; +import { NotificationType } from '@backstage/plugin-notifications-common'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -32,6 +33,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { // @public (undocumented) export type NotificationGetOptions = { user_ref: string; + type?: NotificationType; }; // @public (undocumented) diff --git a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts index 450450d998..63f9685e85 100644 --- a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts @@ -55,15 +55,22 @@ export class DatabaseNotificationsStore implements NotificationsStore { return typeof val === 'string' ? Number.parseInt(val, 10) : val ?? 0; }; + private getNotificationsBaseQuery = (options: NotificationGetOptions) => { + const { user_ref, type } = options; + const query = this.db('notifications').where('userRef', user_ref); + + if (type === 'unread') { + query.whereNull('read'); + } else if (type === 'read') { + query.whereNotNull('read'); + } + // TODO: Saved + return query; + }; + async getNotifications(options: NotificationGetOptions) { - const { user_ref } = options; - const notificationQuery = this.db('notifications').where( - 'userRef', - user_ref, - ); - + const notificationQuery = this.getNotificationsBaseQuery(options); const notifications = await notificationQuery.select('*'); - return notifications; } @@ -72,12 +79,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { } async getStatus(options: NotificationGetOptions) { - const { user_ref } = options; - const notificationQuery = this.db('notifications').where( - 'userRef', - user_ref, - ); - + const notificationQuery = this.getNotificationsBaseQuery(options); const unreadQuery = await notificationQuery .clone() .whereNull('read') diff --git a/plugins/notifications-node/src/database/NotificationsStore.ts b/plugins/notifications-node/src/database/NotificationsStore.ts index 349e2cd7fa..b25d32900a 100644 --- a/plugins/notifications-node/src/database/NotificationsStore.ts +++ b/plugins/notifications-node/src/database/NotificationsStore.ts @@ -17,11 +17,13 @@ import { Notification, NotificationStatus, + NotificationType, } from '@backstage/plugin-notifications-common'; /** @public */ export type NotificationGetOptions = { user_ref: string; + type?: NotificationType; }; /** @public */ diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index 00ff5f4d4c..9039f688eb 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -12,13 +12,21 @@ import { FetchApi } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; import { NotificationStatus } from '@backstage/plugin-notifications-common'; +import { NotificationType } from '@backstage/plugin-notifications-common'; import { default as React_2 } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; +// @public (undocumented) +export type GetNotificationsOptions = { + type?: NotificationType; +}; + // @public (undocumented) export interface NotificationsApi { // (undocumented) - getNotifications(): Promise; + getNotifications( + options?: GetNotificationsOptions, + ): Promise; // (undocumented) getStatus(): Promise; } @@ -30,7 +38,9 @@ export const notificationsApiRef: ApiRef; export class NotificationsClient implements NotificationsApi { constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }); // (undocumented) - getNotifications(): Promise; + getNotifications( + options?: GetNotificationsOptions, + ): Promise; // (undocumented) getStatus(): Promise; } diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts index a4dcf3cd28..aa4b460cb3 100644 --- a/plugins/notifications/src/api/NotificationsApi.ts +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -17,6 +17,7 @@ import { createApiRef } from '@backstage/core-plugin-api'; import { Notification, NotificationStatus, + NotificationType, } from '@backstage/plugin-notifications-common'; /** @public */ @@ -24,11 +25,17 @@ export const notificationsApiRef = createApiRef({ id: 'plugin.notifications.service', }); +/** @public */ +export type GetNotificationsOptions = { + type?: NotificationType; +}; + /** @public */ export interface NotificationsApi { - getNotifications(): Promise; + getNotifications(options?: GetNotificationsOptions): Promise; getStatus(): Promise; // TODO: Mark as read/unread by notification id(s) + // TODO: Mark as saved/unsaved by notification id(s) } diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index 4947900d55..ec6472c37e 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { NotificationsApi } from './NotificationsApi'; +import { GetNotificationsOptions, NotificationsApi } from './NotificationsApi'; import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { @@ -34,8 +34,17 @@ export class NotificationsClient implements NotificationsApi { this.fetchApi = options.fetchApi; } - async getNotifications(): Promise { - return await this.get('notifications'); + async getNotifications( + options?: GetNotificationsOptions, + ): Promise { + const queryString = new URLSearchParams(); + if (options?.type) { + queryString.append('type', options.type); + } + + const urlSegment = `notifications?${queryString}`; + + return await this.get(urlSegment); } async getStatus(): Promise { diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index e00b45df53..461c39e2aa 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { Content, ErrorPanel, @@ -32,6 +32,7 @@ import { import Bookmark from '@material-ui/icons/Bookmark'; import Check from '@material-ui/icons/Check'; import Inbox from '@material-ui/icons/Inbox'; +import { NotificationType } from '@backstage/plugin-notifications-common'; const useStyles = makeStyles(_theme => ({ filterButton: { @@ -41,32 +42,48 @@ const useStyles = makeStyles(_theme => ({ })); export const NotificationsPage = () => { + const [type, setType] = useState('unread'); + const { loading: _loading, error, value, retry: _retry, - } = useNotificationsApi(api => api.getNotifications()); + } = useNotificationsApi(api => api.getNotifications({ type }), [type]); const styles = useStyles(); if (error) { return ; } - // TODO: Make the filter buttons work // TODO: Add signals listener and refresh data on message return ( - - - From b3d70e2a4f66a5540c758faa91c6c53c1bf882f0 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 15 Dec 2023 14:47:22 +0200 Subject: [PATCH 03/25] feat: add support for new backend to notifications Signed-off-by: Heikki Hellgren --- plugins/notifications-backend/api-report.md | 4 ++ plugins/notifications-backend/package.json | 1 + plugins/notifications-backend/src/index.ts | 1 + plugins/notifications-backend/src/plugin.ts | 50 +++++++++++++++++++++ plugins/notifications-node/api-report.md | 4 ++ plugins/notifications-node/package.json | 1 + plugins/notifications-node/src/index.ts | 1 + plugins/notifications-node/src/lib.ts | 39 ++++++++++++++++ yarn.lock | 2 + 9 files changed, 103 insertions(+) create mode 100644 plugins/notifications-backend/src/plugin.ts create mode 100644 plugins/notifications-node/src/lib.ts diff --git a/plugins/notifications-backend/api-report.md b/plugins/notifications-backend/api-report.md index e571fd521a..02e86293f9 100644 --- a/plugins/notifications-backend/api-report.md +++ b/plugins/notifications-backend/api-report.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; import express from 'express'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { Logger } from 'winston'; @@ -11,6 +12,9 @@ import { NotificationService } from '@backstage/plugin-notifications-node'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; +// @public +export const notificationsPlugin: () => BackendFeature; + // @public (undocumented) export interface RouterOptions { // (undocumented) diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index ce67513669..1ee25ec127 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-notifications-node": "workspace:^", diff --git a/plugins/notifications-backend/src/index.ts b/plugins/notifications-backend/src/index.ts index d2e8d61bad..e3a02aca06 100644 --- a/plugins/notifications-backend/src/index.ts +++ b/plugins/notifications-backend/src/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './service/router'; +export * from './plugin'; diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts new file mode 100644 index 0000000000..d125332c4c --- /dev/null +++ b/plugins/notifications-backend/src/plugin.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { loggerToWinstonLogger } from '@backstage/backend-common'; +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { createRouter } from './service/router'; +import { notificationService } from '@backstage/plugin-notifications-node'; + +/** + * Notifications backend plugin + * + * @public + */ +export const notificationsPlugin = createBackendPlugin({ + pluginId: 'notifications', + register(env) { + env.registerInit({ + deps: { + httpRouter: coreServices.httpRouter, + logger: coreServices.logger, + identity: coreServices.identity, + service: notificationService, + }, + async init({ httpRouter, logger, identity, service }) { + httpRouter.use( + await createRouter({ + logger: loggerToWinstonLogger(logger), + identity, + notificationService: service, + }), + ); + }, + }); + }, +}); diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 575c6a9b09..c7b6383c6f 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -8,6 +8,7 @@ import { NotificationStatus } from '@backstage/plugin-notifications-common'; import { NotificationType } from '@backstage/plugin-notifications-common'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { ServiceRef } from '@backstage/backend-plugin-api'; // @public (undocumented) export class DatabaseNotificationsStore implements NotificationsStore { @@ -54,6 +55,9 @@ export class NotificationService { ): Promise; } +// @public (undocumented) +export const notificationService: ServiceRef; + // @public (undocumented) export type NotificationServiceOptions = { database: PluginDatabaseManager; diff --git a/plugins/notifications-node/package.json b/plugins/notifications-node/package.json index 4a9e9a2319..d5cb66ba3d 100644 --- a/plugins/notifications-node/package.json +++ b/plugins/notifications-node/package.json @@ -29,6 +29,7 @@ ], "dependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/plugin-notifications-common": "workspace:^", diff --git a/plugins/notifications-node/src/index.ts b/plugins/notifications-node/src/index.ts index c686e95d3c..f820946cc0 100644 --- a/plugins/notifications-node/src/index.ts +++ b/plugins/notifications-node/src/index.ts @@ -22,3 +22,4 @@ export * from './database'; export * from './service'; +export * from './lib'; diff --git a/plugins/notifications-node/src/lib.ts b/plugins/notifications-node/src/lib.ts new file mode 100644 index 0000000000..05a1321bc1 --- /dev/null +++ b/plugins/notifications-node/src/lib.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + coreServices, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { NotificationService } from './service'; + +/** @public */ +export const notificationService = createServiceRef({ + id: 'notifications.service', + scope: 'plugin', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + discovery: coreServices.discovery, + database: coreServices.database, + // TODO: Signals + }, + factory({ discovery, database }) { + return NotificationService.create({ discovery, database }); + }, + }), +}); diff --git a/yarn.lock b/yarn.lock index 23efe10941..593901c68e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7784,6 +7784,7 @@ __metadata: resolution: "@backstage/plugin-notifications-backend@workspace:plugins/notifications-backend" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" @@ -7814,6 +7815,7 @@ __metadata: resolution: "@backstage/plugin-notifications-node@workspace:plugins/notifications-node" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" From 49afb1823fffd97ab28370a2d7e7a8d7d69c6dbd Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 15 Dec 2023 16:13:57 +0200 Subject: [PATCH 04/25] feat: support saving and marking notifications done Signed-off-by: Heikki Hellgren --- packages/backend/src/plugins/notifications.ts | 16 +- .../src/service/router.ts | 44 +++- plugins/notifications-common/api-report.md | 7 + plugins/notifications-common/src/types.ts | 7 + plugins/notifications-node/api-report.md | 38 +++- .../migrations/20231215_init.js | 3 + .../database/DatabaseNotificationsStore.ts | 33 ++- .../src/database/NotificationsStore.ts | 13 +- .../src/service/NotificationService.ts | 21 +- plugins/notifications/api-report.md | 20 ++ plugins/notifications/package.json | 1 + .../notifications/src/api/NotificationsApi.ts | 10 +- .../src/api/NotificationsClient.ts | 41 +++- .../NotificationsPage/NotificationsPage.tsx | 21 +- .../NotificationsTable/NotificationsTable.tsx | 202 ++++++++++++++++-- yarn.lock | 10 + 16 files changed, 431 insertions(+), 56 deletions(-) diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index bd6976e91b..115d872f8d 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -22,13 +22,17 @@ export default async function createPlugin( env: PluginEnvironment, ): Promise { // TODO: Remove this test code + let notifications = 0; setInterval(() => { - env.notificationService.send( - 'user:default/guest', - 'Test', - 'This is test notification', - '/catalog', - ); + if (notifications < 10) { + env.notificationService.send({ + entityRef: 'user:default/guest', + title: 'Test', + description: 'This is test notification', + link: '/catalog', + }); + notifications++; + } }, 60000); return await createRouter({ diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 482a987638..06a702f9d1 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -70,7 +70,49 @@ export async function createRouter( res.send(status); }); - // TODO: Add endpoint to set read/unread by notification id(s) + router.post('/read', async (req, res) => { + const user = await getUser(req); + const { ids } = req.body; + if (!ids || !Array.isArray(ids)) { + res.status(400).send(); + return; + } + await store.markRead({ user_ref: user, ids }); + res.status(200).send({ ids }); + }); + + router.post('/unread', async (req, res) => { + const user = await getUser(req); + const { ids } = req.body; + if (!ids || !Array.isArray(ids)) { + res.status(400).send(); + return; + } + await store.markUnread({ user_ref: user, ids }); + res.status(200).send({ ids }); + }); + + router.post('/save', async (req, res) => { + const user = await getUser(req); + const { ids } = req.body; + if (!ids || !Array.isArray(ids)) { + res.status(400).send(); + return; + } + await store.markSaved({ user_ref: user, ids }); + res.status(200).send({ ids }); + }); + + router.post('/unsave', async (req, res) => { + const user = await getUser(req); + const { ids } = req.body; + if (!ids || !Array.isArray(ids)) { + res.status(400).send(); + return; + } + await store.markUnsaved({ user_ref: user, ids }); + res.status(200).send({ ids }); + }); router.use(errorHandler()); return router; diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index f8c5a009bc..b33edf3a80 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -11,11 +11,18 @@ type Notification_2 = { description: string; link: string; icon?: string; + image?: string; created: Date; read?: Date; + saved: boolean; }; export { Notification_2 as Notification }; +// @public (undocumented) +export type NotificationIds = { + ids: string[]; +}; + // @public (undocumented) export type NotificationStatus = { unread: number; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index d95ad92508..39dab24681 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -26,8 +26,10 @@ export type Notification = { link: string; // TODO: Icon should be typed so that we know what to render icon?: string; + image?: string; created: Date; read?: Date; + saved: boolean; }; /** @public */ @@ -35,3 +37,8 @@ export type NotificationStatus = { unread: number; read: number; }; + +/** @public */ +export type NotificationIds = { + ids: string[]; +}; diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index c7b6383c6f..31a2bbf3da 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -28,6 +28,14 @@ export class DatabaseNotificationsStore implements NotificationsStore { read: number; }>; // (undocumented) + markRead(options: NotificationModifyOptions): Promise; + // (undocumented) + markSaved(options: NotificationModifyOptions): Promise; + // (undocumented) + markUnread(options: NotificationModifyOptions): Promise; + // (undocumented) + markUnsaved(options: NotificationModifyOptions): Promise; + // (undocumented) saveNotification(notification: Notification_2): Promise; } @@ -37,6 +45,21 @@ export type NotificationGetOptions = { type?: NotificationType; }; +// @public (undocumented) +export type NotificationModifyOptions = { + ids: string[]; +} & NotificationGetOptions; + +// @public (undocumented) +export type NotificationSendOptions = { + entityRef: string | string[]; + title: string; + description: string; + link: string; + image?: string; + icon?: string; +}; + // @public (undocumented) export class NotificationService { // (undocumented) @@ -47,12 +70,7 @@ export class NotificationService { // (undocumented) getStore(): Promise; // (undocumented) - send( - entityRef: string | string[], - title: string, - description: string, - link: string, - ): Promise; + send(options: NotificationSendOptions): Promise; } // @public (undocumented) @@ -71,6 +89,14 @@ export interface NotificationsStore { // (undocumented) getStatus(options: NotificationGetOptions): Promise; // (undocumented) + markRead(options: NotificationModifyOptions): Promise; + // (undocumented) + markSaved(options: NotificationModifyOptions): Promise; + // (undocumented) + markUnread(options: NotificationModifyOptions): Promise; + // (undocumented) + markUnsaved(options: NotificationModifyOptions): Promise; + // (undocumented) saveNotification(notification: Notification_2): Promise; } ``` diff --git a/plugins/notifications-node/migrations/20231215_init.js b/plugins/notifications-node/migrations/20231215_init.js index a76a8c3cbd..e65708a048 100644 --- a/plugins/notifications-node/migrations/20231215_init.js +++ b/plugins/notifications-node/migrations/20231215_init.js @@ -21,8 +21,11 @@ exports.up = async function up(knex) { table.string('title').notNullable(); table.text('description').notNullable(); table.text('link').notNullable(); + table.text('icon').nullable(); + table.text('image').nullable(); table.datetime('created').notNullable(); table.datetime('read').nullable(); + table.boolean('saved').defaultTo(false).notNullable(); }); }; diff --git a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts index 63f9685e85..836bb50022 100644 --- a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts @@ -19,6 +19,7 @@ import { } from '@backstage/backend-common'; import { NotificationGetOptions, + NotificationModifyOptions, NotificationsStore, } from './NotificationsStore'; import { Notification } from '@backstage/plugin-notifications-common'; @@ -55,7 +56,9 @@ export class DatabaseNotificationsStore implements NotificationsStore { return typeof val === 'string' ? Number.parseInt(val, 10) : val ?? 0; }; - private getNotificationsBaseQuery = (options: NotificationGetOptions) => { + private getNotificationsBaseQuery = ( + options: NotificationGetOptions | NotificationModifyOptions, + ) => { const { user_ref, type } = options; const query = this.db('notifications').where('userRef', user_ref); @@ -63,8 +66,14 @@ export class DatabaseNotificationsStore implements NotificationsStore { query.whereNull('read'); } else if (type === 'read') { query.whereNotNull('read'); + } else if (type === 'saved') { + query.where('saved', true); } - // TODO: Saved + + if ('ids' in options && options.ids) { + query.whereIn('id', options.ids); + } + return query; }; @@ -96,4 +105,24 @@ export class DatabaseNotificationsStore implements NotificationsStore { read: this.mapToInteger((readQuery as any)?.READ), }; } + + async markRead(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ read: new Date() }); + } + + async markUnread(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ read: null }); + } + + async markSaved(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ saved: true }); + } + + async markUnsaved(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ saved: false }); + } } diff --git a/plugins/notifications-node/src/database/NotificationsStore.ts b/plugins/notifications-node/src/database/NotificationsStore.ts index b25d32900a..12397621df 100644 --- a/plugins/notifications-node/src/database/NotificationsStore.ts +++ b/plugins/notifications-node/src/database/NotificationsStore.ts @@ -26,6 +26,11 @@ export type NotificationGetOptions = { type?: NotificationType; }; +/** @public */ +export type NotificationModifyOptions = { + ids: string[]; +} & NotificationGetOptions; + /** @public */ export interface NotificationsStore { getNotifications(options: NotificationGetOptions): Promise; @@ -34,5 +39,11 @@ export interface NotificationsStore { getStatus(options: NotificationGetOptions): Promise; - // TODO: Mark as read/unread by notification id(s) + markRead(options: NotificationModifyOptions): Promise; + + markUnread(options: NotificationModifyOptions): Promise; + + markSaved(options: NotificationModifyOptions): Promise; + + markUnsaved(options: NotificationModifyOptions): Promise; } diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts index 55dcff546f..501f799d31 100644 --- a/plugins/notifications-node/src/service/NotificationService.ts +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -36,6 +36,16 @@ export type NotificationServiceOptions = { discovery: PluginEndpointDiscovery; }; +/** @public */ +export type NotificationSendOptions = { + entityRef: string | string[]; + title: string; + description: string; + link: string; + image?: string; + icon?: string; +}; + /** @public */ export class NotificationService { private store: NotificationsStore | null = null; @@ -56,12 +66,8 @@ export class NotificationService { return new NotificationService(database, catalogClient); } - async send( - entityRef: string | string[], - title: string, - description: string, - link: string, - ): Promise { + async send(options: NotificationSendOptions): Promise { + const { entityRef, title, description, link, icon, image } = options; const users = await this.getUsersForEntityRef(entityRef); const notifications = []; const store = await this.getStore(); @@ -73,6 +79,9 @@ export class NotificationService { description, link, created: new Date(), + icon, + image, + saved: false, }; await store.saveNotification(notification); diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index 9039f688eb..b4cb4c6c3e 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -11,6 +11,7 @@ import { DiscoveryApi } from '@backstage/core-plugin-api'; import { FetchApi } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { NotificationIds } from '@backstage/plugin-notifications-common'; import { NotificationStatus } from '@backstage/plugin-notifications-common'; import { NotificationType } from '@backstage/plugin-notifications-common'; import { default as React_2 } from 'react'; @@ -29,6 +30,14 @@ export interface NotificationsApi { ): Promise; // (undocumented) getStatus(): Promise; + // (undocumented) + markRead(ids: string[]): Promise; + // (undocumented) + markSaved(ids: string[]): Promise; + // (undocumented) + markUnread(ids: string[]): Promise; + // (undocumented) + markUnsaved(ids: string[]): Promise; } // @public (undocumented) @@ -43,6 +52,14 @@ export class NotificationsClient implements NotificationsApi { ): Promise; // (undocumented) getStatus(): Promise; + // (undocumented) + markRead(ids: string[]): Promise; + // (undocumented) + markSaved(ids: string[]): Promise; + // (undocumented) + markUnread(ids: string[]): Promise; + // (undocumented) + markUnsaved(ids: string[]): Promise; } // @public (undocumented) @@ -61,6 +78,9 @@ export const NotificationsSidebarItem: () => React_2.JSX.Element; // @public (undocumented) export const NotificationsTable: (props: { + onUpdate: () => void; + type: NotificationType; + loading?: boolean; notifications?: Notification_2[]; }) => React_2.JSX.Element; diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index 9e78fc32de..5d7abcf148 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -31,6 +31,7 @@ "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", + "react-relative-time": "^0.0.9", "react-use": "^17.2.4" }, "peerDependencies": { diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts index aa4b460cb3..276bc02ded 100644 --- a/plugins/notifications/src/api/NotificationsApi.ts +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -16,6 +16,7 @@ import { createApiRef } from '@backstage/core-plugin-api'; import { Notification, + NotificationIds, NotificationStatus, NotificationType, } from '@backstage/plugin-notifications-common'; @@ -36,6 +37,11 @@ export interface NotificationsApi { getStatus(): Promise; - // TODO: Mark as read/unread by notification id(s) - // TODO: Mark as saved/unsaved by notification id(s) + markRead(ids: string[]): Promise; + + markUnread(ids: string[]): Promise; + + markSaved(ids: string[]): Promise; + + markUnsaved(ids: string[]): Promise; } diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index ec6472c37e..113aefddca 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -18,6 +18,7 @@ import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { Notification, + NotificationIds, NotificationStatus, } from '@backstage/plugin-notifications-common'; @@ -44,18 +45,50 @@ export class NotificationsClient implements NotificationsApi { const urlSegment = `notifications?${queryString}`; - return await this.get(urlSegment); + return await this.request(urlSegment); } async getStatus(): Promise { - return await this.get('status'); + return await this.request('status'); } - private async get(path: string): Promise { + async markRead(ids: string[]): Promise { + return await this.request('read', { + method: 'POST', + body: JSON.stringify({ ids: ids }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + async markUnread(ids: string[]): Promise { + return await this.request('unread', { + method: 'POST', + body: JSON.stringify({ ids: ids }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + async markSaved(ids: string[]): Promise { + return await this.request('save', { + method: 'POST', + body: JSON.stringify({ ids: ids }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + async markUnsaved(ids: string[]): Promise { + return await this.request('unsave', { + method: 'POST', + body: JSON.stringify({ ids: ids }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + private async request(path: string, init?: any): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('notifications')}/`; const url = new URL(path, baseUrl); - const response = await this.fetchApi.fetch(url.toString()); + const response = await this.fetchApi.fetch(url.toString(), init); if (!response.ok) { throw await ResponseError.fromResponse(response); diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index 461c39e2aa..eeaec21ef1 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -44,12 +44,14 @@ const useStyles = makeStyles(_theme => ({ export const NotificationsPage = () => { const [type, setType] = useState('unread'); - const { - loading: _loading, - error, - value, - retry: _retry, - } = useNotificationsApi(api => api.getNotifications({ type }), [type]); + const { loading, error, value, retry } = useNotificationsApi( + api => api.getNotifications({ type }), + [type], + ); + + const onUpdate = () => { + retry(); + }; const styles = useStyles(); if (error) { @@ -89,7 +91,12 @@ export const NotificationsPage = () => { - + diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx index 6e990cf3ea..9ccc76e1ec 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { + Box, + Button, IconButton, makeStyles, Table, @@ -24,20 +26,45 @@ import { Tooltip, Typography, } from '@material-ui/core'; -import { Notification } from '@backstage/plugin-notifications-common'; +import { + Notification, + NotificationType, +} from '@backstage/plugin-notifications-common'; import { useNavigate } from 'react-router-dom'; import NotificationsIcon from '@material-ui/icons/Notifications'; import Checkbox from '@material-ui/core/Checkbox'; import Check from '@material-ui/icons/Check'; import Bookmark from '@material-ui/icons/Bookmark'; +import { notificationsApiRef } from '../../api'; +import { useApi } from '@backstage/core-plugin-api'; +import Inbox from '@material-ui/icons/Inbox'; +import CloseIcon from '@material-ui/icons/Close'; +import { Skeleton } from '@material-ui/lab'; +// @ts-ignore +import RelativeTime from 'react-relative-time'; const useStyles = makeStyles(theme => ({ notificationRow: { cursor: 'pointer', + '&.hideOnHover': { + display: 'initial', + }, + '& .showOnHover': { + display: 'none', + }, '&:hover': { backgroundColor: theme.palette.linkHover, + '& .hideOnHover': { + display: 'none', + }, + '& .showOnHover': { + display: 'initial', + }, }, }, + actionButton: { + padding: '9px', + }, checkBox: { padding: '0 10px 10px 0', }, @@ -45,25 +72,102 @@ const useStyles = makeStyles(theme => ({ /** @public */ export const NotificationsTable = (props: { + onUpdate: () => void; + type: NotificationType; + loading?: boolean; notifications?: Notification[]; }) => { - const { notifications } = props; + const { notifications, type, loading } = props; const navigate = useNavigate(); const styles = useStyles(); - // TODO: Add select all - // TODO: Make mark as read work - // TODO: Check status of the notification and change to "Mark as unread" if it's already read - // TODO: Add support to save notifications (storageApi) + const [selected, setSelected] = useState([]); + const notificationsApi = useApi(notificationsApiRef); + + const onCheckBoxClick = (id: string) => { + const index = selected.indexOf(id); + if (index !== -1) { + setSelected(selected.filter(s => s !== id)); + } else { + setSelected([...selected, id]); + } + }; + + useEffect(() => { + setSelected([]); + }, [type]); + + const isChecked = (id: string) => { + return selected.indexOf(id) !== -1; + }; + + const isAllSelected = () => { + return ( + selected.length === notifications?.length && notifications.length > 0 + ); + }; + + if (loading) { + return ; + } + // TODO: Show timestamp relative time (react-relative-time npm package) // TODO: Add signals listener and refresh data on message - // TODO: Handle no notifications properly - // TODO: Handle loading notifications return ( - {notifications?.length ?? 0} notifications + {type !== 'saved' && !notifications?.length && 'No notifications'} + {type !== 'saved' && !!notifications?.length && ( + { + if (isAllSelected()) { + setSelected([]); + } else { + setSelected( + notifications ? notifications.map(n => n.id) : [], + ); + } + }} + /> + )} + {type === 'saved' && + `${notifications?.length ?? 0} saved notifications`} + {selected.length === 0 && + !!notifications?.length && + type !== 'saved' && + 'Select all'} + {selected.length > 0 && `${selected.length} selected`} + {type === 'read' && selected.length > 0 && ( + + )} + + {type === 'unread' && selected.length > 0 && ( + + )} @@ -71,7 +175,12 @@ export const NotificationsTable = (props: { return ( - + onCheckBoxClick(notification.id)} + /> {notification.icon ?? } navigate(notification.link)}> @@ -81,16 +190,67 @@ export const NotificationsTable = (props: { - - - - - - - - - - + + + + + + { + if (notification.read) { + notificationsApi + .markUnread([notification.id]) + .then(() => { + props.onUpdate(); + }); + } else { + notificationsApi + .markRead([notification.id]) + .then(() => { + props.onUpdate(); + }); + } + }} + > + {notification.read ? ( + + ) : ( + + )} + + + + { + if (notification.saved) { + notificationsApi + .markUnsaved([notification.id]) + .then(() => { + props.onUpdate(); + }); + } else { + notificationsApi + .markSaved([notification.id]) + .then(() => { + props.onUpdate(); + }); + } + }} + > + {notification.saved ? ( + + ) : ( + + )} + + + ); diff --git a/yarn.lock b/yarn.lock index 593901c68e..62519ba3a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7845,6 +7845,7 @@ __metadata: "@testing-library/react": ^12.1.3 "@testing-library/user-event": ^14.0.0 msw: ^1.0.0 + react-relative-time: ^0.0.9 react-use: ^17.2.4 peerDependencies: react: ^16.13.1 || ^17.0.0 @@ -39162,6 +39163,15 @@ __metadata: languageName: node linkType: hard +"react-relative-time@npm:^0.0.9": + version: 0.0.9 + resolution: "react-relative-time@npm:0.0.9" + peerDependencies: + react: ">=0.13.0" + checksum: 9c25887125df0eccfd4fee1edc4c99b19bef262ed5c66ac93269b63f0e1654a7c2758489d0d4b534847decc542dac23555896a7e2a0492af4c0ffbf48f1c62fe + languageName: node + linkType: hard + "react-remove-scroll-bar@npm:^2.3.3": version: 2.3.4 resolution: "react-remove-scroll-bar@npm:2.3.4" From fb8fc24df0e32aad38e89a3ed3f6296ae26328ca Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 15 Dec 2023 16:17:19 +0200 Subject: [PATCH 05/25] chore: add changeset Signed-off-by: Heikki Hellgren --- .changeset/wicked-ants-reflect.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/wicked-ants-reflect.md diff --git a/.changeset/wicked-ants-reflect.md b/.changeset/wicked-ants-reflect.md new file mode 100644 index 0000000000..4882d12a34 --- /dev/null +++ b/.changeset/wicked-ants-reflect.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-notifications-backend': patch +'@backstage/plugin-notifications-common': patch +'@backstage/plugin-notifications-node': patch +'@backstage/plugin-notifications': patch +--- + +Initial notifications system for backstage From 62141acaeebdb642a4738fb7ed286eea8d083d44 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Mon, 18 Dec 2023 08:34:51 +0200 Subject: [PATCH 06/25] feat: allow specifying mui icon for notification Signed-off-by: Heikki Hellgren --- packages/backend/src/plugins/notifications.ts | 1 + plugins/notifications-common/api-report.md | 7 +++- plugins/notifications-common/package.json | 5 ++- plugins/notifications-common/src/types.ts | 8 +++- plugins/notifications-node/api-report.md | 3 +- .../src/service/NotificationService.ts | 16 ++++++-- .../NotificationsPage/NotificationsPage.tsx | 22 ++++------ .../NotificationsTable/NotificationIcon.tsx | 41 +++++++++++++++++++ .../NotificationsTable/NotificationsTable.tsx | 24 +++++++---- yarn.lock | 1 + 10 files changed, 98 insertions(+), 30 deletions(-) create mode 100644 plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index 115d872f8d..689e42753a 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -30,6 +30,7 @@ export default async function createPlugin( title: 'Test', description: 'This is test notification', link: '/catalog', + icon: 'SwapHorizRounded', }); notifications++; } diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index b33edf3a80..9c085ff521 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import * as muiIcons from '@material-ui/icons'; + // @public (undocumented) type Notification_2 = { id: string; @@ -10,7 +12,7 @@ type Notification_2 = { title: string; description: string; link: string; - icon?: string; + icon?: NotificationIcon; image?: string; created: Date; read?: Date; @@ -18,6 +20,9 @@ type Notification_2 = { }; export { Notification_2 as Notification }; +// @public (undocumented) +export type NotificationIcon = keyof typeof muiIcons; + // @public (undocumented) export type NotificationIds = { ids: string[]; diff --git a/plugins/notifications-common/package.json b/plugins/notifications-common/package.json index 91acf171f7..cda38bd1cb 100644 --- a/plugins/notifications-common/package.json +++ b/plugins/notifications-common/package.json @@ -28,5 +28,8 @@ }, "files": [ "dist" - ] + ], + "dependencies": { + "@material-ui/icons": "^4.9.1" + } } diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index 39dab24681..f0e6feae30 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -14,9 +14,14 @@ * limitations under the License. */ +import * as muiIcons from '@material-ui/icons'; + /** @public */ export type NotificationType = 'read' | 'unread' | 'saved'; +/** @public */ +export type NotificationIcon = keyof typeof muiIcons; + /** @public */ export type Notification = { id: string; @@ -24,8 +29,7 @@ export type Notification = { title: string; description: string; link: string; - // TODO: Icon should be typed so that we know what to render - icon?: string; + icon?: NotificationIcon; image?: string; created: Date; read?: Date; diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 31a2bbf3da..4cfe35233e 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -4,6 +4,7 @@ ```ts import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { NotificationIcon } from '@backstage/plugin-notifications-common'; import { NotificationStatus } from '@backstage/plugin-notifications-common'; import { NotificationType } from '@backstage/plugin-notifications-common'; import { PluginDatabaseManager } from '@backstage/backend-common'; @@ -57,7 +58,7 @@ export type NotificationSendOptions = { description: string; link: string; image?: string; - icon?: string; + icon?: NotificationIcon; }; // @public (undocumented) diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts index 501f799d31..029df83185 100644 --- a/plugins/notifications-node/src/service/NotificationService.ts +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -13,7 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Notification } from '@backstage/plugin-notifications-common'; +import { + Notification, + NotificationIcon, +} from '@backstage/plugin-notifications-common'; import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; import { NotificationsStore } from '../database/NotificationsStore'; import { v4 as uuid } from 'uuid'; @@ -43,7 +46,7 @@ export type NotificationSendOptions = { description: string; link: string; image?: string; - icon?: string; + icon?: NotificationIcon; }; /** @public */ @@ -68,9 +71,16 @@ export class NotificationService { async send(options: NotificationSendOptions): Promise { const { entityRef, title, description, link, icon, image } = options; - const users = await this.getUsersForEntityRef(entityRef); const notifications = []; + let users = []; + try { + users = await this.getUsersForEntityRef(entityRef); + } catch (e) { + return []; + } + const store = await this.getStore(); + for (const user of users) { const notification = { id: uuid(), diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index eeaec21ef1..4ebcaa6fc4 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -22,13 +22,7 @@ import { } from '@backstage/core-components'; import { NotificationsTable } from '../NotificationsTable'; import { useNotificationsApi } from '../../hooks'; -import { - Button, - Grid, - makeStyles, - Paper, - TableContainer, -} from '@material-ui/core'; +import { Button, Grid, makeStyles } from '@material-ui/core'; import Bookmark from '@material-ui/icons/Bookmark'; import Check from '@material-ui/icons/Check'; import Inbox from '@material-ui/icons/Inbox'; @@ -90,14 +84,12 @@ export const NotificationsPage = () => { - - - + diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx new file mode 100644 index 0000000000..f5ae2e8c22 --- /dev/null +++ b/plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import NotificationsIcon from '@material-ui/icons/Notifications'; +import { Notification } from '@backstage/plugin-notifications-common'; +// eslint-disable-next-line no-restricted-imports +import * as muiIcons from '@material-ui/icons'; +import Avatar from '@material-ui/core/Avatar'; + +/** @internal */ +export const NotificationIcon = (props: { notification: Notification }) => { + const { notification } = props; + if (notification.icon && notification.icon in muiIcons) { + const Icon = muiIcons[notification.icon]; + return ; + } + + if (notification.image) { + return ( + + ); + } + + return ; +}; diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx index 9ccc76e1ec..68df969db6 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -31,7 +31,6 @@ import { NotificationType, } from '@backstage/plugin-notifications-common'; import { useNavigate } from 'react-router-dom'; -import NotificationsIcon from '@material-ui/icons/Notifications'; import Checkbox from '@material-ui/core/Checkbox'; import Check from '@material-ui/icons/Check'; import Bookmark from '@material-ui/icons/Bookmark'; @@ -42,6 +41,8 @@ import CloseIcon from '@material-ui/icons/Close'; import { Skeleton } from '@material-ui/lab'; // @ts-ignore import RelativeTime from 'react-relative-time'; +import { NotificationIcon } from './NotificationIcon'; +import ArrowForwardIcon from '@material-ui/icons/ArrowForward'; const useStyles = makeStyles(theme => ({ notificationRow: { @@ -53,7 +54,7 @@ const useStyles = makeStyles(theme => ({ display: 'none', }, '&:hover': { - backgroundColor: theme.palette.linkHover, + backgroundColor: theme.palette.background.paper, '& .hideOnHover': { display: 'none', }, @@ -110,8 +111,6 @@ export const NotificationsTable = (props: { return ; } - // TODO: Show timestamp relative time (react-relative-time npm package) - // TODO: Add signals listener and refresh data on message return (
@@ -174,16 +173,19 @@ export const NotificationsTable = (props: { {props.notifications?.map(notification => { return ( - + onCheckBoxClick(notification.id)} /> - {notification.icon ?? } + - navigate(notification.link)}> + navigate(notification.link)} + style={{ paddingLeft: 0 }} + > {notification.title} {notification.description} @@ -194,6 +196,14 @@ export const NotificationsTable = (props: { + + navigate(notification.link)} + > + + + diff --git a/yarn.lock b/yarn.lock index 62519ba3a0..3a1a6f0bbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7807,6 +7807,7 @@ __metadata: resolution: "@backstage/plugin-notifications-common@workspace:plugins/notifications-common" dependencies: "@backstage/cli": "workspace:^" + "@material-ui/icons": ^4.9.1 languageName: unknown linkType: soft From b6c1523444b95ef512662b740724ca9a5483cb84 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Mon, 18 Dec 2023 09:00:32 +0200 Subject: [PATCH 07/25] feat: add support for notification processors Signed-off-by: Heikki Hellgren --- plugins/notifications-node/api-report.md | 10 ++++ .../src/service/NotificationProcessor.ts | 34 ++++++++++++ .../src/service/NotificationService.ts | 52 +++++++++++++------ .../notifications-node/src/service/index.ts | 1 + .../NotificationsTable/NotificationsTable.tsx | 5 +- 5 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 plugins/notifications-node/src/service/NotificationProcessor.ts diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 4cfe35233e..7762d41550 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -51,6 +51,12 @@ export type NotificationModifyOptions = { ids: string[]; } & NotificationGetOptions; +// @public (undocumented) +export type NotificationProcessor = { + decorate?(notification: Notification_2): Promise; + send?(notification: Notification_2): Promise; +}; + // @public (undocumented) export type NotificationSendOptions = { entityRef: string | string[]; @@ -63,10 +69,13 @@ export type NotificationSendOptions = { // @public (undocumented) export class NotificationService { + // (undocumented) + addProcessor(processor: NotificationProcessor): this; // (undocumented) static create({ database, discovery, + processors, }: NotificationServiceOptions): NotificationService; // (undocumented) getStore(): Promise; @@ -81,6 +90,7 @@ export const notificationService: ServiceRef; export type NotificationServiceOptions = { database: PluginDatabaseManager; discovery: PluginEndpointDiscovery; + processors?: NotificationProcessor[]; }; // @public (undocumented) diff --git a/plugins/notifications-node/src/service/NotificationProcessor.ts b/plugins/notifications-node/src/service/NotificationProcessor.ts new file mode 100644 index 0000000000..57fe648f30 --- /dev/null +++ b/plugins/notifications-node/src/service/NotificationProcessor.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Notification } from '@backstage/plugin-notifications-common'; + +/** @public */ +export type NotificationProcessor = { + /** + * Decorate notification before sending it + * + * @param notification - The notification to decorate + * @returns The same notification or a modified version of it + */ + decorate?(notification: Notification): Promise; + + /** + * Send notification using this processor. + * + * @param notification - The notification to send + */ + send?(notification: Notification): Promise; +}; diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts index 029df83185..288da01dc4 100644 --- a/plugins/notifications-node/src/service/NotificationService.ts +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -32,11 +32,13 @@ import { PluginEndpointDiscovery, } from '@backstage/backend-common'; import { DatabaseNotificationsStore } from '../database'; +import { NotificationProcessor } from './NotificationProcessor'; /** @public */ export type NotificationServiceOptions = { database: PluginDatabaseManager; discovery: PluginEndpointDiscovery; + processors?: NotificationProcessor[]; }; /** @public */ @@ -52,21 +54,31 @@ export type NotificationSendOptions = { /** @public */ export class NotificationService { private store: NotificationsStore | null = null; + private readonly processors: NotificationProcessor[]; private constructor( private readonly database: PluginDatabaseManager, private readonly catalog: CatalogApi, - ) {} + processors?: NotificationProcessor[], + ) { + this.processors = processors ?? []; + } static create({ database, discovery, + processors, }: NotificationServiceOptions): NotificationService { const catalogClient = new CatalogClient({ discoveryApi: discovery, }); - return new NotificationService(database, catalogClient); + return new NotificationService(database, catalogClient, processors); + } + + addProcessor(processor: NotificationProcessor) { + this.processors.push(processor); + return this; } async send(options: NotificationSendOptions): Promise { @@ -80,27 +92,35 @@ export class NotificationService { } const store = await this.getStore(); + const baseNotification = { + id: uuid(), + title, + description, + link, + created: new Date(), + icon, + image, + saved: false, + }; for (const user of users) { - const notification = { - id: uuid(), - userRef: user, - title, - description, - link, - created: new Date(), - icon, - image, - saved: false, - }; + let notification: Notification = { ...baseNotification, userRef: user }; + for (const processor of this.processors) { + notification = processor.decorate + ? await processor.decorate(notification) + : notification; + } await store.saveNotification(notification); + for (const processor of this.processors) { + if (processor.send) { + processor.send(notification); + } + } notifications.push(notification); + // TODO: Signal service } - // TODO: Signal service - // TODO: Other senders - return notifications; } diff --git a/plugins/notifications-node/src/service/index.ts b/plugins/notifications-node/src/service/index.ts index 450f4f7dcc..718e9a5567 100644 --- a/plugins/notifications-node/src/service/index.ts +++ b/plugins/notifications-node/src/service/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './NotificationService'; +export * from './NotificationProcessor'; diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx index 68df969db6..acdb2b8b59 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -173,7 +173,10 @@ export const NotificationsTable = (props: { {props.notifications?.map(notification => { return ( - + Date: Mon, 18 Dec 2023 09:18:17 +0200 Subject: [PATCH 08/25] docs: add installation documentation for notifications Signed-off-by: Heikki Hellgren --- plugins/notifications-backend/README.md | 39 ++++++++++++++---- plugins/notifications-node/README.md | 55 ++++++++++++++++++++++++- plugins/notifications/README.md | 36 ++++++++++++++-- 3 files changed, 118 insertions(+), 12 deletions(-) diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md index e90d287049..bb965b6695 100644 --- a/plugins/notifications-backend/README.md +++ b/plugins/notifications-backend/README.md @@ -2,13 +2,38 @@ Welcome to the notifications backend plugin! -_This plugin was created through the Backstage CLI_ - ## Getting started -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn -start` in the root directory, and then navigating to [/notifications](http://localhost:3000/notifications). +First you have to install the `@backstage/plugin-notifications-node` package. -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. +Then create a new file to `packages/backend/src/plugins/notifications.ts`: + +```ts +import { createRouter } from '@backstage/plugin-notifications-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + identity: env.identity, + notificationService: env.notificationService, + }); +} +``` + +and add it to `packages/backend/src/index.ts`: + +```ts +async function main() { + //... + const notificationsEnv = useHotMemoize(module, () => + createEnv('notifications'), + ); + + // ... + apiRouter.use('/notifications', await notifications(notificationsEnv)); +} +``` diff --git a/plugins/notifications-node/README.md b/plugins/notifications-node/README.md index 4cf460c3fe..2a1e61638d 100644 --- a/plugins/notifications-node/README.md +++ b/plugins/notifications-node/README.md @@ -2,4 +2,57 @@ Welcome to the Node.js library package for the notifications plugin! -_This plugin was created through the Backstage CLI_ +## Getting Started + +To be able to send notifications from other backend plugins, the `NotificationService` must be initialized for the +environment. This can be done by adding the following changes to `packages/backend/src/index.ts` and +`packages/backend/src/types.ts`: + +`index.ts`: + +```ts +import { NotificationService } from '@backstage/plugin-notifications-node'; + +function makeCreateEnv(config: Config) { + // ... + const notificationService = NotificationService.create({ + database: databaseManager.forPlugin('notifications'), + discovery, + }); + // ... + return (plugin: string): PluginEnvironment => { + // ... + return { + // ... + notificationService, + }; + }; +} +``` + +`types.ts`: + +```ts +import { NotificationService } from '@backstage/plugin-notifications-node'; +export type PluginEnvironment = { + // ... + notificationService: NotificationService; +}; +``` + +You also need to set up the `@backstage/plugin-notifications-backend` and `@backstage/plugin-notifications` +to be able to show notifications in the UI. + +## Sending notifications + +To send notifications from backend plugin, use the `NotificationService::send` functionality. This function will +save the notification and optionally signal the frontend to show the latest status for users. + +When sending notifications, you can specify the entity reference of the notification. If the entity reference is +a user, the notification will be sent to only that user. If it's a group, the notification will be sent to all +members of the group. If it's some other entity, the notification will be sent to the owner of that entity. + +## Extending Notification Service + +The `NotificationService` can be extended with `NotificationProcessor`. These processors allow to decorate notifications +before they are sent or/and send the notifications to external services. diff --git a/plugins/notifications/README.md b/plugins/notifications/README.md index 3eeac7a647..63212d2c16 100644 --- a/plugins/notifications/README.md +++ b/plugins/notifications/README.md @@ -6,8 +6,36 @@ _This plugin was created through the Backstage CLI_ ## Getting started -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/notifications](http://localhost:3000/notifications). +First, install the `@backstage/plugin-notifications-backend` and `@backstage/plugin-notifications-node` packages. +See the documentation for installation instructions. -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. +To add the notifications main menu, add the following to your `packages/app/src/components/Root/Root.tsx`: + +```tsx +import { NotificationsSidebarItem } from '@backstage/plugin-notifications'; + + + + + // ... + + + +; +``` + +Also add the route to notifications to `packages/app/src/App.tsx`: + +```tsx +import { NotificationsPage } from '@backstage/plugin-notifications'; + + + // ... + } /> +; +``` + +## Real-time notifications + +To be able to get real-time notifications to the UI without need for the user to refresh the page, you also need to +add `@backstage/plugin-signals` package to your installation. From 13346970b4838c999f9f3095f70e68b588f3daa1 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Mon, 18 Dec 2023 09:26:14 +0200 Subject: [PATCH 09/25] chore: remove test code from notifications Signed-off-by: Heikki Hellgren --- packages/app/package.json | 2 +- packages/backend/package.json | 2 +- packages/backend/src/plugins/notifications.ts | 15 --------------- plugins/notifications-backend/catalog-info.yaml | 9 +++++++++ plugins/notifications-common/catalog-info.yaml | 10 ++++++++++ plugins/notifications-node/catalog-info.yaml | 10 ++++++++++ plugins/notifications/catalog-info.yaml | 9 +++++++++ plugins/notifications/package.json | 1 + yarn.lock | 9 +++++---- 9 files changed, 46 insertions(+), 21 deletions(-) create mode 100644 plugins/notifications-backend/catalog-info.yaml create mode 100644 plugins/notifications-common/catalog-info.yaml create mode 100644 plugins/notifications-node/catalog-info.yaml create mode 100644 plugins/notifications/catalog-info.yaml diff --git a/packages/app/package.json b/packages/app/package.json index b960712550..43b12a1cb7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -59,7 +59,7 @@ "@backstage/plugin-newrelic": "workspace:^", "@backstage/plugin-newrelic-dashboard": "workspace:^", "@backstage/plugin-nomad": "workspace:^", - "@backstage/plugin-notifications": "^0.0.0", + "@backstage/plugin-notifications": "workspace:^", "@backstage/plugin-octopus-deploy": "workspace:^", "@backstage/plugin-org": "workspace:^", "@backstage/plugin-pagerduty": "workspace:^", diff --git a/packages/backend/package.json b/packages/backend/package.json index 1606158a7e..bb0b0e0ed7 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -55,7 +55,7 @@ "@backstage/plugin-lighthouse-backend": "workspace:^", "@backstage/plugin-linguist-backend": "workspace:^", "@backstage/plugin-nomad-backend": "workspace:^", - "@backstage/plugin-notifications-backend": "^0.0.0", + "@backstage/plugin-notifications-backend": "workspace:^", "@backstage/plugin-notifications-node": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index 689e42753a..6bef7f0a01 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -21,21 +21,6 @@ import { PluginEnvironment } from '../types'; export default async function createPlugin( env: PluginEnvironment, ): Promise { - // TODO: Remove this test code - let notifications = 0; - setInterval(() => { - if (notifications < 10) { - env.notificationService.send({ - entityRef: 'user:default/guest', - title: 'Test', - description: 'This is test notification', - link: '/catalog', - icon: 'SwapHorizRounded', - }); - notifications++; - } - }, 60000); - return await createRouter({ logger: env.logger, identity: env.identity, diff --git a/plugins/notifications-backend/catalog-info.yaml b/plugins/notifications-backend/catalog-info.yaml new file mode 100644 index 0000000000..66b39c39df --- /dev/null +++ b/plugins/notifications-backend/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-notifications-backend + title: '@backstage/plugin-notifications-backend' +spec: + lifecycle: experimental + type: backstage-backend-plugin + owner: maintainers diff --git a/plugins/notifications-common/catalog-info.yaml b/plugins/notifications-common/catalog-info.yaml new file mode 100644 index 0000000000..5a888d2372 --- /dev/null +++ b/plugins/notifications-common/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-notifications-common + title: '@backstage/plugin-notifications-common' + description: Common functionalities for the notifications plugin +spec: + lifecycle: experimental + type: backstage-common-library + owner: maintainers diff --git a/plugins/notifications-node/catalog-info.yaml b/plugins/notifications-node/catalog-info.yaml new file mode 100644 index 0000000000..cf25f049c9 --- /dev/null +++ b/plugins/notifications-node/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-notifications-node + title: '@backstage/plugin-notifications-node' + description: Node.js library for the notifications plugin +spec: + lifecycle: experimental + type: backstage-node-library + owner: maintainers diff --git a/plugins/notifications/catalog-info.yaml b/plugins/notifications/catalog-info.yaml new file mode 100644 index 0000000000..33d39798e7 --- /dev/null +++ b/plugins/notifications/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-notifications + title: '@backstage/plugin-notifications' +spec: + lifecycle: experimental + type: backstage-frontend-plugin + owner: maintainers diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index 5d7abcf148..80804a68c4 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -31,6 +31,7 @@ "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", + "@types/react": "^16.13.1 || ^17.0.0", "react-relative-time": "^0.0.9", "react-use": "^17.2.4" }, diff --git a/yarn.lock b/yarn.lock index 3a1a6f0bbf..fe739b0049 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7779,7 +7779,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-notifications-backend@^0.0.0, @backstage/plugin-notifications-backend@workspace:plugins/notifications-backend": +"@backstage/plugin-notifications-backend@workspace:^, @backstage/plugin-notifications-backend@workspace:plugins/notifications-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-notifications-backend@workspace:plugins/notifications-backend" dependencies: @@ -7826,7 +7826,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-notifications@^0.0.0, @backstage/plugin-notifications@workspace:plugins/notifications": +"@backstage/plugin-notifications@workspace:^, @backstage/plugin-notifications@workspace:plugins/notifications": version: 0.0.0-use.local resolution: "@backstage/plugin-notifications@workspace:plugins/notifications" dependencies: @@ -7845,6 +7845,7 @@ __metadata: "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 "@testing-library/user-event": ^14.0.0 + "@types/react": ^16.13.1 || ^17.0.0 msw: ^1.0.0 react-relative-time: ^0.0.9 react-use: ^17.2.4 @@ -27136,7 +27137,7 @@ __metadata: "@backstage/plugin-newrelic": "workspace:^" "@backstage/plugin-newrelic-dashboard": "workspace:^" "@backstage/plugin-nomad": "workspace:^" - "@backstage/plugin-notifications": ^0.0.0 + "@backstage/plugin-notifications": "workspace:^" "@backstage/plugin-octopus-deploy": "workspace:^" "@backstage/plugin-org": "workspace:^" "@backstage/plugin-pagerduty": "workspace:^" @@ -27274,7 +27275,7 @@ __metadata: "@backstage/plugin-lighthouse-backend": "workspace:^" "@backstage/plugin-linguist-backend": "workspace:^" "@backstage/plugin-nomad-backend": "workspace:^" - "@backstage/plugin-notifications-backend": ^0.0.0 + "@backstage/plugin-notifications-backend": "workspace:^" "@backstage/plugin-notifications-node": "workspace:^" "@backstage/plugin-permission-backend": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" From 1a38100a8e0f15fcc62750d5670aa596d52a9b99 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 12 Jan 2024 15:39:03 +0200 Subject: [PATCH 10/25] chore: refactor service and backend to use REST Signed-off-by: Heikki Hellgren --- packages/backend/src/index.ts | 7 +- packages/backend/src/plugins/notifications.ts | 13 +- plugins/notifications-backend/README.md | 9 +- plugins/notifications-backend/api-report.md | 26 ++- .../migrations/20231215_init.js | 0 plugins/notifications-backend/package.json | 6 + .../database/DatabaseNotificationsStore.ts | 2 +- .../src/database/NotificationsStore.ts | 0 .../src/database/index.ts | 0 plugins/notifications-backend/src/index.ts | 1 + plugins/notifications-backend/src/plugin.ts | 21 ++- .../src/service/router.test.ts | 37 +++- .../src/service/router.ts | 158 ++++++++++++++++- .../src/service/standaloneServer.ts | 48 +++-- .../src/types.ts} | 0 plugins/notifications-common/api-report.md | 7 - plugins/notifications-common/src/types.ts | 7 - plugins/notifications-node/README.md | 11 +- plugins/notifications-node/api-report.md | 91 ++-------- plugins/notifications-node/src/index.ts | 1 - plugins/notifications-node/src/lib.ts | 14 +- .../src/service/DefaultNotificationService.ts | 73 ++++++++ .../src/service/NotificationService.ts | 164 +----------------- .../notifications-node/src/service/index.ts | 4 +- .../NotificationsTable/NotificationIcon.tsx | 41 ----- .../NotificationsTable/NotificationsTable.tsx | 19 +- yarn.lock | 6 + 27 files changed, 407 insertions(+), 359 deletions(-) rename plugins/{notifications-node => notifications-backend}/migrations/20231215_init.js (100%) rename plugins/{notifications-node => notifications-backend}/src/database/DatabaseNotificationsStore.ts (98%) rename plugins/{notifications-node => notifications-backend}/src/database/NotificationsStore.ts (100%) rename plugins/{notifications-node => notifications-backend}/src/database/index.ts (100%) rename plugins/{notifications-node/src/service/NotificationProcessor.ts => notifications-backend/src/types.ts} (100%) create mode 100644 plugins/notifications-node/src/service/DefaultNotificationService.ts delete mode 100644 plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index fd29c4a8c7..c60d3d8feb 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -75,7 +75,7 @@ import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { metrics } from '@opentelemetry/api'; import { DefaultSignalService } from '@backstage/plugin-signals-node'; -import { NotificationService } from '@backstage/plugin-notifications-node'; +import { DefaultNotificationService } from '@backstage/plugin-notifications-node'; // Expose opentelemetry metrics using a Prometheus exporter on // http://localhost:9464/metrics . See prometheus.yml in packages/backend for @@ -105,9 +105,10 @@ function makeCreateEnv(config: Config) { const signalService = DefaultSignalService.create({ eventBroker, }); - const notificationService = NotificationService.create({ - database: databaseManager.forPlugin('notifications'), + const notificationService = DefaultNotificationService.create({ + logger: root.child({ type: 'plugin' }), discovery, + tokenManager, }); root.info(`Created UrlReader ${reader}`); diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index 6bef7f0a01..583495cba5 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -21,9 +21,20 @@ import { PluginEnvironment } from '../types'; export default async function createPlugin( env: PluginEnvironment, ): Promise { + setInterval(() => { + env.notificationService.send({ + entityRef: 'user:default/guest', + title: 'Test', + description: 'Test', + link: '/catalog', + }); + }, 60000); + return await createRouter({ logger: env.logger, identity: env.identity, - notificationService: env.notificationService, + tokenManager: env.tokenManager, + database: env.database, + discovery: env.discovery, }); } diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md index bb965b6695..da1d64cf6d 100644 --- a/plugins/notifications-backend/README.md +++ b/plugins/notifications-backend/README.md @@ -19,7 +19,9 @@ export default async function createPlugin( return await createRouter({ logger: env.logger, identity: env.identity, - notificationService: env.notificationService, + tokenManager: env.tokenManager, + database: env.database, + discovery: env.discovery, }); } ``` @@ -37,3 +39,8 @@ async function main() { apiRouter.use('/notifications', await notifications(notificationsEnv)); } ``` + +## Extending Notifications + +The notifications can be extended with `NotificationProcessor`. These processors allow to decorate notifications +before they are sent or/and send the notifications to external services. diff --git a/plugins/notifications-backend/api-report.md b/plugins/notifications-backend/api-report.md index 02e86293f9..b10bc65e8c 100644 --- a/plugins/notifications-backend/api-report.md +++ b/plugins/notifications-backend/api-report.md @@ -4,25 +4,43 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CatalogApi } from '@backstage/catalog-client'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; import express from 'express'; import { IdentityApi } from '@backstage/plugin-auth-node'; -import { Logger } from 'winston'; -import { NotificationService } from '@backstage/plugin-notifications-node'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; +// @public (undocumented) +export type NotificationProcessor = { + decorate?(notification: Notification_2): Promise; + send?(notification: Notification_2): Promise; +}; + // @public export const notificationsPlugin: () => BackendFeature; // @public (undocumented) export interface RouterOptions { + // (undocumented) + catalog?: CatalogApi; + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + discovery: DiscoveryApi; // (undocumented) identity: IdentityApi; // (undocumented) - logger: Logger; + logger: LoggerService; // (undocumented) - notificationService: NotificationService; + processors?: NotificationProcessor[]; + // (undocumented) + tokenManager: TokenManager; } // (No @packageDocumentation comment for this package) diff --git a/plugins/notifications-node/migrations/20231215_init.js b/plugins/notifications-backend/migrations/20231215_init.js similarity index 100% rename from plugins/notifications-node/migrations/20231215_init.js rename to plugins/notifications-backend/migrations/20231215_init.js diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index 1ee25ec127..8d56034b47 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -24,14 +24,20 @@ "dependencies": { "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-client": "workspace:^", + "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", + "@backstage/plugin-notifications-common": "workspace:^", "@backstage/plugin-notifications-node": "workspace:^", "@types/express": "*", "express": "^4.17.1", "express-promise-router": "^4.1.0", "knex": "^3.0.0", "node-fetch": "^2.6.7", + "uuid": "^8.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts similarity index 98% rename from plugins/notifications-node/src/database/DatabaseNotificationsStore.ts rename to plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index 836bb50022..b0ab25f2f9 100644 --- a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -26,7 +26,7 @@ import { Notification } from '@backstage/plugin-notifications-common'; import { Knex } from 'knex'; const migrationsDir = resolvePackagePath( - '@backstage/plugin-notifications-node', + '@backstage/plugin-notifications-backend', 'migrations', ); diff --git a/plugins/notifications-node/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts similarity index 100% rename from plugins/notifications-node/src/database/NotificationsStore.ts rename to plugins/notifications-backend/src/database/NotificationsStore.ts diff --git a/plugins/notifications-node/src/database/index.ts b/plugins/notifications-backend/src/database/index.ts similarity index 100% rename from plugins/notifications-node/src/database/index.ts rename to plugins/notifications-backend/src/database/index.ts diff --git a/plugins/notifications-backend/src/index.ts b/plugins/notifications-backend/src/index.ts index e3a02aca06..44891496fd 100644 --- a/plugins/notifications-backend/src/index.ts +++ b/plugins/notifications-backend/src/index.ts @@ -15,3 +15,4 @@ */ export * from './service/router'; export * from './plugin'; +export type { NotificationProcessor } from './types'; diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts index d125332c4c..6ab3c464db 100644 --- a/plugins/notifications-backend/src/plugin.ts +++ b/plugins/notifications-backend/src/plugin.ts @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { loggerToWinstonLogger } from '@backstage/backend-common'; import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; -import { notificationService } from '@backstage/plugin-notifications-node'; /** * Notifications backend plugin @@ -34,14 +32,25 @@ export const notificationsPlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, logger: coreServices.logger, identity: coreServices.identity, - service: notificationService, + database: coreServices.database, + tokenManager: coreServices.tokenManager, + discovery: coreServices.discovery, }, - async init({ httpRouter, logger, identity, service }) { + async init({ + httpRouter, + logger, + identity, + database, + tokenManager, + discovery, + }) { httpRouter.use( await createRouter({ - logger: loggerToWinstonLogger(logger), + logger, identity, - notificationService: service, + database, + tokenManager, + discovery, }), ); }, diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index c0f09b3ef9..3ad9b4c531 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -13,13 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { + DatabaseManager, + getVoidLogger, + PluginDatabaseManager, + PluginEndpointDiscovery, + TokenManager, +} from '@backstage/backend-common'; import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; import { IdentityApi } from '@backstage/plugin-auth-node'; -import { NotificationService } from '@backstage/plugin-notifications-node'; +import { ConfigReader } from '@backstage/config'; + +function createDatabase(): PluginDatabaseManager { + return DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('notifications'); +} describe('createRouter', () => { let app: express.Express; @@ -36,17 +55,23 @@ describe('createRouter', () => { }; }, }; + const mockedTokenManager: jest.Mocked = { + getToken: jest.fn(), + authenticate: jest.fn(), + }; - const notificationServiceMock: jest.Mocked> = { - getStore: jest.fn(), - send: jest.fn(), + const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), }; beforeAll(async () => { const router = await createRouter({ logger: getVoidLogger(), identity: identityMock, - notificationService: notificationServiceMock as NotificationService, + database: createDatabase(), + tokenManager: mockedTokenManager, + discovery, }); app = express().use(router); }); diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 06a702f9d1..af5a5a826f 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -13,36 +13,125 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { errorHandler } from '@backstage/backend-common'; +import { + errorHandler, + PluginDatabaseManager, + TokenManager, +} from '@backstage/backend-common'; import express, { Request } from 'express'; import Router from 'express-promise-router'; -import { Logger } from 'winston'; import { + getBearerTokenFromAuthorizationHeader, + IdentityApi, +} from '@backstage/plugin-auth-node'; +import { + DatabaseNotificationsStore, NotificationGetOptions, - NotificationService, -} from '@backstage/plugin-notifications-node'; -import { IdentityApi } from '@backstage/plugin-auth-node'; +} from '../database'; +import { v4 as uuid } from 'uuid'; +import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; +import { + Entity, + isGroupEntity, + isUserEntity, + RELATION_HAS_MEMBER, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { NotificationProcessor } from '../types'; +import { AuthenticationError } from '@backstage/errors'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { LoggerService } from '@backstage/backend-plugin-api'; /** @public */ export interface RouterOptions { - logger: Logger; + logger: LoggerService; identity: IdentityApi; - notificationService: NotificationService; + database: PluginDatabaseManager; + tokenManager: TokenManager; + discovery: DiscoveryApi; + catalog?: CatalogApi; + processors?: NotificationProcessor[]; } /** @public */ export async function createRouter( options: RouterOptions, ): Promise { - const { logger, notificationService, identity } = options; + const { + logger, + database, + identity, + discovery, + catalog, + tokenManager, + processors, + } = options; - const store = await notificationService.getStore(); + const catalogClient = + catalog ?? new CatalogClient({ discoveryApi: discovery }); + const store = await DatabaseNotificationsStore.create({ database }); const getUser = async (req: Request) => { const user = await identity.getIdentity({ request: req }); return user ? user.identity.userEntityRef : 'user:default/guest'; }; + const authenticateService = async (req: Request) => { + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + if (!token) { + throw new AuthenticationError(); + } + await tokenManager.authenticate(token); + }; + + const getUsersForEntityRef = async ( + entityRef: string | string[], + ): Promise => { + const refs = Array.isArray(entityRef) ? entityRef : [entityRef]; + const { token } = await tokenManager.getToken(); + const entities = await catalogClient.getEntitiesByRefs( + { + entityRefs: refs, + }, + { token }, + ); + const mapEntity = async (entity: Entity | undefined): Promise => { + if (!entity) { + return []; + } + + if (isUserEntity(entity)) { + return [stringifyEntityRef(entity)]; + } else if (isGroupEntity(entity) && entity.relations) { + return entity.relations + .filter( + relation => + relation.type === RELATION_HAS_MEMBER && relation.targetRef, + ) + .map(r => r.targetRef); + } else if (!isGroupEntity(entity) && entity.spec?.owner) { + const owner = await catalogClient.getEntityByRef( + entity.spec.owner as string, + { token }, + ); + if (owner) { + return mapEntity(owner); + } + } + + return []; + }; + + const users: string[] = []; + for (const entity of entities.items) { + const u = await mapEntity(entity); + users.push(...u); + } + return users; + }; + const router = Router(); router.use(express.json()); @@ -114,6 +203,57 @@ export async function createRouter( res.status(200).send({ ids }); }); + router.post('/notifications', async (req, res) => { + const { entityRef, title, description, link } = req.body; + const notifications = []; + let users = []; + + try { + await authenticateService(req); + } catch (e) { + logger.error(`Failed to authenticate notification request ${e}`); + res.status(401).send(); + return; + } + + try { + users = await getUsersForEntityRef(entityRef); + } catch (e) { + logger.error(`Failed to resolve notification receiver ${e}`); + res.status(400).send(); + return; + } + + const baseNotification = { + id: uuid(), + title, + description, + link, + created: new Date(), + saved: false, + }; + + for (const user of users) { + let notification = { ...baseNotification, userRef: user }; + for (const processor of processors ?? []) { + notification = processor.decorate + ? await processor.decorate(notification) + : notification; + } + + await store.saveNotification(notification); + for (const processor of processors ?? []) { + if (processor.send) { + processor.send(notification); + } + } + notifications.push(notification); + // TODO: Signal service + } + + res.send(notifications); + }); + router.use(errorHandler()); return router; } diff --git a/plugins/notifications-backend/src/service/standaloneServer.ts b/plugins/notifications-backend/src/service/standaloneServer.ts index c19ae01b74..4a8ba80f72 100644 --- a/plugins/notifications-backend/src/service/standaloneServer.ts +++ b/plugins/notifications-backend/src/service/standaloneServer.ts @@ -15,9 +15,10 @@ */ import { createServiceBuilder, + HostDiscovery, loadBackendConfig, PluginDatabaseManager, - PluginEndpointDiscovery, + ServerTokenManager, } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -25,7 +26,11 @@ import { createRouter } from './router'; import Knex from 'knex'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { Request } from 'express'; -import { NotificationService } from '@backstage/plugin-notifications-node'; +import { + CatalogApi, + CatalogRequestOptions, + GetEntitiesByRefsRequest, +} from '@backstage/catalog-client'; export interface ServerOptions { port: number; @@ -42,26 +47,34 @@ export async function startStandaloneServer( const config = await loadBackendConfig({ logger, argv: process.argv }); const db = Knex(config.get('backend.database')); + const tokenManager = ServerTokenManager.fromConfig(config, { + logger, + }); + const discovery = HostDiscovery.fromConfig(config); + const dbMock: PluginDatabaseManager = { async getClient() { return db; }, }; - const discoveryMock: PluginEndpointDiscovery = { - async getBaseUrl(pluginId: string) { - return `http://localhost:7007/api/${pluginId}`; + const catalogApi = { + async getEntitiesByRefs( + _request: GetEntitiesByRefsRequest, + __options?: CatalogRequestOptions, + ) { + return { + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'user', namespace: 'default' }, + spec: {}, + }, + ], + }; }, - - async getExternalBaseUrl(pluginId: string) { - return `http://localhost:7007/api/${pluginId}`; - }, - }; - - const notificationService = NotificationService.create({ - database: dbMock, - discovery: discoveryMock, - }); + } as Partial as CatalogApi; const identityMock: IdentityApi = { async getIdentity({ request }: { request: Request }) { @@ -80,7 +93,10 @@ export async function startStandaloneServer( const router = await createRouter({ logger, identity: identityMock, - notificationService, + database: dbMock, + catalog: catalogApi, + discovery, + tokenManager, }); let service = createServiceBuilder(module) diff --git a/plugins/notifications-node/src/service/NotificationProcessor.ts b/plugins/notifications-backend/src/types.ts similarity index 100% rename from plugins/notifications-node/src/service/NotificationProcessor.ts rename to plugins/notifications-backend/src/types.ts diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index 9c085ff521..1fb928f3c4 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -3,8 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import * as muiIcons from '@material-ui/icons'; - // @public (undocumented) type Notification_2 = { id: string; @@ -12,17 +10,12 @@ type Notification_2 = { title: string; description: string; link: string; - icon?: NotificationIcon; - image?: string; created: Date; read?: Date; saved: boolean; }; export { Notification_2 as Notification }; -// @public (undocumented) -export type NotificationIcon = keyof typeof muiIcons; - // @public (undocumented) export type NotificationIds = { ids: string[]; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index f0e6feae30..2dd3f9bcb7 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -14,14 +14,9 @@ * limitations under the License. */ -import * as muiIcons from '@material-ui/icons'; - /** @public */ export type NotificationType = 'read' | 'unread' | 'saved'; -/** @public */ -export type NotificationIcon = keyof typeof muiIcons; - /** @public */ export type Notification = { id: string; @@ -29,8 +24,6 @@ export type Notification = { title: string; description: string; link: string; - icon?: NotificationIcon; - image?: string; created: Date; read?: Date; saved: boolean; diff --git a/plugins/notifications-node/README.md b/plugins/notifications-node/README.md index 2a1e61638d..31fe22baf9 100644 --- a/plugins/notifications-node/README.md +++ b/plugins/notifications-node/README.md @@ -15,10 +15,12 @@ import { NotificationService } from '@backstage/plugin-notifications-node'; function makeCreateEnv(config: Config) { // ... - const notificationService = NotificationService.create({ - database: databaseManager.forPlugin('notifications'), + const notificationService = DefaultNotificationService.create({ + logger: root.child({ type: 'plugin' }), discovery, + tokenManager, }); + // ... return (plugin: string): PluginEnvironment => { // ... @@ -51,8 +53,3 @@ save the notification and optionally signal the frontend to show the latest stat When sending notifications, you can specify the entity reference of the notification. If the entity reference is a user, the notification will be sent to only that user. If it's a group, the notification will be sent to all members of the group. If it's some other entity, the notification will be sent to the owner of that entity. - -## Extending Notification Service - -The `NotificationService` can be extended with `NotificationProcessor`. These processors allow to decorate notifications -before they are sent or/and send the notifications to external services. diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 7762d41550..b7f8047983 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -3,111 +3,44 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { LoggerService } from '@backstage/backend-plugin-api'; import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; -import { NotificationIcon } from '@backstage/plugin-notifications-common'; -import { NotificationStatus } from '@backstage/plugin-notifications-common'; -import { NotificationType } from '@backstage/plugin-notifications-common'; -import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { ServiceRef } from '@backstage/backend-plugin-api'; +import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) -export class DatabaseNotificationsStore implements NotificationsStore { +export class DefaultNotificationService implements NotificationService { // (undocumented) static create({ - database, - skipMigrations, - }: { - database: PluginDatabaseManager; - skipMigrations?: boolean; - }): Promise; + logger, + tokenManager, + discovery, + }: NotificationServiceOptions): DefaultNotificationService; // (undocumented) - getNotifications(options: NotificationGetOptions): Promise; - // (undocumented) - getStatus(options: NotificationGetOptions): Promise<{ - unread: number; - read: number; - }>; - // (undocumented) - markRead(options: NotificationModifyOptions): Promise; - // (undocumented) - markSaved(options: NotificationModifyOptions): Promise; - // (undocumented) - markUnread(options: NotificationModifyOptions): Promise; - // (undocumented) - markUnsaved(options: NotificationModifyOptions): Promise; - // (undocumented) - saveNotification(notification: Notification_2): Promise; + send(options: NotificationSendOptions): Promise; } -// @public (undocumented) -export type NotificationGetOptions = { - user_ref: string; - type?: NotificationType; -}; - -// @public (undocumented) -export type NotificationModifyOptions = { - ids: string[]; -} & NotificationGetOptions; - -// @public (undocumented) -export type NotificationProcessor = { - decorate?(notification: Notification_2): Promise; - send?(notification: Notification_2): Promise; -}; - // @public (undocumented) export type NotificationSendOptions = { entityRef: string | string[]; title: string; description: string; link: string; - image?: string; - icon?: NotificationIcon; }; // @public (undocumented) -export class NotificationService { - // (undocumented) - addProcessor(processor: NotificationProcessor): this; - // (undocumented) - static create({ - database, - discovery, - processors, - }: NotificationServiceOptions): NotificationService; - // (undocumented) - getStore(): Promise; - // (undocumented) +export type NotificationService = { send(options: NotificationSendOptions): Promise; -} +}; // @public (undocumented) export const notificationService: ServiceRef; // @public (undocumented) export type NotificationServiceOptions = { - database: PluginDatabaseManager; + logger: LoggerService; discovery: PluginEndpointDiscovery; - processors?: NotificationProcessor[]; + tokenManager: TokenManager; }; - -// @public (undocumented) -export interface NotificationsStore { - // (undocumented) - getNotifications(options: NotificationGetOptions): Promise; - // (undocumented) - getStatus(options: NotificationGetOptions): Promise; - // (undocumented) - markRead(options: NotificationModifyOptions): Promise; - // (undocumented) - markSaved(options: NotificationModifyOptions): Promise; - // (undocumented) - markUnread(options: NotificationModifyOptions): Promise; - // (undocumented) - markUnsaved(options: NotificationModifyOptions): Promise; - // (undocumented) - saveNotification(notification: Notification_2): Promise; -} ``` diff --git a/plugins/notifications-node/src/index.ts b/plugins/notifications-node/src/index.ts index f820946cc0..8e1a590b3d 100644 --- a/plugins/notifications-node/src/index.ts +++ b/plugins/notifications-node/src/index.ts @@ -20,6 +20,5 @@ * @packageDocumentation */ -export * from './database'; export * from './service'; export * from './lib'; diff --git a/plugins/notifications-node/src/lib.ts b/plugins/notifications-node/src/lib.ts index 05a1321bc1..2cf396bbc3 100644 --- a/plugins/notifications-node/src/lib.ts +++ b/plugins/notifications-node/src/lib.ts @@ -18,7 +18,8 @@ import { createServiceFactory, createServiceRef, } from '@backstage/backend-plugin-api'; -import { NotificationService } from './service'; +import { DefaultNotificationService } from './service'; +import { NotificationService } from './service/NotificationService'; /** @public */ export const notificationService = createServiceRef({ @@ -28,12 +29,17 @@ export const notificationService = createServiceRef({ createServiceFactory({ service, deps: { + logger: coreServices.logger, discovery: coreServices.discovery, - database: coreServices.database, + tokenManager: coreServices.tokenManager, // TODO: Signals }, - factory({ discovery, database }) { - return NotificationService.create({ discovery, database }); + factory({ logger, discovery, tokenManager }) { + return DefaultNotificationService.create({ + logger, + discovery, + tokenManager, + }); }, }), }); diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts new file mode 100644 index 0000000000..5580773151 --- /dev/null +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Notification } from '@backstage/plugin-notifications-common'; +import { + PluginEndpointDiscovery, + TokenManager, +} from '@backstage/backend-common'; +import { NotificationService } from './NotificationService'; +import { LoggerService } from '@backstage/backend-plugin-api'; + +/** @public */ +export type NotificationServiceOptions = { + logger: LoggerService; + discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; +}; + +/** @public */ +export type NotificationSendOptions = { + entityRef: string | string[]; + title: string; + description: string; + link: string; +}; + +/** @public */ +export class DefaultNotificationService implements NotificationService { + private constructor( + private readonly logger: LoggerService, + private readonly discovery: PluginEndpointDiscovery, + private readonly tokenManager: TokenManager, + ) {} + + static create({ + logger, + tokenManager, + discovery, + }: NotificationServiceOptions): DefaultNotificationService { + return new DefaultNotificationService(logger, discovery, tokenManager); + } + + async send(options: NotificationSendOptions): Promise { + try { + const baseUrl = await this.discovery.getBaseUrl('notifications'); + const { token } = await this.tokenManager.getToken(); + const response = await fetch(`${baseUrl}/notifications`, { + method: 'POST', + body: JSON.stringify(options), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + return await response.json(); + } catch (error) { + this.logger.error(`Failed to send notifications: ${error}`); + return []; + } + } +} diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts index 288da01dc4..b2fc4f8a3d 100644 --- a/plugins/notifications-node/src/service/NotificationService.ts +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,163 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Notification, - NotificationIcon, -} from '@backstage/plugin-notifications-common'; -import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; -import { NotificationsStore } from '../database/NotificationsStore'; -import { v4 as uuid } from 'uuid'; -import { - Entity, - isGroupEntity, - isUserEntity, - RELATION_HAS_MEMBER, - stringifyEntityRef, -} from '@backstage/catalog-model'; -import { - PluginDatabaseManager, - PluginEndpointDiscovery, -} from '@backstage/backend-common'; -import { DatabaseNotificationsStore } from '../database'; -import { NotificationProcessor } from './NotificationProcessor'; + +import { NotificationSendOptions } from './DefaultNotificationService'; +import { Notification } from '@backstage/plugin-notifications-common'; /** @public */ -export type NotificationServiceOptions = { - database: PluginDatabaseManager; - discovery: PluginEndpointDiscovery; - processors?: NotificationProcessor[]; +export type NotificationService = { + send(options: NotificationSendOptions): Promise; }; - -/** @public */ -export type NotificationSendOptions = { - entityRef: string | string[]; - title: string; - description: string; - link: string; - image?: string; - icon?: NotificationIcon; -}; - -/** @public */ -export class NotificationService { - private store: NotificationsStore | null = null; - private readonly processors: NotificationProcessor[]; - - private constructor( - private readonly database: PluginDatabaseManager, - private readonly catalog: CatalogApi, - processors?: NotificationProcessor[], - ) { - this.processors = processors ?? []; - } - - static create({ - database, - discovery, - processors, - }: NotificationServiceOptions): NotificationService { - const catalogClient = new CatalogClient({ - discoveryApi: discovery, - }); - - return new NotificationService(database, catalogClient, processors); - } - - addProcessor(processor: NotificationProcessor) { - this.processors.push(processor); - return this; - } - - async send(options: NotificationSendOptions): Promise { - const { entityRef, title, description, link, icon, image } = options; - const notifications = []; - let users = []; - try { - users = await this.getUsersForEntityRef(entityRef); - } catch (e) { - return []; - } - - const store = await this.getStore(); - const baseNotification = { - id: uuid(), - title, - description, - link, - created: new Date(), - icon, - image, - saved: false, - }; - - for (const user of users) { - let notification: Notification = { ...baseNotification, userRef: user }; - for (const processor of this.processors) { - notification = processor.decorate - ? await processor.decorate(notification) - : notification; - } - - await store.saveNotification(notification); - for (const processor of this.processors) { - if (processor.send) { - processor.send(notification); - } - } - notifications.push(notification); - // TODO: Signal service - } - - return notifications; - } - - async getStore(): Promise { - if (!this.store) { - this.store = await DatabaseNotificationsStore.create({ - database: this.database, - }); - } - return this.store; - } - - private async getUsersForEntityRef( - entityRef: string | string[], - ): Promise { - const refs = Array.isArray(entityRef) ? entityRef : [entityRef]; - const entities = await this.catalog.getEntitiesByRefs({ entityRefs: refs }); - - const mapEntity = async (entity: Entity | undefined): Promise => { - if (!entity) { - return []; - } - - if (isUserEntity(entity)) { - return [stringifyEntityRef(entity)]; - } else if (isGroupEntity(entity) && entity.relations) { - return entity.relations - .filter( - relation => - relation.type === RELATION_HAS_MEMBER && relation.targetRef, - ) - .map(r => r.targetRef); - } else if (!isGroupEntity(entity) && entity.spec?.owner) { - const owner = await this.catalog.getEntityByRef( - entity.spec.owner as string, - ); - if (owner) { - return mapEntity(owner); - } - } - - return []; - }; - - const users: string[] = []; - for (const entity of entities.items) { - const u = await mapEntity(entity); - users.push(...u); - } - return users; - } -} diff --git a/plugins/notifications-node/src/service/index.ts b/plugins/notifications-node/src/service/index.ts index 718e9a5567..37c3bff908 100644 --- a/plugins/notifications-node/src/service/index.ts +++ b/plugins/notifications-node/src/service/index.ts @@ -13,5 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './NotificationService'; -export * from './NotificationProcessor'; +export * from './DefaultNotificationService'; +export type { NotificationService } from './NotificationService'; diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx deleted file mode 100644 index f5ae2e8c22..0000000000 --- a/plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import NotificationsIcon from '@material-ui/icons/Notifications'; -import { Notification } from '@backstage/plugin-notifications-common'; -// eslint-disable-next-line no-restricted-imports -import * as muiIcons from '@material-ui/icons'; -import Avatar from '@material-ui/core/Avatar'; - -/** @internal */ -export const NotificationIcon = (props: { notification: Notification }) => { - const { notification } = props; - if (notification.icon && notification.icon in muiIcons) { - const Icon = muiIcons[notification.icon]; - return ; - } - - if (notification.image) { - return ( - - ); - } - - return ; -}; diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx index acdb2b8b59..1ebd63e1d4 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -41,10 +41,15 @@ import CloseIcon from '@material-ui/icons/Close'; import { Skeleton } from '@material-ui/lab'; // @ts-ignore import RelativeTime from 'react-relative-time'; -import { NotificationIcon } from './NotificationIcon'; import ArrowForwardIcon from '@material-ui/icons/ArrowForward'; const useStyles = makeStyles(theme => ({ + table: { + border: `1px solid ${theme.palette.divider}`, + }, + header: { + borderBottom: `1px solid ${theme.palette.divider}`, + }, notificationRow: { cursor: 'pointer', '&.hideOnHover': { @@ -54,7 +59,6 @@ const useStyles = makeStyles(theme => ({ display: 'none', }, '&:hover': { - backgroundColor: theme.palette.background.paper, '& .hideOnHover': { display: 'none', }, @@ -112,7 +116,7 @@ export const NotificationsTable = (props: { } return ( -
+
@@ -172,9 +176,13 @@ export const NotificationsTable = (props: { {props.notifications?.map(notification => { return ( - + onCheckBoxClick(notification.id)} /> - navigate(notification.link)} diff --git a/yarn.lock b/yarn.lock index fe739b0049..11c6352623 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7785,9 +7785,14 @@ __metadata: dependencies: "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" + "@backstage/plugin-notifications-common": "workspace:^" "@backstage/plugin-notifications-node": "workspace:^" "@types/express": "*" "@types/supertest": ^2.0.8 @@ -7797,6 +7802,7 @@ __metadata: msw: ^1.0.0 node-fetch: ^2.6.7 supertest: ^6.2.4 + uuid: ^8.0.0 winston: ^3.2.1 yn: ^4.0.0 languageName: unknown From 6a79f9f1eed993d1294a52dd6ff30a121a43e83a Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Thu, 25 Jan 2024 09:07:16 +0200 Subject: [PATCH 11/25] feat: integrate notifications with signals Signed-off-by: Heikki Hellgren --- packages/backend/src/index.ts | 1 + packages/backend/src/plugins/notifications.ts | 1 + plugins/notifications-backend/README.md | 4 +++- plugins/notifications-backend/api-report.md | 7 +++++-- plugins/notifications-backend/package.json | 3 ++- plugins/notifications-backend/src/plugin.ts | 4 ++++ .../src/service/router.test.ts | 6 ++++++ .../src/service/router.ts | 17 +++++++++++---- .../src/service/standaloneServer.ts | 21 +++++++++++++++++++ plugins/notifications-node/api-report.md | 6 ++++-- plugins/notifications-node/package.json | 1 + plugins/notifications-node/src/lib.ts | 6 ++++-- .../src/service/DefaultNotificationService.ts | 13 ++++++------ plugins/notifications/package.json | 1 + .../NotificationsPage/NotificationsPage.tsx | 10 ++++++++- .../NotificationsSideBarItem.tsx | 19 ++++++++++------- yarn.lock | 14 +++++-------- 17 files changed, 98 insertions(+), 36 deletions(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index c60d3d8feb..c140e6dcf5 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -109,6 +109,7 @@ function makeCreateEnv(config: Config) { logger: root.child({ type: 'plugin' }), discovery, tokenManager, + signalService, }); root.info(`Created UrlReader ${reader}`); diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index 583495cba5..74e1915cd4 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -36,5 +36,6 @@ export default async function createPlugin( tokenManager: env.tokenManager, database: env.database, discovery: env.discovery, + signalService: env.signalService, }); } diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md index da1d64cf6d..ea317afefe 100644 --- a/plugins/notifications-backend/README.md +++ b/plugins/notifications-backend/README.md @@ -4,7 +4,8 @@ Welcome to the notifications backend plugin! ## Getting started -First you have to install the `@backstage/plugin-notifications-node` package. +First you have to install `@backstage/plugin-notifications-node` and `@backstage/plugin-signals-node` +packages. Then create a new file to `packages/backend/src/plugins/notifications.ts`: @@ -22,6 +23,7 @@ export default async function createPlugin( tokenManager: env.tokenManager, database: env.database, discovery: env.discovery, + signalService: env.signalService }); } ``` diff --git a/plugins/notifications-backend/api-report.md b/plugins/notifications-backend/api-report.md index b10bc65e8c..b11c2269bd 100644 --- a/plugins/notifications-backend/api-report.md +++ b/plugins/notifications-backend/api-report.md @@ -5,12 +5,13 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; -import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import express from 'express'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { LoggerService } from '@backstage/backend-plugin-api'; import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; import { PluginDatabaseManager } from '@backstage/backend-common'; +import { SignalService } from '@backstage/plugin-signals-node'; import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) @@ -32,7 +33,7 @@ export interface RouterOptions { // (undocumented) database: PluginDatabaseManager; // (undocumented) - discovery: DiscoveryApi; + discovery: DiscoveryService; // (undocumented) identity: IdentityApi; // (undocumented) @@ -40,6 +41,8 @@ export interface RouterOptions { // (undocumented) processors?: NotificationProcessor[]; // (undocumented) + signalService?: SignalService; + // (undocumented) tokenManager: TokenManager; } diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index 8d56034b47..810fa95085 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -27,11 +27,12 @@ "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", - "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", + "@backstage/plugin-events-node": "workspace:^", "@backstage/plugin-notifications-common": "workspace:^", "@backstage/plugin-notifications-node": "workspace:^", + "@backstage/plugin-signals-node": "workspace:^", "@types/express": "*", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts index 6ab3c464db..3d9937525c 100644 --- a/plugins/notifications-backend/src/plugin.ts +++ b/plugins/notifications-backend/src/plugin.ts @@ -18,6 +18,7 @@ import { createBackendPlugin, } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; +import { signalService } from '@backstage/plugin-signals-node'; /** * Notifications backend plugin @@ -35,6 +36,7 @@ export const notificationsPlugin = createBackendPlugin({ database: coreServices.database, tokenManager: coreServices.tokenManager, discovery: coreServices.discovery, + signals: signalService, }, async init({ httpRouter, @@ -43,6 +45,7 @@ export const notificationsPlugin = createBackendPlugin({ database, tokenManager, discovery, + signals, }) { httpRouter.use( await createRouter({ @@ -51,6 +54,7 @@ export const notificationsPlugin = createBackendPlugin({ database, tokenManager, discovery, + signalService: signals, }), ); }, diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index 3ad9b4c531..3152b1b07f 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -26,6 +26,7 @@ import request from 'supertest'; import { createRouter } from './router'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { ConfigReader } from '@backstage/config'; +import { SignalService } from '@backstage/plugin-signals-node'; function createDatabase(): PluginDatabaseManager { return DatabaseManager.fromConfig( @@ -65,6 +66,10 @@ describe('createRouter', () => { getExternalBaseUrl: jest.fn(), }; + const signalService: jest.Mocked = { + publish: jest.fn(), + }; + beforeAll(async () => { const router = await createRouter({ logger: getVoidLogger(), @@ -72,6 +77,7 @@ describe('createRouter', () => { database: createDatabase(), tokenManager: mockedTokenManager, discovery, + signalService, }); app = express().use(router); }); diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index af5a5a826f..5246bc140a 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -39,8 +39,8 @@ import { } from '@backstage/catalog-model'; import { NotificationProcessor } from '../types'; import { AuthenticationError } from '@backstage/errors'; -import { DiscoveryApi } from '@backstage/core-plugin-api'; -import { LoggerService } from '@backstage/backend-plugin-api'; +import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; +import { SignalService } from '@backstage/plugin-signals-node'; /** @public */ export interface RouterOptions { @@ -48,7 +48,8 @@ export interface RouterOptions { identity: IdentityApi; database: PluginDatabaseManager; tokenManager: TokenManager; - discovery: DiscoveryApi; + discovery: DiscoveryService; + signalService?: SignalService; catalog?: CatalogApi; processors?: NotificationProcessor[]; } @@ -65,6 +66,7 @@ export async function createRouter( catalog, tokenManager, processors, + signalService, } = options; const catalogClient = @@ -248,7 +250,14 @@ export async function createRouter( } } notifications.push(notification); - // TODO: Signal service + } + + if (signalService) { + await signalService.publish({ + recipients: users, + message: { action: 'refresh' }, + channel: 'notifications', + }); } res.send(notifications); diff --git a/plugins/notifications-backend/src/service/standaloneServer.ts b/plugins/notifications-backend/src/service/standaloneServer.ts index 4a8ba80f72..e2841543b1 100644 --- a/plugins/notifications-backend/src/service/standaloneServer.ts +++ b/plugins/notifications-backend/src/service/standaloneServer.ts @@ -31,6 +31,12 @@ import { CatalogRequestOptions, GetEntitiesByRefsRequest, } from '@backstage/catalog-client'; +import { DefaultSignalService } from '@backstage/plugin-signals-node'; +import { + EventBroker, + EventParams, + EventSubscriber, +} from '@backstage/plugin-events-node'; export interface ServerOptions { port: number; @@ -90,6 +96,20 @@ export async function startStandaloneServer( }, }; + const mockSubscribers: EventSubscriber[] = []; + const eventBroker: EventBroker = { + async publish(params: EventParams): Promise { + mockSubscribers.forEach(sub => sub.onEvent(params)); + }, + subscribe(...subscribers: EventSubscriber[]) { + subscribers.flat().forEach(subscriber => { + mockSubscribers.push(subscriber); + }); + }, + }; + + const signalService = DefaultSignalService.create({ eventBroker }); + const router = await createRouter({ logger, identity: identityMock, @@ -97,6 +117,7 @@ export async function startStandaloneServer( catalog: catalogApi, discovery, tokenManager, + signalService, }); let service = createServiceBuilder(module) diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index b7f8047983..f6b4987c90 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -3,10 +3,11 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { DiscoveryService } from '@backstage/backend-plugin-api'; import { LoggerService } from '@backstage/backend-plugin-api'; import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { ServiceRef } from '@backstage/backend-plugin-api'; +import { SignalService } from '@backstage/plugin-signals-node'; import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) @@ -40,7 +41,8 @@ export const notificationService: ServiceRef; // @public (undocumented) export type NotificationServiceOptions = { logger: LoggerService; - discovery: PluginEndpointDiscovery; + discovery: DiscoveryService; tokenManager: TokenManager; + signalService: SignalService; }; ``` diff --git a/plugins/notifications-node/package.json b/plugins/notifications-node/package.json index d5cb66ba3d..fd27d3c63c 100644 --- a/plugins/notifications-node/package.json +++ b/plugins/notifications-node/package.json @@ -33,6 +33,7 @@ "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/plugin-notifications-common": "workspace:^", + "@backstage/plugin-signals-node": "workspace:^", "knex": "^3.0.0", "uuid": "^8.0.0" } diff --git a/plugins/notifications-node/src/lib.ts b/plugins/notifications-node/src/lib.ts index 2cf396bbc3..41773143d2 100644 --- a/plugins/notifications-node/src/lib.ts +++ b/plugins/notifications-node/src/lib.ts @@ -20,6 +20,7 @@ import { } from '@backstage/backend-plugin-api'; import { DefaultNotificationService } from './service'; import { NotificationService } from './service/NotificationService'; +import { signalService } from '@backstage/plugin-signals-node'; /** @public */ export const notificationService = createServiceRef({ @@ -32,13 +33,14 @@ export const notificationService = createServiceRef({ logger: coreServices.logger, discovery: coreServices.discovery, tokenManager: coreServices.tokenManager, - // TODO: Signals + signals: signalService, }, - factory({ logger, discovery, tokenManager }) { + factory({ logger, discovery, tokenManager, signals }) { return DefaultNotificationService.create({ logger, discovery, tokenManager, + signalService: signals, }); }, }), diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts index 5580773151..8be0f71799 100644 --- a/plugins/notifications-node/src/service/DefaultNotificationService.ts +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -14,18 +14,17 @@ * limitations under the License. */ import { Notification } from '@backstage/plugin-notifications-common'; -import { - PluginEndpointDiscovery, - TokenManager, -} from '@backstage/backend-common'; +import { TokenManager } from '@backstage/backend-common'; import { NotificationService } from './NotificationService'; -import { LoggerService } from '@backstage/backend-plugin-api'; +import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; +import { SignalService } from '@backstage/plugin-signals-node'; /** @public */ export type NotificationServiceOptions = { logger: LoggerService; - discovery: PluginEndpointDiscovery; + discovery: DiscoveryService; tokenManager: TokenManager; + signalService: SignalService; }; /** @public */ @@ -40,7 +39,7 @@ export type NotificationSendOptions = { export class DefaultNotificationService implements NotificationService { private constructor( private readonly logger: LoggerService, - private readonly discovery: PluginEndpointDiscovery, + private readonly discovery: DiscoveryService, private readonly tokenManager: TokenManager, ) {} diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index 80804a68c4..57f0b38e82 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -27,6 +27,7 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-notifications-common": "workspace:^", + "@backstage/plugin-signals-react": "workspace:^", "@backstage/theme": "workspace:^", "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index 4ebcaa6fc4..20b106f6f2 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Content, ErrorPanel, @@ -27,6 +27,7 @@ import Bookmark from '@material-ui/icons/Bookmark'; import Check from '@material-ui/icons/Check'; import Inbox from '@material-ui/icons/Inbox'; import { NotificationType } from '@backstage/plugin-notifications-common'; +import { useSignal } from '@backstage/plugin-signals-react'; const useStyles = makeStyles(_theme => ({ filterButton: { @@ -43,6 +44,13 @@ export const NotificationsPage = () => { [type], ); + const { lastSignal } = useSignal('notifications'); + useEffect(() => { + if (lastSignal && lastSignal.action === 'refresh') { + retry(); + } + }, [lastSignal, retry]); + const onUpdate = () => { retry(); }; diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx index 6a562b20c3..255105a499 100644 --- a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx +++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx @@ -19,25 +19,30 @@ import { SidebarItem } from '@backstage/core-components'; import NotificationsIcon from '@material-ui/icons/Notifications'; import { useRouteRef } from '@backstage/core-plugin-api'; import { rootRouteRef } from '../../routes'; +import { useSignal } from '@backstage/plugin-signals-react'; /** @public */ export const NotificationsSidebarItem = () => { - const { - loading, - error, - value, - retry: _retry, - } = useNotificationsApi(api => api.getStatus()); + const { loading, error, value, retry } = useNotificationsApi(api => + api.getStatus(), + ); const [unreadCount, setUnreadCount] = React.useState(0); const notificationsRoute = useRouteRef(rootRouteRef); + const { lastSignal } = useSignal('notifications'); + useEffect(() => { + if (lastSignal && lastSignal.action === 'refresh') { + retry(); + } + }, [lastSignal, retry]); + useEffect(() => { if (!loading && !error && value) { setUnreadCount(value.unread); } }, [loading, error, value]); - // TODO: Figure out if there count can be added to hasNotifications + // TODO: Figure out if the count can be added to hasNotifications return ( Date: Thu, 25 Jan 2024 10:12:31 +0200 Subject: [PATCH 12/25] feat: allow broadcast notifications Signed-off-by: Heikki Hellgren --- packages/backend/src/plugins/notifications.ts | 2 +- plugins/notifications-backend/README.md | 4 ++-- .../src/service/router.ts | 24 +++++++++++++++---- plugins/notifications-node/api-report.md | 17 ++++++++++--- .../src/service/DefaultNotificationService.ts | 7 +++++- .../src/service/NotificationService.ts | 4 ++-- 6 files changed, 45 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index 74e1915cd4..5926c292ff 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -23,7 +23,7 @@ export default async function createPlugin( ): Promise { setInterval(() => { env.notificationService.send({ - entityRef: 'user:default/guest', + receivers: { type: 'broadcast' }, title: 'Test', description: 'Test', link: '/catalog', diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md index ea317afefe..376d38c855 100644 --- a/plugins/notifications-backend/README.md +++ b/plugins/notifications-backend/README.md @@ -4,7 +4,7 @@ Welcome to the notifications backend plugin! ## Getting started -First you have to install `@backstage/plugin-notifications-node` and `@backstage/plugin-signals-node` +First you have to install `@backstage/plugin-notifications-node` and `@backstage/plugin-signals-node` packages. Then create a new file to `packages/backend/src/plugins/notifications.ts`: @@ -23,7 +23,7 @@ export default async function createPlugin( tokenManager: env.tokenManager, database: env.database, discovery: env.discovery, - signalService: env.signalService + signalService: env.signalService, }); } ``` diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 5246bc140a..a31f47861f 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -89,13 +89,24 @@ export async function createRouter( }; const getUsersForEntityRef = async ( - entityRef: string | string[], + entityRef: string | string[] | null, ): Promise => { - const refs = Array.isArray(entityRef) ? entityRef : [entityRef]; const { token } = await tokenManager.getToken(); + + // Broadcast + if (entityRef === null) { + const users = await catalogClient.getEntities({ + filter: { kind: 'User' }, + fields: ['kind', 'metadata.name', 'metadata.namespace'], + }); + return users.items.map(stringifyEntityRef); + } + + const refs = Array.isArray(entityRef) ? entityRef : [entityRef]; const entities = await catalogClient.getEntitiesByRefs( { entityRefs: refs, + fields: ['kind', 'metadata.name', 'metadata.namespace'], }, { token }, ); @@ -206,7 +217,7 @@ export async function createRouter( }); router.post('/notifications', async (req, res) => { - const { entityRef, title, description, link } = req.body; + const { receivers, title, description, link } = req.body; const notifications = []; let users = []; @@ -218,6 +229,11 @@ export async function createRouter( return; } + let entityRef = null; + if (receivers.entityRef && receivers.type === 'entity') { + entityRef = receivers.entityRef; + } + try { users = await getUsersForEntityRef(entityRef); } catch (e) { @@ -254,7 +270,7 @@ export async function createRouter( if (signalService) { await signalService.publish({ - recipients: users, + recipients: entityRef === null ? null : users, message: { action: 'refresh' }, channel: 'notifications', }); diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index f6b4987c90..806b0400ac 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -22,18 +22,29 @@ export class DefaultNotificationService implements NotificationService { send(options: NotificationSendOptions): Promise; } +// @public (undocumented) +export type NotificationReceivers = + | { + type: 'entity'; + entityRef: string | string[]; + } + | { + type: 'broadcast'; + }; + // @public (undocumented) export type NotificationSendOptions = { - entityRef: string | string[]; + receivers: NotificationReceivers; title: string; description: string; link: string; }; // @public (undocumented) -export type NotificationService = { +export interface NotificationService { + // (undocumented) send(options: NotificationSendOptions): Promise; -}; +} // @public (undocumented) export const notificationService: ServiceRef; diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts index 8be0f71799..16de07912a 100644 --- a/plugins/notifications-node/src/service/DefaultNotificationService.ts +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -27,9 +27,14 @@ export type NotificationServiceOptions = { signalService: SignalService; }; +/** @public */ +export type NotificationReceivers = + | { type: 'entity'; entityRef: string | string[] } + | { type: 'broadcast' }; + /** @public */ export type NotificationSendOptions = { - entityRef: string | string[]; + receivers: NotificationReceivers; title: string; description: string; link: string; diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts index b2fc4f8a3d..68b0c13a9e 100644 --- a/plugins/notifications-node/src/service/NotificationService.ts +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -18,6 +18,6 @@ import { NotificationSendOptions } from './DefaultNotificationService'; import { Notification } from '@backstage/plugin-notifications-common'; /** @public */ -export type NotificationService = { +export interface NotificationService { send(options: NotificationSendOptions): Promise; -}; +} From c3a22686dded6623570871d52493b5652edcb4c2 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Thu, 25 Jan 2024 22:04:51 +0200 Subject: [PATCH 13/25] feat: add support for web api notifications Signed-off-by: Heikki Hellgren --- .../src/service/router.ts | 20 ++++++-- .../NotificationsPage/NotificationsPage.tsx | 17 +++++-- .../NotificationsSideBarItem.tsx | 50 +++++++++++++++++-- .../NotificationsTable/NotificationsTable.tsx | 8 +-- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index a31f47861f..3611d407a3 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -180,6 +180,14 @@ export async function createRouter( return; } await store.markRead({ user_ref: user, ids }); + + if (signalService) { + await signalService.publish({ + recipients: [user], + message: { action: 'refresh' }, + channel: 'notifications', + }); + } res.status(200).send({ ids }); }); @@ -191,6 +199,13 @@ export async function createRouter( return; } await store.markUnread({ user_ref: user, ids }); + if (signalService) { + await signalService.publish({ + recipients: [user], + message: { action: 'refresh' }, + channel: 'notifications', + }); + } res.status(200).send({ ids }); }); @@ -243,7 +258,6 @@ export async function createRouter( } const baseNotification = { - id: uuid(), title, description, link, @@ -252,7 +266,7 @@ export async function createRouter( }; for (const user of users) { - let notification = { ...baseNotification, userRef: user }; + let notification = { ...baseNotification, id: uuid(), userRef: user }; for (const processor of processors ?? []) { notification = processor.decorate ? await processor.decorate(notification) @@ -271,7 +285,7 @@ export async function createRouter( if (signalService) { await signalService.publish({ recipients: entityRef === null ? null : users, - message: { action: 'refresh' }, + message: { action: 'refresh', title, description, link }, channel: 'notifications', }); } diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index 20b106f6f2..41db91b9cf 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -38,21 +38,29 @@ const useStyles = makeStyles(_theme => ({ export const NotificationsPage = () => { const [type, setType] = useState('unread'); + const [refresh, setRefresh] = React.useState(false); - const { loading, error, value, retry } = useNotificationsApi( + const { error, value, retry } = useNotificationsApi( api => api.getNotifications({ type }), [type], ); + useEffect(() => { + if (refresh) { + retry(); + setRefresh(false); + } + }, [refresh, setRefresh, retry]); + const { lastSignal } = useSignal('notifications'); useEffect(() => { if (lastSignal && lastSignal.action === 'refresh') { - retry(); + setRefresh(true); } - }, [lastSignal, retry]); + }, [lastSignal]); const onUpdate = () => { - retry(); + setRefresh(true); }; const styles = useStyles(); @@ -95,7 +103,6 @@ export const NotificationsPage = () => { diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx index 255105a499..6dfa0ebb75 100644 --- a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx +++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx @@ -27,14 +27,51 @@ export const NotificationsSidebarItem = () => { api.getStatus(), ); const [unreadCount, setUnreadCount] = React.useState(0); + const [webNotificationPermission, setWebNotificationPermission] = + React.useState('default'); const notificationsRoute = useRouteRef(rootRouteRef); - const { lastSignal } = useSignal('notifications'); + const [webNotifications, setWebNotifications] = React.useState< + Notification[] + >([]); + const [refresh, setRefresh] = React.useState(false); + + useEffect(() => { + if ('Notification' in window && webNotificationPermission === 'default') { + window.Notification.requestPermission().then(permission => { + setWebNotificationPermission(permission); + }); + } + }, [webNotificationPermission]); + + useEffect(() => { + if (refresh) { + retry(); + setRefresh(false); + } + }, [refresh, retry]); + useEffect(() => { if (lastSignal && lastSignal.action === 'refresh') { - retry(); + if ( + webNotificationPermission === 'granted' && + 'title' in lastSignal && + 'description' in lastSignal && + 'link' in lastSignal + ) { + const notification = new Notification(lastSignal.title as string, { + body: lastSignal.description as string, + }); + notification.onclick = function (event) { + event.preventDefault(); + notification.close(); + window.open(lastSignal.link as string, '_blank'); + }; + setWebNotifications(prev => [...prev, notification]); + } + setRefresh(true); } - }, [lastSignal, retry]); + }, [lastSignal, webNotificationPermission]); useEffect(() => { if (!loading && !error && value) { @@ -42,6 +79,13 @@ export const NotificationsSidebarItem = () => { } }, [loading, error, value]); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + webNotifications.forEach(n => n.close()); + setWebNotifications([]); + } + }); + // TODO: Figure out if the count can be added to hasNotifications return ( ({ export const NotificationsTable = (props: { onUpdate: () => void; type: NotificationType; - loading?: boolean; notifications?: Notification[]; }) => { - const { notifications, type, loading } = props; + const { notifications, type } = props; const navigate = useNavigate(); const styles = useStyles(); const [selected, setSelected] = useState([]); @@ -111,10 +109,6 @@ export const NotificationsTable = (props: { ); }; - if (loading) { - return ; - } - return (
From 8724ed1fdfe7d02df86ee7d50721f6c60277d001 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 26 Jan 2024 09:00:29 +0200 Subject: [PATCH 14/25] feat: separate read and done statuses Signed-off-by: Heikki Hellgren --- .../migrations/20231215_init.js | 1 + .../database/DatabaseNotificationsStore.ts | 22 +- .../src/database/NotificationsStore.ts | 4 + .../src/service/router.ts | 39 +++- plugins/notifications-common/api-report.md | 2 +- plugins/notifications-common/src/types.ts | 2 +- plugins/notifications/api-report.md | 9 +- .../notifications/src/api/NotificationsApi.ts | 4 + .../src/api/NotificationsClient.ts | 16 ++ .../NotificationsPage/NotificationsPage.tsx | 10 +- .../NotificationsTable/NotificationsTable.tsx | 219 ++++++++++-------- 11 files changed, 214 insertions(+), 114 deletions(-) diff --git a/plugins/notifications-backend/migrations/20231215_init.js b/plugins/notifications-backend/migrations/20231215_init.js index e65708a048..08758d06f3 100644 --- a/plugins/notifications-backend/migrations/20231215_init.js +++ b/plugins/notifications-backend/migrations/20231215_init.js @@ -25,6 +25,7 @@ exports.up = async function up(knex) { table.text('image').nullable(); table.datetime('created').notNullable(); table.datetime('read').nullable(); + table.datetime('done').nullable(); table.boolean('saved').defaultTo(false).notNullable(); }); }; diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index b0ab25f2f9..533a5b8627 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -60,12 +60,14 @@ export class DatabaseNotificationsStore implements NotificationsStore { options: NotificationGetOptions | NotificationModifyOptions, ) => { const { user_ref, type } = options; - const query = this.db('notifications').where('userRef', user_ref); + const query = this.db('notifications') + .where('userRef', user_ref) + .orderBy('created', 'desc'); - if (type === 'unread') { - query.whereNull('read'); - } else if (type === 'read') { - query.whereNotNull('read'); + if (type === 'undone') { + query.whereNull('done'); + } else if (type === 'done') { + query.whereNotNull('done'); } else if (type === 'saved') { query.where('saved', true); } @@ -116,6 +118,16 @@ export class DatabaseNotificationsStore implements NotificationsStore { await notificationQuery.update({ read: null }); } + async markDone(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ done: new Date(), read: new Date() }); + } + + async markUndone(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ done: null, read: null }); + } + async markSaved(options: NotificationModifyOptions): Promise { const notificationQuery = this.getNotificationsBaseQuery(options); await notificationQuery.update({ saved: true }); diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index 12397621df..c63de65dc5 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -43,6 +43,10 @@ export interface NotificationsStore { markUnread(options: NotificationModifyOptions): Promise; + markDone(options: NotificationModifyOptions): Promise; + + markUndone(options: NotificationModifyOptions): Promise; + markSaved(options: NotificationModifyOptions): Promise; markUnsaved(options: NotificationModifyOptions): Promise; diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 3611d407a3..f5299931e0 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -168,10 +168,47 @@ export async function createRouter( router.get('/status', async (req, res) => { const user = await getUser(req); - const status = await store.getStatus({ user_ref: user }); + const status = await store.getStatus({ user_ref: user, type: 'undone' }); res.send(status); }); + router.post('/done', async (req, res) => { + const user = await getUser(req); + const { ids } = req.body; + if (!ids || !Array.isArray(ids)) { + res.status(400).send(); + return; + } + await store.markDone({ user_ref: user, ids }); + + if (signalService) { + await signalService.publish({ + recipients: [user], + message: { action: 'refresh' }, + channel: 'notifications', + }); + } + res.status(200).send({ ids }); + }); + + router.post('/undo', async (req, res) => { + const user = await getUser(req); + const { ids } = req.body; + if (!ids || !Array.isArray(ids)) { + res.status(400).send(); + return; + } + await store.markUndone({ user_ref: user, ids }); + if (signalService) { + await signalService.publish({ + recipients: [user], + message: { action: 'refresh' }, + channel: 'notifications', + }); + } + res.status(200).send({ ids }); + }); + router.post('/read', async (req, res) => { const user = await getUser(req); const { ids } = req.body; diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index 1fb928f3c4..4e34ae2afc 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -28,5 +28,5 @@ export type NotificationStatus = { }; // @public (undocumented) -export type NotificationType = 'read' | 'unread' | 'saved'; +export type NotificationType = 'undone' | 'done' | 'saved'; ``` diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index 2dd3f9bcb7..c19edb9b24 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -15,7 +15,7 @@ */ /** @public */ -export type NotificationType = 'read' | 'unread' | 'saved'; +export type NotificationType = 'undone' | 'done' | 'saved'; /** @public */ export type Notification = { diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index b4cb4c6c3e..ebb146a48f 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -31,10 +31,14 @@ export interface NotificationsApi { // (undocumented) getStatus(): Promise; // (undocumented) + markDone(ids: string[]): Promise; + // (undocumented) markRead(ids: string[]): Promise; // (undocumented) markSaved(ids: string[]): Promise; // (undocumented) + markUndone(ids: string[]): Promise; + // (undocumented) markUnread(ids: string[]): Promise; // (undocumented) markUnsaved(ids: string[]): Promise; @@ -53,10 +57,14 @@ export class NotificationsClient implements NotificationsApi { // (undocumented) getStatus(): Promise; // (undocumented) + markDone(ids: string[]): Promise; + // (undocumented) markRead(ids: string[]): Promise; // (undocumented) markSaved(ids: string[]): Promise; // (undocumented) + markUndone(ids: string[]): Promise; + // (undocumented) markUnread(ids: string[]): Promise; // (undocumented) markUnsaved(ids: string[]): Promise; @@ -80,7 +88,6 @@ export const NotificationsSidebarItem: () => React_2.JSX.Element; export const NotificationsTable: (props: { onUpdate: () => void; type: NotificationType; - loading?: boolean; notifications?: Notification_2[]; }) => React_2.JSX.Element; diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts index 276bc02ded..462787d87f 100644 --- a/plugins/notifications/src/api/NotificationsApi.ts +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -37,6 +37,10 @@ export interface NotificationsApi { getStatus(): Promise; + markDone(ids: string[]): Promise; + + markUndone(ids: string[]): Promise; + markRead(ids: string[]): Promise; markUnread(ids: string[]): Promise; diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index 113aefddca..e3225843ae 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -52,6 +52,22 @@ export class NotificationsClient implements NotificationsApi { return await this.request('status'); } + async markDone(ids: string[]): Promise { + return await this.request('done', { + method: 'POST', + body: JSON.stringify({ ids: ids }), + headers: { 'Content-Type': 'application/json' }, + }); + } + + async markUndone(ids: string[]): Promise { + return await this.request('undone', { + method: 'POST', + body: JSON.stringify({ ids: ids }), + headers: { 'Content-Type': 'application/json' }, + }); + } + async markRead(ids: string[]): Promise { return await this.request('read', { method: 'POST', diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index 41db91b9cf..9ed74b5c3d 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -37,7 +37,7 @@ const useStyles = makeStyles(_theme => ({ })); export const NotificationsPage = () => { - const [type, setType] = useState('unread'); + const [type, setType] = useState('undone'); const [refresh, setRefresh] = React.useState(false); const { error, value, retry } = useNotificationsApi( @@ -77,16 +77,16 @@ export const NotificationsPage = () => { diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx index 3f89e590cf..75d273bfc9 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -20,6 +20,7 @@ import { IconButton, makeStyles, Table, + TableBody, TableCell, TableHead, TableRow, @@ -49,9 +50,13 @@ const useStyles = makeStyles(theme => ({ header: { borderBottom: `1px solid ${theme.palette.divider}`, }, + notificationRow: { cursor: 'pointer', - '&.hideOnHover': { + '&.unread': { + border: '1px solid rgba(255, 255, 255, .3)', + }, + '& .hideOnHover': { display: 'initial', }, '& .showOnHover': { @@ -138,12 +143,12 @@ export const NotificationsTable = (props: { type !== 'saved' && 'Select all'} {selected.length > 0 && `${selected.length} selected`} - {type === 'read' && selected.length > 0 && ( + {type === 'done' && selected.length > 0 && ( )} - {type === 'unread' && selected.length > 0 && ( + {type === 'undone' && selected.length > 0 && (
); }; From fdf03a6997f3b1d66bf0e06a657dbd57525ffef6 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 26 Jan 2024 12:06:58 +0200 Subject: [PATCH 15/25] chore: restore signals changes to master Signed-off-by: Heikki Hellgren --- plugins/signals-node/api-report.md | 4 ++-- plugins/signals-node/src/SignalService.ts | 4 ++-- plugins/signals-react/api-report.md | 15 ++++-------- plugins/signals-react/src/api/SignalApi.ts | 11 +++------ plugins/signals/src/api/SignalClient.ts | 28 +++++++++++----------- 5 files changed, 26 insertions(+), 36 deletions(-) diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md index cbf4142daf..52d7e37dac 100644 --- a/plugins/signals-node/api-report.md +++ b/plugins/signals-node/api-report.md @@ -22,9 +22,9 @@ export type SignalPayload = { }; // @public (undocumented) -export interface SignalService { +export type SignalService = { publish(signal: SignalPayload): Promise; -} +}; // @public (undocumented) export const signalService: ServiceRef; diff --git a/plugins/signals-node/src/SignalService.ts b/plugins/signals-node/src/SignalService.ts index 7d021ccf4e..f08a12661f 100644 --- a/plugins/signals-node/src/SignalService.ts +++ b/plugins/signals-node/src/SignalService.ts @@ -16,9 +16,9 @@ import { SignalPayload } from './types'; /** @public */ -export interface SignalService { +export type SignalService = { /** * Publishes a message to user refs to specific topic */ publish(signal: SignalPayload): Promise; -} +}; diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md index ef4bc1b7fc..438b3e95cd 100644 --- a/plugins/signals-react/api-report.md +++ b/plugins/signals-react/api-report.md @@ -7,23 +7,18 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; // @public (undocumented) -export interface SignalApi { - // (undocumented) +export type SignalApi = { subscribe( channel: string, onMessage: (message: JsonObject) => void, - ): SignalSubscriber; -} + ): { + unsubscribe: () => void; + }; +}; // @public (undocumented) export const signalApiRef: ApiRef; -// @public (undocumented) -export interface SignalSubscriber { - // (undocumented) - unsubscribe(): void; -} - // @public (undocumented) export const useSignal: (channel: string) => { lastSignal: JsonObject | null; diff --git a/plugins/signals-react/src/api/SignalApi.ts b/plugins/signals-react/src/api/SignalApi.ts index b67b2ea0dc..b37b3ae2f5 100644 --- a/plugins/signals-react/src/api/SignalApi.ts +++ b/plugins/signals-react/src/api/SignalApi.ts @@ -22,14 +22,9 @@ export const signalApiRef = createApiRef({ }); /** @public */ -export interface SignalSubscriber { - unsubscribe(): void; -} - -/** @public */ -export interface SignalApi { +export type SignalApi = { subscribe( channel: string, onMessage: (message: JsonObject) => void, - ): SignalSubscriber; -} + ): { unsubscribe: () => void }; +}; diff --git a/plugins/signals/src/api/SignalClient.ts b/plugins/signals/src/api/SignalClient.ts index d6c4634264..ee4ed8756b 100644 --- a/plugins/signals/src/api/SignalClient.ts +++ b/plugins/signals/src/api/SignalClient.ts @@ -146,6 +146,20 @@ export class SignalClient implements SignalApi { url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'; this.ws = new WebSocket(url.toString(), token); + this.ws.onmessage = (data: MessageEvent) => { + this.handleMessage(data); + }; + + this.ws.onerror = () => { + this.reconnect(); + }; + + this.ws.onclose = (ev: CloseEvent) => { + if (ev.code !== WS_CLOSE_NORMAL && ev.code !== WS_CLOSE_GOING_AWAY) { + this.reconnect(); + } + }; + // Wait until connection is open let connectSleep = 0; while ( @@ -160,20 +174,6 @@ export class SignalClient implements SignalApi { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error('Connect timeout'); } - - this.ws.onmessage = (data: MessageEvent) => { - this.handleMessage(data); - }; - - this.ws.onerror = () => { - this.reconnect(); - }; - - this.ws.onclose = (ev: CloseEvent) => { - if (ev.code !== WS_CLOSE_NORMAL && ev.code !== WS_CLOSE_GOING_AWAY) { - this.reconnect(); - } - }; } private handleMessage(data: MessageEvent) { From dced4bbc96597fc7edfe327125cae974b059de26 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 26 Jan 2024 13:16:30 +0200 Subject: [PATCH 16/25] feat: add support for unread count in document title Signed-off-by: Heikki Hellgren --- plugins/notifications/api-report.md | 13 ++++ plugins/notifications/config.d.ts | 31 +++++++++ plugins/notifications/package.json | 6 +- .../NotificationsSideBarItem.tsx | 66 +++++++++---------- plugins/notifications/src/hooks/index.ts | 2 + .../src/hooks/useTitleCounter.ts | 60 +++++++++++++++++ .../src/hooks/useWebNotifications.ts | 54 +++++++++++++++ 7 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 plugins/notifications/config.d.ts create mode 100644 plugins/notifications/src/hooks/useTitleCounter.ts create mode 100644 plugins/notifications/src/hooks/useWebNotifications.ts diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index ebb146a48f..efe9ba000e 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -121,5 +121,18 @@ export function useNotificationsApi( value: T; }; +// @public (undocumented) +export function useTitleCounter(): { + setNotificationCount: (newCount: number) => void; +}; + +// @public (undocumented) +export function useWebNotifications(): { + sendWebNotification: (options: { + title: string; + description: string; + }) => Notification | null; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/notifications/config.d.ts b/plugins/notifications/config.d.ts new file mode 100644 index 0000000000..0ae7ea2a1c --- /dev/null +++ b/plugins/notifications/config.d.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + /** @visibility frontend */ + notifications?: { + /** + * Enable or disable the Web Notification API for notifications, defaults to false + * @visibility frontend + */ + enableWebNotifications?: boolean; + /** + * Enable or disable the title override to show notification count, defaults to true + * @visibility frontend + */ + enableTitleCounter?: boolean; + }; +} diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index 57f0b38e82..f5a2f9199a 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -51,6 +51,8 @@ "msw": "^1.0.0" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx index 6dfa0ebb75..f3034f6b26 100644 --- a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx +++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx @@ -13,36 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useNotificationsApi } from '../../hooks'; import { SidebarItem } from '@backstage/core-components'; import NotificationsIcon from '@material-ui/icons/Notifications'; -import { useRouteRef } from '@backstage/core-plugin-api'; +import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { rootRouteRef } from '../../routes'; import { useSignal } from '@backstage/plugin-signals-react'; +import { useWebNotifications } from '../../hooks/useWebNotifications'; +import { useTitleCounter } from '../../hooks/useTitleCounter'; /** @public */ export const NotificationsSidebarItem = () => { const { loading, error, value, retry } = useNotificationsApi(api => api.getStatus(), ); + const config = useApi(configApiRef); const [unreadCount, setUnreadCount] = React.useState(0); - const [webNotificationPermission, setWebNotificationPermission] = - React.useState('default'); const notificationsRoute = useRouteRef(rootRouteRef); const { lastSignal } = useSignal('notifications'); - const [webNotifications, setWebNotifications] = React.useState< - Notification[] - >([]); + const { sendWebNotification } = useWebNotifications(); const [refresh, setRefresh] = React.useState(false); - - useEffect(() => { - if ('Notification' in window && webNotificationPermission === 'default') { - window.Notification.requestPermission().then(permission => { - setWebNotificationPermission(permission); - }); - } - }, [webNotificationPermission]); + const webNotificationsEnabled = useMemo( + () => + config.getOptionalBoolean('notifications.enableWebNotifications') ?? + false, + [config], + ); + const titleCounterEnabled = useMemo( + () => config.getOptionalString('notifications.enableTitleCounter') ?? true, + [config], + ); + const { setNotificationCount } = useTitleCounter(); useEffect(() => { if (refresh) { @@ -54,37 +56,35 @@ export const NotificationsSidebarItem = () => { useEffect(() => { if (lastSignal && lastSignal.action === 'refresh') { if ( - webNotificationPermission === 'granted' && + webNotificationsEnabled && 'title' in lastSignal && 'description' in lastSignal && 'link' in lastSignal ) { - const notification = new Notification(lastSignal.title as string, { - body: lastSignal.description as string, + const notification = sendWebNotification({ + title: lastSignal.title as string, + description: lastSignal.description as string, }); - notification.onclick = function (event) { - event.preventDefault(); - notification.close(); - window.open(lastSignal.link as string, '_blank'); - }; - setWebNotifications(prev => [...prev, notification]); + if (notification) { + notification.onclick = event => { + event.preventDefault(); + notification.close(); + window.open(lastSignal.link as string, '_blank'); + }; + } } setRefresh(true); } - }, [lastSignal, webNotificationPermission]); + }, [lastSignal, sendWebNotification, webNotificationsEnabled]); useEffect(() => { if (!loading && !error && value) { setUnreadCount(value.unread); + if (titleCounterEnabled) { + setNotificationCount(value.unread); + } } - }, [loading, error, value]); - - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - webNotifications.forEach(n => n.close()); - setWebNotifications([]); - } - }); + }, [loading, error, value, titleCounterEnabled, setNotificationCount]); // TODO: Figure out if the count can be added to hasNotifications return ( diff --git a/plugins/notifications/src/hooks/index.ts b/plugins/notifications/src/hooks/index.ts index 2613596f72..516901aa04 100644 --- a/plugins/notifications/src/hooks/index.ts +++ b/plugins/notifications/src/hooks/index.ts @@ -14,3 +14,5 @@ * limitations under the License. */ export * from './useNotificationsApi'; +export * from './useWebNotifications'; +export * from './useTitleCounter'; diff --git a/plugins/notifications/src/hooks/useTitleCounter.ts b/plugins/notifications/src/hooks/useTitleCounter.ts new file mode 100644 index 0000000000..d4ba22b946 --- /dev/null +++ b/plugins/notifications/src/hooks/useTitleCounter.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useCallback, useEffect, useState } from 'react'; + +/** @public */ +export function useTitleCounter() { + const [title, setTitle] = useState(document.title); + const [count, setCount] = useState(0); + + const getPrefix = (value: number) => { + return value === 0 ? '' : `(${value}) `; + }; + + const cleanTitle = (currentTitle: string) => { + return currentTitle.replace(/^\(\d+\)\s/, ''); + }; + + useEffect(() => { + document.title = title; + }, [title]); + + useEffect(() => { + const baseTitle = cleanTitle(title); + setTitle(`${getPrefix(count)}${baseTitle}`); + return () => { + document.title = cleanTitle(title); + }; + }, [title, count]); + + const titleElement = document.querySelector('title'); + if (titleElement) { + new MutationObserver(() => { + setTitle(document.title); + }).observe(titleElement, { + subtree: true, + characterData: true, + childList: true, + }); + } + + const setNotificationCount = useCallback( + (newCount: number) => setCount(newCount), + [], + ); + + return { setNotificationCount }; +} diff --git a/plugins/notifications/src/hooks/useWebNotifications.ts b/plugins/notifications/src/hooks/useWebNotifications.ts new file mode 100644 index 0000000000..7e34172a97 --- /dev/null +++ b/plugins/notifications/src/hooks/useWebNotifications.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useCallback, useEffect, useState } from 'react'; + +/** @public */ +export function useWebNotifications() { + const [webNotificationPermission, setWebNotificationPermission] = + useState('default'); + const [webNotifications, setWebNotifications] = useState([]); + + useEffect(() => { + if ('Notification' in window && webNotificationPermission === 'default') { + window.Notification.requestPermission().then(permission => { + setWebNotificationPermission(permission); + }); + } + }, [webNotificationPermission]); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + webNotifications.forEach(n => n.close()); + setWebNotifications([]); + } + }); + + const sendWebNotification = useCallback( + (options: { title: string; description: string }) => { + if (webNotificationPermission !== 'granted') { + return null; + } + + const notification = new Notification(options.title, { + body: options.description, + }); + return notification; + }, + [webNotificationPermission], + ); + + return { sendWebNotification }; +} From 50c7dbde2f8da23389fe6ac0f37d3e403c848ef9 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 26 Jan 2024 14:44:30 +0200 Subject: [PATCH 17/25] feat: support for topic in notification using same topic twice will restore existing notification in that topic if any. this can be useful if you want to remind users about some specific thing over time Signed-off-by: Heikki Hellgren --- packages/backend/src/plugins/notifications.ts | 2 + .../migrations/20231215_init.js | 6 +- .../database/DatabaseNotificationsStore.ts | 52 +++++++++++++++++ .../src/database/NotificationsStore.ts | 12 ++++ .../src/service/router.ts | 56 +++++++++++++++---- plugins/notifications-common/api-report.md | 3 + plugins/notifications-common/src/types.ts | 3 + plugins/notifications-node/README.md | 3 + plugins/notifications-node/api-report.md | 1 + .../src/service/DefaultNotificationService.ts | 1 + 10 files changed, 124 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index 5926c292ff..8bfbb593d8 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -21,12 +21,14 @@ 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); diff --git a/plugins/notifications-backend/migrations/20231215_init.js b/plugins/notifications-backend/migrations/20231215_init.js index 08758d06f3..329906ca99 100644 --- a/plugins/notifications-backend/migrations/20231215_init.js +++ b/plugins/notifications-backend/migrations/20231215_init.js @@ -21,9 +21,9 @@ exports.up = async function up(knex) { table.string('title').notNullable(); table.text('description').notNullable(); table.text('link').notNullable(); - table.text('icon').nullable(); - table.text('image').nullable(); - table.datetime('created').notNullable(); + table.text('topic').nullable(); + table.datetime('created').defaultTo(knex.fn.now()).notNullable(); + table.datetime('updated').nullable(); table.datetime('read').nullable(); table.datetime('done').nullable(); table.boolean('saved').defaultTo(false).notNullable(); diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index 533a5b8627..f1915d233b 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -108,6 +108,58 @@ export class DatabaseNotificationsStore implements NotificationsStore { }; } + async getExistingTopicNotification(options: { + user_ref: string; + topic: string; + }) { + const query = this.db('notifications') + .where('userRef', options.user_ref) + .where('topic', options.topic) + .select('*') + .limit(1); + + const rows = await query; + if (!rows || rows.length === 0) { + return null; + } + return rows[0] as Notification; + } + + async restoreExistingNotification(options: { + id: string; + notification: Notification; + }) { + const query = this.db('notifications') + .where('id', options.id) + .where('userRef', options.notification.userRef); + const rows = await query.update({ + title: options.notification.title, + description: options.notification.description, + link: options.notification.link, + topic: options.notification.topic, + updated: options.notification.created, + read: null, + done: null, + }); + + if (!rows) { + return null; + } + + return await this.getNotification(options); + } + + async getNotification(options: { id: string }) { + const rows = await this.db('notifications') + .where('id', options.id) + .select('*') + .limit(1); + if (!rows || rows.length === 0) { + return null; + } + return rows[0] as Notification; + } + async markRead(options: NotificationModifyOptions): Promise { const notificationQuery = this.getNotificationsBaseQuery(options); await notificationQuery.update({ read: new Date() }); diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index c63de65dc5..457b08489a 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -37,6 +37,18 @@ export interface NotificationsStore { saveNotification(notification: Notification): Promise; + getExistingTopicNotification(options: { + user_ref: string; + topic: string; + }): Promise; + + restoreExistingNotification(options: { + id: string; + notification: Notification; + }): Promise; + + getNotification(options: { id: string }): Promise; + getStatus(options: NotificationGetOptions): Promise; markRead(options: NotificationModifyOptions): Promise; diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index f5299931e0..5dd7350c33 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -41,6 +41,7 @@ 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'; /** @public */ export interface RouterOptions { @@ -145,6 +146,22 @@ export async function createRouter( return users; }; + const decorateNotification = async (notification: Notification) => { + let ret: Notification = notification; + for (const processor of processors ?? []) { + ret = processor.decorate ? await processor.decorate(ret) : ret; + } + return ret; + }; + + const processorSendNotification = async (notification: Notification) => { + for (const processor of processors ?? []) { + if (processor.send) { + processor.send(notification); + } + } + }; + const router = Router(); router.use(express.json()); @@ -269,7 +286,7 @@ export async function createRouter( }); router.post('/notifications', async (req, res) => { - const { receivers, title, description, link } = req.body; + const { receivers, title, description, link, topic } = req.body; const notifications = []; let users = []; @@ -298,25 +315,40 @@ export async function createRouter( title, description, link, + topic, created: new Date(), saved: false, }; for (const user of users) { - let notification = { ...baseNotification, id: uuid(), userRef: user }; - for (const processor of processors ?? []) { - notification = processor.decorate - ? await processor.decorate(notification) - : notification; + const userNotification = { + ...baseNotification, + id: uuid(), + userRef: user, + }; + const notification = await decorateNotification(userNotification); + + let existingNotification; + if (topic) { + existingNotification = await store.getExistingTopicNotification({ + user_ref: user, + topic, + }); } - await store.saveNotification(notification); - for (const processor of processors ?? []) { - if (processor.send) { - processor.send(notification); - } + let ret = notification; + if (existingNotification) { + const restored = await store.restoreExistingNotification({ + id: existingNotification.id, + notification, + }); + ret = restored ?? notification; + } else { + await store.saveNotification(notification); } - notifications.push(notification); + + processorSendNotification(ret); + notifications.push(ret); } if (signalService) { diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index 4e34ae2afc..0770c94fae 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -10,8 +10,11 @@ type Notification_2 = { title: string; description: string; link: string; + topic?: string; created: Date; + updated?: Date; read?: Date; + done?: Date; saved: boolean; }; export { Notification_2 as Notification }; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index c19edb9b24..43de9c725f 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -24,8 +24,11 @@ export type Notification = { title: string; description: string; link: string; + topic?: string; created: Date; + updated?: Date; read?: Date; + done?: Date; saved: boolean; }; diff --git a/plugins/notifications-node/README.md b/plugins/notifications-node/README.md index 31fe22baf9..c3d8e93fe7 100644 --- a/plugins/notifications-node/README.md +++ b/plugins/notifications-node/README.md @@ -53,3 +53,6 @@ save the notification and optionally signal the frontend to show the latest stat When sending notifications, you can specify the entity reference of the notification. If the entity reference is a user, the notification will be sent to only that user. If it's a group, the notification will be sent to all members of the group. If it's some other entity, the notification will be sent to the owner of that entity. + +If the notification has `topic` set and user already has notification with that topic, the existing notification +will be updated with the new notification values and moved to inbox as unread. diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 806b0400ac..58e0df5603 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -38,6 +38,7 @@ export type NotificationSendOptions = { title: string; description: string; link: string; + topic?: string; }; // @public (undocumented) diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts index 16de07912a..bffac9e725 100644 --- a/plugins/notifications-node/src/service/DefaultNotificationService.ts +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -38,6 +38,7 @@ export type NotificationSendOptions = { title: string; description: string; link: string; + topic?: string; }; /** @public */ From b3c3672b64a1bb60dbd3c4bfcf01e9f2829466c5 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Thu, 1 Feb 2024 21:19:45 +0200 Subject: [PATCH 18/25] feat: support pagination and searching in notification backend disabled broadcast notifications for now Signed-off-by: Heikki Hellgren --- packages/backend/src/plugins/notifications.ts | 11 ---- .../database/DatabaseNotificationsStore.ts | 23 +++++++++ .../src/database/NotificationsStore.ts | 3 ++ .../src/service/router.ts | 51 ++++++++++++++----- plugins/notifications-node/api-report.md | 12 ++--- .../src/service/DefaultNotificationService.ts | 10 ++-- plugins/notifications/api-report.md | 3 ++ plugins/notifications/package.json | 1 + .../notifications/src/api/NotificationsApi.ts | 3 ++ .../src/api/NotificationsClient.ts | 9 ++++ .../NotificationsPage/NotificationsPage.tsx | 3 +- .../NotificationsSideBarItem.tsx | 50 +++++++++++------- yarn.lock | 1 + 13 files changed, 124 insertions(+), 56 deletions(-) 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 From acbe630b9d561951e32cc62c290389586ad84170 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 2 Feb 2024 13:30:34 +0200 Subject: [PATCH 19/25] feat: follow-up changes on BEP #22641 Signed-off-by: Heikki Hellgren --- packages/backend/src/index.ts | 3 +- .../migrations/20231215_init.js | 7 +- .../database/DatabaseNotificationsStore.ts | 23 +-- .../src/database/NotificationsStore.ts | 7 +- .../src/service/router.ts | 160 ++++++++---------- plugins/notifications-common/api-report.md | 24 ++- plugins/notifications-common/src/types.ts | 32 ++-- plugins/notifications-node/README.md | 7 +- plugins/notifications-node/api-report.md | 19 ++- plugins/notifications-node/src/lib.ts | 8 +- .../src/service/DefaultNotificationService.ts | 36 ++-- .../src/service/NotificationService.ts | 5 +- plugins/notifications/api-report.md | 37 ++-- .../notifications/src/api/NotificationsApi.ts | 23 ++- .../src/api/NotificationsClient.ts | 55 ++---- .../NotificationsTable/NotificationsTable.tsx | 41 +++-- 16 files changed, 236 insertions(+), 251 deletions(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index c140e6dcf5..032bcfbb9f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -105,7 +105,7 @@ function makeCreateEnv(config: Config) { const signalService = DefaultSignalService.create({ eventBroker, }); - const notificationService = DefaultNotificationService.create({ + const defaultNotificationService = DefaultNotificationService.create({ logger: root.child({ type: 'plugin' }), discovery, tokenManager, @@ -119,6 +119,7 @@ function makeCreateEnv(config: Config) { const database = databaseManager.forPlugin(plugin); const cache = cacheManager.forPlugin(plugin); const scheduler = taskScheduler.forPlugin(plugin); + const notificationService = defaultNotificationService.forPlugin(plugin); return { logger, diff --git a/plugins/notifications-backend/migrations/20231215_init.js b/plugins/notifications-backend/migrations/20231215_init.js index 329906ca99..65139e6d07 100644 --- a/plugins/notifications-backend/migrations/20231215_init.js +++ b/plugins/notifications-backend/migrations/20231215_init.js @@ -19,14 +19,17 @@ exports.up = async function up(knex) { table.uuid('id').primary(); table.string('userRef').notNullable(); table.string('title').notNullable(); - table.text('description').notNullable(); + table.text('description').nullable(); + table.text('severity').notNullable(); table.text('link').notNullable(); + table.text('origin').notNullable(); + table.text('scope').nullable(); table.text('topic').nullable(); table.datetime('created').defaultTo(knex.fn.now()).notNullable(); table.datetime('updated').nullable(); table.datetime('read').nullable(); table.datetime('done').nullable(); - table.boolean('saved').defaultTo(false).notNullable(); + table.datetime('saved').nullable(); }); }; diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index a0a262b95b..34fa42423b 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -95,7 +95,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { } } - if ('ids' in options && options.ids) { + if (options.ids) { query.whereIn('id', options.ids); } @@ -131,13 +131,15 @@ export class DatabaseNotificationsStore implements NotificationsStore { }; } - async getExistingTopicNotification(options: { + async getExistingScopeNotification(options: { user_ref: string; - topic: string; + scope: string; + origin: string; }) { const query = this.db('notifications') .where('userRef', options.user_ref) - .where('topic', options.topic) + .where('scope', options.scope) + .where('origin', options.origin) .select('*') .limit(1); @@ -156,11 +158,12 @@ export class DatabaseNotificationsStore implements NotificationsStore { .where('id', options.id) .where('userRef', options.notification.userRef); const rows = await query.update({ - title: options.notification.title, - description: options.notification.description, - link: options.notification.link, - topic: options.notification.topic, + title: options.notification.payload.title, + description: options.notification.payload.description, + link: options.notification.payload.link, + topic: options.notification.payload.topic, updated: options.notification.created, + severity: options.notification.payload.severity, read: null, done: null, }); @@ -205,11 +208,11 @@ export class DatabaseNotificationsStore implements NotificationsStore { async markSaved(options: NotificationModifyOptions): Promise { const notificationQuery = this.getNotificationsBaseQuery(options); - await notificationQuery.update({ saved: true }); + await notificationQuery.update({ saved: new Date() }); } async markUnsaved(options: NotificationModifyOptions): Promise { const notificationQuery = this.getNotificationsBaseQuery(options); - await notificationQuery.update({ saved: false }); + await notificationQuery.update({ saved: null }); } } diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index ec13bc42b0..d24a5e6991 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -23,6 +23,8 @@ import { /** @public */ export type NotificationGetOptions = { user_ref: string; + + ids?: string[]; type?: NotificationType; offset?: number; limit?: number; @@ -40,9 +42,10 @@ export interface NotificationsStore { saveNotification(notification: Notification): Promise; - getExistingTopicNotification(options: { + getExistingScopeNotification(options: { user_ref: string; - topic: string; + scope: string; + origin: string; }): Promise; restoreExistingNotification(options: { diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index e28026abf4..ee66e410c8 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -209,104 +209,67 @@ export async function createRouter( res.send(status); }); - router.post('/done', async (req, res) => { + router.post('/update', async (req, res) => { const user = await getUser(req); - const { ids } = req.body; + const { ids, done, read, saved } = req.body; if (!ids || !Array.isArray(ids)) { res.status(400).send(); return; } - await store.markDone({ user_ref: user, ids }); + if (done === true) { + await store.markDone({ user_ref: user, ids }); + if (signalService) { + await signalService.publish({ + recipients: [user], + message: { action: 'done', notification_ids: ids }, + channel: 'notifications', + }); + } + } else if (done === false) { + await store.markUndone({ user_ref: user, ids }); + if (signalService) { + await signalService.publish({ + recipients: [user], + message: { action: 'undone', notification_ids: ids }, + channel: 'notifications', + }); + } + } - if (signalService) { - await signalService.publish({ - recipients: [user], - message: { action: 'done', notification_ids: ids }, - channel: 'notifications', - }); - } - res.status(200).send({ ids }); - }); + if (read === true) { + await store.markRead({ user_ref: user, ids }); - router.post('/undo', async (req, res) => { - const user = await getUser(req); - const { ids } = req.body; - if (!ids || !Array.isArray(ids)) { - res.status(400).send(); - return; - } - await store.markUndone({ user_ref: user, ids }); - if (signalService) { - await signalService.publish({ - recipients: [user], - message: { action: 'undone', notification_ids: ids }, - channel: 'notifications', - }); - } - res.status(200).send({ ids }); - }); + if (signalService) { + await signalService.publish({ + recipients: [user], + message: { action: 'mark_read', notification_ids: ids }, + channel: 'notifications', + }); + } + } else if (read === false) { + await store.markUnread({ user_ref: user, ids }); - router.post('/read', async (req, res) => { - const user = await getUser(req); - const { ids } = req.body; - if (!ids || !Array.isArray(ids)) { - res.status(400).send(); - return; + if (signalService) { + await signalService.publish({ + recipients: [user], + message: { action: 'mark_unread', notification_ids: ids }, + channel: 'notifications', + }); + } } - await store.markRead({ user_ref: user, ids }); - if (signalService) { - await signalService.publish({ - recipients: [user], - message: { action: 'mark_read', notification_ids: ids }, - channel: 'notifications', - }); + if (saved === true) { + await store.markSaved({ user_ref: user, ids }); + } else if (saved === false) { + await store.markUnsaved({ user_ref: user, ids }); } - res.status(200).send({ ids }); - }); - router.post('/unread', async (req, res) => { - const user = await getUser(req); - const { ids } = req.body; - if (!ids || !Array.isArray(ids)) { - res.status(400).send(); - return; - } - await store.markUnread({ user_ref: user, ids }); - if (signalService) { - await signalService.publish({ - recipients: [user], - message: { action: 'mark_unread', notification_ids: ids }, - channel: 'notifications', - }); - } - res.status(200).send({ ids }); - }); - - router.post('/save', async (req, res) => { - const user = await getUser(req); - const { ids } = req.body; - if (!ids || !Array.isArray(ids)) { - res.status(400).send(); - return; - } - await store.markSaved({ user_ref: user, ids }); - res.status(200).send({ ids }); - }); - - router.post('/unsave', async (req, res) => { - const user = await getUser(req); - const { ids } = req.body; - if (!ids || !Array.isArray(ids)) { - res.status(400).send(); - return; - } - await store.markUnsaved({ user_ref: user, ids }); - res.status(200).send({ ids }); + const notifications = await store.getNotifications({ ids, user_ref: user }); + res.status(200).send(notifications); }); router.post('/notifications', async (req, res) => { - const { receivers, title, description, link, topic } = req.body; + const { recipients, origin, payload } = req.body; const notifications = []; let users = []; @@ -318,9 +281,17 @@ export async function createRouter( return; } + const { title, link, description, scope } = payload; + + if (!recipients || !title || !origin || !link) { + logger.error(`Invalid notification request received`); + res.status(400).send(); + return; + } + let entityRef = null; - if (receivers.entityRef && receivers.type === 'entity') { - entityRef = receivers.entityRef; + if (recipients.entityRef && recipients.type === 'entity') { + entityRef = recipients.entityRef; } try { @@ -331,13 +302,13 @@ export async function createRouter( return; } - const baseNotification = { - title, - description, - link, - topic, + const baseNotification: Omit = { + payload: { + ...payload, + severity: payload.severity ?? 'normal', + }, + origin, created: new Date(), - saved: false, }; for (const user of users) { @@ -349,10 +320,11 @@ export async function createRouter( const notification = await decorateNotification(userNotification); let existingNotification; - if (topic) { - existingNotification = await store.getExistingTopicNotification({ + if (scope) { + existingNotification = await store.getExistingScopeNotification({ user_ref: user, - topic, + scope, + origin, }); } diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index 0770c94fae..9ada705f31 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -7,23 +7,29 @@ type Notification_2 = { id: string; userRef: string; - title: string; - description: string; - link: string; - topic?: string; created: Date; - updated?: Date; + saved?: Date; read?: Date; - done?: Date; - saved: boolean; + updated?: Date; + origin: string; + payload: NotificationPayload; }; export { Notification_2 as Notification }; // @public (undocumented) -export type NotificationIds = { - ids: string[]; +export type NotificationPayload = { + title: string; + description?: string; + link: string; + severity: NotificationSeverity; + topic?: string; + scope?: string; + icon?: string; }; +// @public (undocumented) +export type NotificationSeverity = 'critical' | 'high' | 'normal' | 'low'; + // @public (undocumented) export type NotificationStatus = { unread: number; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index 43de9c725f..c717073403 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -17,19 +17,32 @@ /** @public */ export type NotificationType = 'undone' | 'done' | 'saved'; +/** @public */ +export type NotificationSeverity = 'critical' | 'high' | 'normal' | 'low'; + +/** @public */ +export type NotificationPayload = { + title: string; + description?: string; + link: string; + // TODO: Add support for additional links + // additionalLinks?: string[]; + severity: NotificationSeverity; + topic?: string; + scope?: string; + icon?: string; +}; + /** @public */ export type Notification = { id: string; userRef: string; - title: string; - description: string; - link: string; - topic?: string; created: Date; - updated?: Date; + saved?: Date; read?: Date; - done?: Date; - saved: boolean; + updated?: Date; + origin: string; + payload: NotificationPayload; }; /** @public */ @@ -37,8 +50,3 @@ export type NotificationStatus = { unread: number; read: number; }; - -/** @public */ -export type NotificationIds = { - ids: string[]; -}; diff --git a/plugins/notifications-node/README.md b/plugins/notifications-node/README.md index c3d8e93fe7..60d1e35c24 100644 --- a/plugins/notifications-node/README.md +++ b/plugins/notifications-node/README.md @@ -15,15 +15,18 @@ import { NotificationService } from '@backstage/plugin-notifications-node'; function makeCreateEnv(config: Config) { // ... - const notificationService = DefaultNotificationService.create({ + const defaultNotificationService = DefaultNotificationService.create({ logger: root.child({ type: 'plugin' }), discovery, tokenManager, + signalService, }); // ... return (plugin: string): PluginEnvironment => { // ... + const notificationService = defaultNotificationService.forPlugin(plugin); + return { // ... notificationService, @@ -55,4 +58,4 @@ a user, the notification will be sent to only that user. If it's a group, the no members of the group. If it's some other entity, the notification will be sent to the owner of that entity. If the notification has `topic` set and user already has notification with that topic, the existing notification -will be updated with the new notification values and moved to inbox as unread. +will be updated with the new notification values and moved to inbox as unread. diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 6f655d9f18..17b172a8e5 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -5,7 +5,7 @@ ```ts import { DiscoveryService } from '@backstage/backend-plugin-api'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { NotificationPayload } from '@backstage/plugin-notifications-common'; import { ServiceRef } from '@backstage/backend-plugin-api'; import { SignalService } from '@backstage/plugin-signals-node'; import { TokenManager } from '@backstage/backend-common'; @@ -19,28 +19,29 @@ export class DefaultNotificationService implements NotificationService { discovery, }: NotificationServiceOptions): DefaultNotificationService; // (undocumented) - send(options: NotificationSendOptions): Promise; + forPlugin(pluginId: string): NotificationService; + // (undocumented) + send(notification: NotificationSendOptions): Promise; } // @public (undocumented) -export type NotificationReceivers = { +export type NotificationRecipients = { type: 'entity'; entityRef: string | string[]; }; // @public (undocumented) export type NotificationSendOptions = { - receivers: NotificationReceivers; - title: string; - description: string; - link: string; - topic?: string; + recipients: NotificationRecipients; + payload: NotificationPayload; }; // @public (undocumented) export interface NotificationService { // (undocumented) - send(options: NotificationSendOptions): Promise; + forPlugin(pluginId: string): NotificationService; + // (undocumented) + send(options: NotificationSendOptions): Promise; } // @public (undocumented) diff --git a/plugins/notifications-node/src/lib.ts b/plugins/notifications-node/src/lib.ts index 41773143d2..67bb3c315d 100644 --- a/plugins/notifications-node/src/lib.ts +++ b/plugins/notifications-node/src/lib.ts @@ -30,18 +30,20 @@ export const notificationService = createServiceRef({ createServiceFactory({ service, deps: { - logger: coreServices.logger, + logger: coreServices.rootLogger, discovery: coreServices.discovery, tokenManager: coreServices.tokenManager, + pluginMetadata: coreServices.pluginMetadata, signals: signalService, }, - factory({ logger, discovery, tokenManager, signals }) { + factory({ logger, discovery, tokenManager, signals, pluginMetadata }) { + // TODO: Convert to use createRootContext return DefaultNotificationService.create({ logger, discovery, tokenManager, signalService: signals, - }); + }).forPlugin(pluginMetadata.getId()); }, }), }); diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts index d92e92f61c..3348c02a6e 100644 --- a/plugins/notifications-node/src/service/DefaultNotificationService.ts +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Notification } from '@backstage/plugin-notifications-common'; import { TokenManager } from '@backstage/backend-common'; import { NotificationService } from './NotificationService'; import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; import { SignalService } from '@backstage/plugin-signals-node'; +import { NotificationPayload } from '@backstage/plugin-notifications-common'; /** @public */ export type NotificationServiceOptions = { @@ -28,7 +28,7 @@ export type NotificationServiceOptions = { }; /** @public */ -export type NotificationReceivers = { +export type NotificationRecipients = { type: 'entity'; entityRef: string | string[]; }; @@ -38,11 +38,8 @@ export type NotificationReceivers = { /** @public */ export type NotificationSendOptions = { - receivers: NotificationReceivers; - title: string; - description: string; - link: string; - topic?: string; + recipients: NotificationRecipients; + payload: NotificationPayload; }; /** @public */ @@ -51,6 +48,7 @@ export class DefaultNotificationService implements NotificationService { private readonly logger: LoggerService, private readonly discovery: DiscoveryService, private readonly tokenManager: TokenManager, + private readonly pluginId?: string, ) {} static create({ @@ -61,22 +59,36 @@ export class DefaultNotificationService implements NotificationService { return new DefaultNotificationService(logger, discovery, tokenManager); } - async send(options: NotificationSendOptions): Promise { + forPlugin(pluginId: string): NotificationService { + return new DefaultNotificationService( + this.logger, + this.discovery, + this.tokenManager, + pluginId, + ); + } + + async send(notification: NotificationSendOptions): Promise { + if (!this.pluginId) { + throw new Error('Invalid initialization of the NotificationService'); + } + try { const baseUrl = await this.discovery.getBaseUrl('notifications'); const { token } = await this.tokenManager.getToken(); - const response = await fetch(`${baseUrl}/notifications`, { + await fetch(`${baseUrl}/notifications`, { method: 'POST', - body: JSON.stringify(options), + body: JSON.stringify({ + ...notification, + origin: `plugin-${this.pluginId}`, + }), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, }); - return await response.json(); } catch (error) { this.logger.error(`Failed to send notifications: ${error}`); - return []; } } } diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts index 68b0c13a9e..5c76dd8088 100644 --- a/plugins/notifications-node/src/service/NotificationService.ts +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -15,9 +15,10 @@ */ import { NotificationSendOptions } from './DefaultNotificationService'; -import { Notification } from '@backstage/plugin-notifications-common'; /** @public */ export interface NotificationService { - send(options: NotificationSendOptions): Promise; + forPlugin(pluginId: string): NotificationService; + + send(options: NotificationSendOptions): Promise; } diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index 93c6eaedf7..3d96934438 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -11,7 +11,6 @@ import { DiscoveryApi } from '@backstage/core-plugin-api'; import { FetchApi } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; -import { NotificationIds } from '@backstage/plugin-notifications-common'; import { NotificationStatus } from '@backstage/plugin-notifications-common'; import { NotificationType } from '@backstage/plugin-notifications-common'; import { default as React_2 } from 'react'; @@ -34,17 +33,9 @@ export interface NotificationsApi { // (undocumented) getStatus(): Promise; // (undocumented) - markDone(ids: string[]): Promise; - // (undocumented) - markRead(ids: string[]): Promise; - // (undocumented) - markSaved(ids: string[]): Promise; - // (undocumented) - markUndone(ids: string[]): Promise; - // (undocumented) - markUnread(ids: string[]): Promise; - // (undocumented) - markUnsaved(ids: string[]): Promise; + updateNotifications( + options: UpdateNotificationsOptions, + ): Promise; } // @public (undocumented) @@ -60,17 +51,9 @@ export class NotificationsClient implements NotificationsApi { // (undocumented) getStatus(): Promise; // (undocumented) - markDone(ids: string[]): Promise; - // (undocumented) - markRead(ids: string[]): Promise; - // (undocumented) - markSaved(ids: string[]): Promise; - // (undocumented) - markUndone(ids: string[]): Promise; - // (undocumented) - markUnread(ids: string[]): Promise; - // (undocumented) - markUnsaved(ids: string[]): Promise; + updateNotifications( + options: UpdateNotificationsOptions, + ): Promise; } // @public (undocumented) @@ -94,6 +77,14 @@ export const NotificationsTable: (props: { notifications?: Notification_2[]; }) => React_2.JSX.Element; +// @public (undocumented) +export type UpdateNotificationsOptions = { + ids: string[]; + done?: boolean; + read?: boolean; + saved?: boolean; +}; + // @public (undocumented) export function useNotificationsApi( f: (api: NotificationsApi) => Promise, diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts index 95d8cd6297..775dc6b244 100644 --- a/plugins/notifications/src/api/NotificationsApi.ts +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -16,7 +16,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; import { Notification, - NotificationIds, NotificationStatus, NotificationType, } from '@backstage/plugin-notifications-common'; @@ -34,21 +33,21 @@ export type GetNotificationsOptions = { search?: string; }; +/** @public */ +export type UpdateNotificationsOptions = { + ids: string[]; + done?: boolean; + read?: boolean; + saved?: boolean; +}; + /** @public */ export interface NotificationsApi { getNotifications(options?: GetNotificationsOptions): Promise; getStatus(): Promise; - markDone(ids: string[]): Promise; - - markUndone(ids: string[]): Promise; - - markRead(ids: string[]): Promise; - - markUnread(ids: string[]): Promise; - - markSaved(ids: string[]): Promise; - - markUnsaved(ids: string[]): Promise; + updateNotifications( + options: UpdateNotificationsOptions, + ): Promise; } diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index a4398e4cb8..3dd5651ec5 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -13,12 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { GetNotificationsOptions, NotificationsApi } from './NotificationsApi'; +import { + GetNotificationsOptions, + NotificationsApi, + UpdateNotificationsOptions, +} from './NotificationsApi'; import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { Notification, - NotificationIds, NotificationStatus, } from '@backstage/plugin-notifications-common'; @@ -61,50 +64,12 @@ export class NotificationsClient implements NotificationsApi { return await this.request('status'); } - async markDone(ids: string[]): Promise { - return await this.request('done', { + async updateNotifications( + options: UpdateNotificationsOptions, + ): Promise { + return await this.request('update', { method: 'POST', - body: JSON.stringify({ ids: ids }), - headers: { 'Content-Type': 'application/json' }, - }); - } - - async markUndone(ids: string[]): Promise { - return await this.request('undone', { - method: 'POST', - body: JSON.stringify({ ids: ids }), - headers: { 'Content-Type': 'application/json' }, - }); - } - - async markRead(ids: string[]): Promise { - return await this.request('read', { - method: 'POST', - body: JSON.stringify({ ids: ids }), - headers: { 'Content-Type': 'application/json' }, - }); - } - - async markUnread(ids: string[]): Promise { - return await this.request('unread', { - method: 'POST', - body: JSON.stringify({ ids: ids }), - headers: { 'Content-Type': 'application/json' }, - }); - } - - async markSaved(ids: string[]): Promise { - return await this.request('save', { - method: 'POST', - body: JSON.stringify({ ids: ids }), - headers: { 'Content-Type': 'application/json' }, - }); - } - - async markUnsaved(ids: string[]): Promise { - return await this.request('unsave', { - method: 'POST', - body: JSON.stringify({ ids: ids }), + body: JSON.stringify(options), headers: { 'Content-Type': 'application/json' }, }); } diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx index 75d273bfc9..8328da1c6d 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -148,7 +148,7 @@ export const NotificationsTable = (props: { startIcon={} onClick={() => { notificationsApi - .markUndone(selected) + .updateNotifications({ ids: selected, done: false }) .then(() => props.onUpdate()); setSelected([]); }} @@ -162,7 +162,7 @@ export const NotificationsTable = (props: { startIcon={} onClick={() => { notificationsApi - .markDone(selected) + .updateNotifications({ ids: selected, done: true }) .then(() => props.onUpdate()); setSelected([]); }} @@ -197,16 +197,16 @@ export const NotificationsTable = (props: { notificationsApi - .markRead([notification.id]) - .then(() => navigate(notification.link)) + .updateNotifications({ ids: [notification.id], read: true }) + .then(() => navigate(notification.payload.link)) } style={{ paddingLeft: 0 }} > - {notification.title} + {notification.payload.title} - {notification.description} + {notification.payload.description} @@ -214,13 +214,16 @@ export const NotificationsTable = (props: { - + notificationsApi - .markRead([notification.id]) - .then(() => navigate(notification.link)) + .updateNotifications({ + ids: [notification.id], + read: true, + }) + .then(() => navigate(notification.payload.link)) } > @@ -234,13 +237,19 @@ export const NotificationsTable = (props: { onClick={() => { if (notification.read) { notificationsApi - .markUndone([notification.id]) + .updateNotifications({ + ids: [notification.id], + done: false, + }) .then(() => { props.onUpdate(); }); } else { notificationsApi - .markDone([notification.id]) + .updateNotifications({ + ids: [notification.id], + done: true, + }) .then(() => { props.onUpdate(); }); @@ -262,13 +271,19 @@ export const NotificationsTable = (props: { onClick={() => { if (notification.saved) { notificationsApi - .markUnsaved([notification.id]) + .updateNotifications({ + ids: [notification.id], + saved: false, + }) .then(() => { props.onUpdate(); }); } else { notificationsApi - .markSaved([notification.id]) + .updateNotifications({ + ids: [notification.id], + saved: true, + }) .then(() => { props.onUpdate(); }); From 819a7302a2a4afecdb3624e823caeebd3a6f1a41 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Mon, 5 Feb 2024 10:37:50 +0200 Subject: [PATCH 20/25] fix: code review findings Signed-off-by: Heikki Hellgren --- packages/backend-next/package.json | 2 + packages/backend-next/src/index.ts | 2 + packages/backend/src/index.ts | 14 - packages/backend/src/plugins/notifications.ts | 32 -- packages/backend/src/types.ts | 2 - plugins/notifications-backend/README.md | 35 +- plugins/notifications-backend/api-report.md | 11 +- .../migrations/20231215_init.js | 17 +- plugins/notifications-backend/package.json | 1 + .../DatabaseNotificationsStore.test.ts | 338 ++++++++++++++++++ .../database/DatabaseNotificationsStore.ts | 114 +++--- .../src/database/NotificationsStore.ts | 7 +- plugins/notifications-backend/src/index.ts | 3 +- plugins/notifications-backend/src/plugin.ts | 29 ++ .../src/service/router.ts | 59 +-- plugins/notifications-common/api-report.md | 3 +- plugins/notifications-common/src/types.ts | 3 +- plugins/notifications-node/api-report.md | 30 +- .../src/extensions.ts} | 28 +- plugins/notifications-node/src/index.ts | 1 + plugins/notifications-node/src/lib.ts | 11 +- .../src/service/DefaultNotificationService.ts | 30 +- .../src/service/NotificationService.ts | 2 - plugins/notifications/api-report.md | 5 +- plugins/notifications/config.d.ts | 31 -- plugins/notifications/package.json | 6 +- .../NotificationsSideBarItem.tsx | 23 +- plugins/signals-node/api-report.md | 4 +- plugins/signals-node/src/SignalService.ts | 4 +- plugins/signals-react/api-report.md | 15 +- plugins/signals-react/src/api/SignalApi.ts | 11 +- plugins/signals/src/api/SignalClient.ts | 28 +- yarn.lock | 3 + 33 files changed, 608 insertions(+), 296 deletions(-) delete mode 100644 packages/backend/src/plugins/notifications.ts create mode 100644 plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts rename plugins/{notifications-backend/src/types.ts => notifications-node/src/extensions.ts} (66%) delete mode 100644 plugins/notifications/config.d.ts diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index eca36208a9..2743a46335 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -45,6 +45,7 @@ "@backstage/plugin-lighthouse-backend": "workspace:^", "@backstage/plugin-linguist-backend": "workspace:^", "@backstage/plugin-nomad-backend": "workspace:^", + "@backstage/plugin-notifications-backend": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", "@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", @@ -57,6 +58,7 @@ "@backstage/plugin-search-backend-module-explore": "workspace:^", "@backstage/plugin-search-backend-module-techdocs": "workspace:^", "@backstage/plugin-search-backend-node": "workspace:^", + "@backstage/plugin-signals-backend": "workspace:^", "@backstage/plugin-sonarqube-backend": "workspace:^", "@backstage/plugin-techdocs-backend": "workspace:^", "@backstage/plugin-todo-backend": "workspace:^" diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index 53a51fcfa7..9c61b98ec4 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -51,5 +51,7 @@ backend.add(import('@backstage/plugin-search-backend/alpha')); backend.add(import('@backstage/plugin-techdocs-backend/alpha')); backend.add(import('@backstage/plugin-todo-backend')); backend.add(import('@backstage/plugin-sonarqube-backend')); +backend.add(import('@backstage/plugin-signals-backend')); +backend.add(import('@backstage/plugin-notifications-backend')); backend.start(); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 032bcfbb9f..bade028597 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -66,7 +66,6 @@ import linguist from './plugins/linguist'; import devTools from './plugins/devtools'; import nomad from './plugins/nomad'; import signals from './plugins/signals'; -import notifications from './plugins/notifications'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; @@ -75,7 +74,6 @@ import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { metrics } from '@opentelemetry/api'; import { DefaultSignalService } from '@backstage/plugin-signals-node'; -import { DefaultNotificationService } from '@backstage/plugin-notifications-node'; // Expose opentelemetry metrics using a Prometheus exporter on // http://localhost:9464/metrics . See prometheus.yml in packages/backend for @@ -105,12 +103,6 @@ function makeCreateEnv(config: Config) { const signalService = DefaultSignalService.create({ eventBroker, }); - const defaultNotificationService = DefaultNotificationService.create({ - logger: root.child({ type: 'plugin' }), - discovery, - tokenManager, - signalService, - }); root.info(`Created UrlReader ${reader}`); @@ -119,7 +111,6 @@ function makeCreateEnv(config: Config) { const database = databaseManager.forPlugin(plugin); const cache = cacheManager.forPlugin(plugin); const scheduler = taskScheduler.forPlugin(plugin); - const notificationService = defaultNotificationService.forPlugin(plugin); return { logger, @@ -134,7 +125,6 @@ function makeCreateEnv(config: Config) { scheduler, identity, signalService, - notificationService, }; }; } @@ -189,9 +179,6 @@ async function main() { const devToolsEnv = useHotMemoize(module, () => createEnv('devtools')); const nomadEnv = useHotMemoize(module, () => createEnv('nomad')); const signalsEnv = useHotMemoize(module, () => createEnv('signals')); - const notificationsEnv = useHotMemoize(module, () => - createEnv('notifications'), - ); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -219,7 +206,6 @@ async function main() { apiRouter.use('/devtools', await devTools(devToolsEnv)); apiRouter.use('/nomad', await nomad(nomadEnv)); apiRouter.use('/signals', await signals(signalsEnv)); - apiRouter.use('/notifications', await notifications(notificationsEnv)); apiRouter.use(notFoundHandler()); await lighthouse(lighthouseEnv); diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts deleted file mode 100644 index 8bf5f9a740..0000000000 --- a/packages/backend/src/plugins/notifications.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createRouter } from '@backstage/plugin-notifications-backend'; -import { Router } from 'express'; -import { PluginEnvironment } from '../types'; - -export default async function createPlugin( - env: PluginEnvironment, -): Promise { - return await createRouter({ - logger: env.logger, - identity: env.identity, - tokenManager: env.tokenManager, - database: env.database, - discovery: env.discovery, - signalService: env.signalService, - }); -} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 6a48804d1c..d76e68c1c9 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -28,7 +28,6 @@ import { IdentityApi } from '@backstage/plugin-auth-node'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { EventBroker } from '@backstage/plugin-events-node'; import { SignalService } from '@backstage/plugin-signals-node'; -import { NotificationService } from '@backstage/plugin-notifications-node'; export type PluginEnvironment = { logger: Logger; @@ -43,5 +42,4 @@ export type PluginEnvironment = { identity: IdentityApi; eventBroker: EventBroker; signalService: SignalService; - notificationService: NotificationService; }; diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md index 376d38c855..e12f540aa5 100644 --- a/plugins/notifications-backend/README.md +++ b/plugins/notifications-backend/README.md @@ -7,39 +7,12 @@ Welcome to the notifications backend plugin! First you have to install `@backstage/plugin-notifications-node` and `@backstage/plugin-signals-node` packages. -Then create a new file to `packages/backend/src/plugins/notifications.ts`: +Add the notifications to your backend: ```ts -import { createRouter } from '@backstage/plugin-notifications-backend'; -import { Router } from 'express'; -import { PluginEnvironment } from '../types'; - -export default async function createPlugin( - env: PluginEnvironment, -): Promise { - return await createRouter({ - logger: env.logger, - identity: env.identity, - tokenManager: env.tokenManager, - database: env.database, - discovery: env.discovery, - signalService: env.signalService, - }); -} -``` - -and add it to `packages/backend/src/index.ts`: - -```ts -async function main() { - //... - const notificationsEnv = useHotMemoize(module, () => - createEnv('notifications'), - ); - - // ... - apiRouter.use('/notifications', await notifications(notificationsEnv)); -} +const backend = createBackend(); +// ... +backend.add(import('@backstage/plugin-notifications-backend')); ``` ## Extending Notifications diff --git a/plugins/notifications-backend/api-report.md b/plugins/notifications-backend/api-report.md index b11c2269bd..4828db197b 100644 --- a/plugins/notifications-backend/api-report.md +++ b/plugins/notifications-backend/api-report.md @@ -9,7 +9,7 @@ import { DiscoveryService } from '@backstage/backend-plugin-api'; import express from 'express'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { NotificationProcessor } from '@backstage/plugin-notifications-node'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { SignalService } from '@backstage/plugin-signals-node'; import { TokenManager } from '@backstage/backend-common'; @@ -17,14 +17,9 @@ import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; -// @public (undocumented) -export type NotificationProcessor = { - decorate?(notification: Notification_2): Promise; - send?(notification: Notification_2): Promise; -}; - // @public -export const notificationsPlugin: () => BackendFeature; +const notificationsPlugin: () => BackendFeature; +export default notificationsPlugin; // @public (undocumented) export interface RouterOptions { diff --git a/plugins/notifications-backend/migrations/20231215_init.js b/plugins/notifications-backend/migrations/20231215_init.js index 65139e6d07..a1e57237b6 100644 --- a/plugins/notifications-backend/migrations/20231215_init.js +++ b/plugins/notifications-backend/migrations/20231215_init.js @@ -15,21 +15,24 @@ */ exports.up = async function up(knex) { - await knex.schema.createTable('notifications', table => { + await knex.schema.createTable('notification', table => { table.uuid('id').primary(); - table.string('userRef').notNullable(); + table.string('user', 255).notNullable(); table.string('title').notNullable(); table.text('description').nullable(); - table.text('severity').notNullable(); + table.string('severity', 8).notNullable(); table.text('link').notNullable(); - table.text('origin').notNullable(); - table.text('scope').nullable(); - table.text('topic').nullable(); + table.string('origin', 255).notNullable(); + table.string('scope', 255).nullable(); + table.string('topic', 255).nullable(); table.datetime('created').defaultTo(knex.fn.now()).notNullable(); table.datetime('updated').nullable(); table.datetime('read').nullable(); table.datetime('done').nullable(); table.datetime('saved').nullable(); + + table.index(['user'], 'notification_user_idx'); + table.index(['scope', 'origin'], 'notification_scope_origin_idx'); }); }; @@ -37,5 +40,5 @@ exports.up = async function up(knex) { * @param {import('knex').Knex} knex */ exports.down = async function down(knex) { - await knex.schema.dropTable('notifications'); + await knex.schema.dropTable('notification'); }; diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index 810fa95085..89cf7a1e57 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -43,6 +43,7 @@ "yn": "^4.0.0" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@types/supertest": "^2.0.8", "msw": "^1.0.0", diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts new file mode 100644 index 0000000000..35964ff582 --- /dev/null +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts @@ -0,0 +1,338 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { DatabaseNotificationsStore } from './DatabaseNotificationsStore'; +import { Knex } from 'knex'; +import { v4 as uuid } from 'uuid'; +import { Notification } from '@backstage/plugin-notifications-common'; + +jest.setTimeout(60_000); + +const databases = TestDatabases.create(); + +async function createStore(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + const mgr = { + getClient: async () => knex, + migrations: { + skip: false, + }, + }; + return { + knex, + storage: await DatabaseNotificationsStore.create({ database: mgr }), + }; +} + +const user = 'user:default/john.doe'; +const testNotification: Partial = { + user, + created: new Date(), + origin: 'plugin-test', + payload: { + title: 'Notification 1', + link: '/catalog', + severity: 'normal', + }, +}; + +const otherUserNotification: Partial = { + ...testNotification, + user: 'user:default/jane.doe', +}; + +describe.each(databases.eachSupportedId())( + 'DatabaseNotificationsStore (%s)', + databaseId => { + let storage: DatabaseNotificationsStore; + let knex: Knex; + const insertNotification = async ( + notification: Partial & { + id: string; + done?: Date; + saved?: Date; + read?: Date; + }, + ) => + ( + await knex('notification') + .insert({ + id: notification.id, + user: notification.user, + origin: notification.origin, + created: notification.created, + link: notification.payload?.link, + title: notification.payload?.title, + severity: notification.payload?.severity, + scope: notification.payload?.scope, + done: notification.done, + saved: notification.saved, + read: notification.read, + }) + .returning('id') + )[0].id ?? -1; + + beforeAll(async () => { + ({ storage, knex } = await createStore(databaseId)); + }); + + afterEach(async () => { + jest.resetAllMocks(); + await knex('notification').del(); + }); + + describe('getNotifications', () => { + it('should return all notifications for user', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ id: id1, ...testNotification }); + await insertNotification({ id: id2, ...testNotification }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const notifications = await storage.getNotifications({ user }); + expect(notifications.length).toBe(2); + }); + + it('should return undone notifications for user', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + done: new Date(), + }); + await insertNotification({ id: id2, ...testNotification }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const notifications = await storage.getNotifications({ + user, + type: 'undone', + }); + expect(notifications.length).toBe(1); + expect(notifications.at(0)?.id).toEqual(id2); + }); + + it('should return done notifications for user', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + done: new Date(), + }); + await insertNotification({ id: id2, ...testNotification }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const notifications = await storage.getNotifications({ + user, + type: 'done', + }); + expect(notifications.length).toBe(1); + expect(notifications.at(0)?.id).toEqual(id1); + }); + + it('should allow searching for notifications', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + payload: { + link: '/catalog', + severity: 'normal', + title: 'Please find me', + }, + }); + await insertNotification({ id: id2, ...testNotification }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const notifications = await storage.getNotifications({ + user, + search: 'find me', + }); + expect(notifications.length).toBe(1); + expect(notifications.at(0)?.id).toEqual(id1); + }); + }); + + describe('getStatus', () => { + it('should return status for user', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + read: new Date(), + }); + await insertNotification({ id: id2, ...testNotification }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const status = await storage.getStatus({ user }); + expect(status.read).toEqual(1); + expect(status.unread).toEqual(1); + }); + }); + + describe('getExistingScopeNotification', () => { + it('should return existing scope notification', async () => { + const id1 = uuid(); + const notification: any = { + ...testNotification, + id: id1, + payload: { + title: 'Notification', + link: '/scaffolder/task/1234', + severity: 'normal', + scope: 'scaffolder-1234', + }, + }; + await insertNotification(notification); + + const existing = await storage.getExistingScopeNotification({ + user, + origin: 'plugin-test', + scope: 'scaffolder-1234', + }); + expect(existing).not.toBeNull(); + expect(existing?.id).toEqual(id1); + }); + }); + + describe('restoreExistingNotification', () => { + it('should return restore existing scope notification', async () => { + const id1 = uuid(); + const notification: any = { + ...testNotification, + id: id1, + read: new Date(), + done: new Date(), + payload: { + title: 'Notification', + link: '/scaffolder/task/1234', + severity: 'normal', + scope: 'scaffolder-1234', + }, + }; + await insertNotification(notification); + + const existing = await storage.restoreExistingNotification({ + id: id1, + notification: { + user: notification.user, + payload: { + title: 'New notification', + link: '/scaffolder/task/1234', + severity: 'normal', + }, + } as any, + }); + expect(existing).not.toBeNull(); + expect(existing?.id).toEqual(id1); + expect(existing?.payload.title).toEqual('New notification'); + expect(existing?.done).toBeNull(); + expect(existing?.read).toBeNull(); + }); + }); + + describe('getNotification', () => { + it('should return notification by id', async () => { + const id1 = uuid(); + await insertNotification({ id: id1, ...testNotification }); + + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.id).toEqual(id1); + }); + }); + + describe('markRead', () => { + it('should mark notification read', async () => { + const id1 = uuid(); + await insertNotification({ id: id1, ...testNotification }); + + await storage.markRead({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.read).not.toBeNull(); + }); + }); + + describe('markUnread', () => { + it('should mark notification unread', async () => { + const id1 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + read: new Date(), + }); + + await storage.markUnread({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.read).toBeNull(); + }); + }); + + describe('markDone', () => { + it('should mark notification done', async () => { + const id1 = uuid(); + await insertNotification({ id: id1, ...testNotification }); + + await storage.markDone({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.done).not.toBeNull(); + }); + }); + + describe('markUndone', () => { + it('should mark notification undone', async () => { + const id1 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + done: new Date(), + }); + + await storage.markUndone({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.done).toBeNull(); + }); + }); + + describe('markSaved', () => { + it('should mark notification saved', async () => { + const id1 = uuid(); + await insertNotification({ id: id1, ...testNotification }); + + await storage.markSaved({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.saved).not.toBeNull(); + }); + }); + + describe('markUnsaved', () => { + it('should mark notification not saved', async () => { + const id1 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + saved: new Date(), + }); + + await storage.markUnsaved({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.saved).toBeNull(); + }); + }); + }, +); diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index 34fa42423b..a02c8fcae2 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -56,20 +56,46 @@ export class DatabaseNotificationsStore implements NotificationsStore { return typeof val === 'string' ? Number.parseInt(val, 10) : val ?? 0; }; + private mapToNotifications = (rows: any[]): Notification[] => { + return rows.map(row => ({ + id: row.id, + user: row.user, + created: row.created, + done: row.done, + saved: row.saved, + read: row.read, + updated: row.updated, + origin: row.origin, + payload: { + title: row.title, + description: row.description, + link: row.link, + topic: row.topic, + severity: row.severity, + scope: row.scope, + icon: row.icon, + }, + })); + }; + private getNotificationsBaseQuery = ( options: NotificationGetOptions | NotificationModifyOptions, ) => { - const { user_ref, type } = options; - const query = this.db('notifications') - .where('userRef', user_ref) - .orderBy('created', 'desc'); + const { user, type } = options; + const query = this.db('notification').where('user', user); + + if (options.sort !== undefined && options.sort !== null) { + query.orderBy(options.sort, options.sortOrder ?? 'desc'); + } else if (options.sort !== null) { + query.orderBy('created', options.sortOrder ?? 'desc'); + } if (type === 'undone') { query.whereNull('done'); } else if (type === 'done') { query.whereNotNull('done'); } else if (type === 'saved') { - query.where('saved', true); + query.whereNotNull('saved'); } if (options.limit) { @@ -81,22 +107,14 @@ export class DatabaseNotificationsStore implements NotificationsStore { } 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}%`], - ); - } + query.whereRaw( + `(LOWER(notification.title) LIKE LOWER(?) OR LOWER(notification.description) LIKE LOWER(?))`, + [`%${options.search}%`, `%${options.search}%`], + ); } if (options.ids) { - query.whereIn('id', options.ids); + query.whereIn('notification.id', options.ids); } return query; @@ -104,43 +122,50 @@ export class DatabaseNotificationsStore implements NotificationsStore { async getNotifications(options: NotificationGetOptions) { const notificationQuery = this.getNotificationsBaseQuery(options); - const notifications = await notificationQuery.select('*'); - return notifications; + const notifications = await notificationQuery.select(); + return this.mapToNotifications(notifications); } async saveNotification(notification: Notification) { - await this.db.insert(notification).into('notifications'); + await this.db.insert(notification).into('notification'); } async getStatus(options: NotificationGetOptions) { - const notificationQuery = this.getNotificationsBaseQuery(options); - const unreadQuery = await notificationQuery - .clone() - .whereNull('read') - .count('id as UNREAD') - .first(); - const readQuery = await notificationQuery + const notificationQuery = this.getNotificationsBaseQuery({ + ...options, + sort: null, + }); + const readSubQuery = notificationQuery .clone() + .count('id') .whereNotNull('read') - .count('id as READ') + .as('READ'); + const unreadSubQuery = notificationQuery + .clone() + .count('id') + .whereNull('read') + .as('UNREAD'); + + const query = await notificationQuery + .select(readSubQuery, unreadSubQuery) .first(); return { - unread: this.mapToInteger((unreadQuery as any)?.UNREAD), - read: this.mapToInteger((readQuery as any)?.READ), + unread: this.mapToInteger((query as any)?.UNREAD), + read: this.mapToInteger((query as any)?.READ), }; } async getExistingScopeNotification(options: { - user_ref: string; + user: string; scope: string; origin: string; }) { - const query = this.db('notifications') - .where('userRef', options.user_ref) + const query = this.db('notification') + .where('user', options.user) .where('scope', options.scope) .where('origin', options.origin) - .select('*') + .select() .limit(1); const rows = await query; @@ -154,10 +179,11 @@ export class DatabaseNotificationsStore implements NotificationsStore { id: string; notification: Notification; }) { - const query = this.db('notifications') + const query = this.db('notification') .where('id', options.id) - .where('userRef', options.notification.userRef); - const rows = await query.update({ + .where('user', options.notification.user); + + await query.update({ title: options.notification.payload.title, description: options.notification.payload.description, link: options.notification.payload.link, @@ -168,22 +194,18 @@ export class DatabaseNotificationsStore implements NotificationsStore { done: null, }); - if (!rows) { - return null; - } - return await this.getNotification(options); } - async getNotification(options: { id: string }) { - const rows = await this.db('notifications') + async getNotification(options: { id: string }): Promise { + const rows = await this.db('notification') .where('id', options.id) - .select('*') + .select() .limit(1); if (!rows || rows.length === 0) { return null; } - return rows[0] as Notification; + return this.mapToNotifications(rows)[0]; } async markRead(options: NotificationModifyOptions): Promise { diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index d24a5e6991..dfc260e76f 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -22,13 +22,14 @@ import { /** @public */ export type NotificationGetOptions = { - user_ref: string; - + user: string; ids?: string[]; type?: NotificationType; offset?: number; limit?: number; search?: string; + sort?: 'created' | 'read' | 'updated' | null; + sortOrder?: 'asc' | 'desc'; }; /** @public */ @@ -43,7 +44,7 @@ export interface NotificationsStore { saveNotification(notification: Notification): Promise; getExistingScopeNotification(options: { - user_ref: string; + user: string; scope: string; origin: string; }): Promise; diff --git a/plugins/notifications-backend/src/index.ts b/plugins/notifications-backend/src/index.ts index 44891496fd..6e5d3a7c8c 100644 --- a/plugins/notifications-backend/src/index.ts +++ b/plugins/notifications-backend/src/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ export * from './service/router'; -export * from './plugin'; -export type { NotificationProcessor } from './types'; +export { notificationsPlugin as default } from './plugin'; diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts index 3d9937525c..1d8806cf49 100644 --- a/plugins/notifications-backend/src/plugin.ts +++ b/plugins/notifications-backend/src/plugin.ts @@ -19,6 +19,27 @@ import { } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; import { signalService } from '@backstage/plugin-signals-node'; +import { + NotificationProcessor, + notificationsProcessingExtensionPoint, + NotificationsProcessingExtensionPoint, +} from '@backstage/plugin-notifications-node'; + +class NotificationsProcessingExtensionPointImpl + implements NotificationsProcessingExtensionPoint +{ + #processors = new Array(); + + addProcessor( + ...processors: Array> + ): void { + this.#processors.push(...processors.flat()); + } + + get processors() { + return this.#processors; + } +} /** * Notifications backend plugin @@ -28,6 +49,13 @@ import { signalService } from '@backstage/plugin-signals-node'; export const notificationsPlugin = createBackendPlugin({ pluginId: 'notifications', register(env) { + const processingExtensions = + new NotificationsProcessingExtensionPointImpl(); + env.registerExtensionPoint( + notificationsProcessingExtensionPoint, + processingExtensions, + ); + env.registerInit({ deps: { httpRouter: coreServices.httpRouter, @@ -55,6 +83,7 @@ export const notificationsPlugin = createBackendPlugin({ tokenManager, discovery, signalService: signals, + processors: processingExtensions.processors, }), ); }, diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index ee66e410c8..2176f6854c 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -37,8 +37,8 @@ import { RELATION_HAS_MEMBER, stringifyEntityRef, } from '@backstage/catalog-model'; -import { NotificationProcessor } from '../types'; -import { AuthenticationError } from '@backstage/errors'; +import { NotificationProcessor } from '@backstage/plugin-notifications-node'; +import { AuthenticationError, InputError } from '@backstage/errors'; import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; import { SignalService } from '@backstage/plugin-signals-node'; import { @@ -79,7 +79,10 @@ export async function createRouter( const getUser = async (req: Request) => { const user = await identity.getIdentity({ request: req }); - return user ? user.identity.userEntityRef : 'user:default/guest'; + if (!user) { + throw new AuthenticationError(); + } + return user.identity.userEntityRef; }; const authenticateService = async (req: Request) => { @@ -184,7 +187,7 @@ export async function createRouter( router.get('/notifications', async (req, res) => { const user = await getUser(req); const opts: NotificationGetOptions = { - user_ref: user, + user: user, }; if (req.query.type) { opts.type = req.query.type.toString() as NotificationType; @@ -205,7 +208,7 @@ export async function createRouter( router.get('/status', async (req, res) => { const user = await getUser(req); - const status = await store.getStatus({ user_ref: user, type: 'undone' }); + const status = await store.getStatus({ user, type: 'undone' }); res.send(status); }); @@ -213,11 +216,11 @@ export async function createRouter( const user = await getUser(req); const { ids, done, read, saved } = req.body; if (!ids || !Array.isArray(ids)) { - res.status(400).send(); - return; + throw new InputError(); } + if (done === true) { - await store.markDone({ user_ref: user, ids }); + await store.markDone({ user, ids }); if (signalService) { await signalService.publish({ recipients: [user], @@ -226,7 +229,7 @@ export async function createRouter( }); } } else if (done === false) { - await store.markUndone({ user_ref: user, ids }); + await store.markUndone({ user, ids }); if (signalService) { await signalService.publish({ recipients: [user], @@ -237,7 +240,7 @@ export async function createRouter( } if (read === true) { - await store.markRead({ user_ref: user, ids }); + await store.markRead({ user, ids }); if (signalService) { await signalService.publish({ @@ -247,7 +250,7 @@ export async function createRouter( }); } } else if (read === false) { - await store.markUnread({ user_ref: user, ids }); + await store.markUnread({ user: user, ids }); if (signalService) { await signalService.publish({ @@ -259,15 +262,18 @@ export async function createRouter( } if (saved === true) { - await store.markSaved({ user_ref: user, ids }); + await store.markSaved({ user: user, ids }); } else if (saved === false) { - await store.markUnsaved({ user_ref: user, ids }); + await store.markUnsaved({ user: user, ids }); } - const notifications = await store.getNotifications({ ids, user_ref: user }); + const notifications = await store.getNotifications({ ids, user: user }); res.status(200).send(notifications); }); + // Add new notification + // Allowed only for service-to-service authentication, uses `getUsersForEntityRef` to retrieve recipients for + // specific entity reference router.post('/notifications', async (req, res) => { const { recipients, origin, payload } = req.body; const notifications = []; @@ -276,20 +282,18 @@ export async function createRouter( try { await authenticateService(req); } catch (e) { - logger.error(`Failed to authenticate notification request ${e}`); - res.status(401).send(); - return; + throw new AuthenticationError(); } const { title, link, description, scope } = payload; if (!recipients || !title || !origin || !link) { logger.error(`Invalid notification request received`); - res.status(400).send(); - return; + throw new InputError(); } let entityRef = null; + // TODO: Support for broadcast notifications if (recipients.entityRef && recipients.type === 'entity') { entityRef = recipients.entityRef; } @@ -297,12 +301,10 @@ export async function createRouter( try { users = await getUsersForEntityRef(entityRef); } catch (e) { - logger.error(`Failed to resolve notification receiver ${e}`); - res.status(400).send(); - return; + throw new InputError(); } - const baseNotification: Omit = { + const baseNotification: Omit = { payload: { ...payload, severity: payload.severity ?? 'normal', @@ -311,18 +313,19 @@ export async function createRouter( created: new Date(), }; - for (const user of users) { + const uniqueUsers = [...new Set(users)]; + for (const user of uniqueUsers) { const userNotification = { ...baseNotification, id: uuid(), - userRef: user, + user, }; const notification = await decorateNotification(userNotification); let existingNotification; if (scope) { existingNotification = await store.getExistingScopeNotification({ - user_ref: user, + user, scope, origin, }); @@ -345,7 +348,7 @@ export async function createRouter( if (signalService) { await signalService.publish({ - recipients: entityRef === null ? null : users, + recipients: entityRef === null ? null : uniqueUsers, message: { action: 'new_notification', notification: { title, description, link }, @@ -354,7 +357,7 @@ export async function createRouter( }); } - res.send(notifications); + res.json(notifications); }); router.use(errorHandler()); diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index 9ada705f31..d2dc1afb9d 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -6,10 +6,11 @@ // @public (undocumented) type Notification_2 = { id: string; - userRef: string; + user: string; created: Date; saved?: Date; read?: Date; + done?: Date; updated?: Date; origin: string; payload: NotificationPayload; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index c717073403..1e7469244f 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -36,10 +36,11 @@ export type NotificationPayload = { /** @public */ export type Notification = { id: string; - userRef: string; + user: string; created: Date; saved?: Date; read?: Date; + done?: Date; updated?: Date; origin: string; payload: NotificationPayload; diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 17b172a8e5..75a0fb68fd 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -4,26 +4,30 @@ ```ts import { DiscoveryService } from '@backstage/backend-plugin-api'; -import { LoggerService } from '@backstage/backend-plugin-api'; +import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; import { NotificationPayload } from '@backstage/plugin-notifications-common'; import { ServiceRef } from '@backstage/backend-plugin-api'; -import { SignalService } from '@backstage/plugin-signals-node'; import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) export class DefaultNotificationService implements NotificationService { // (undocumented) static create({ - logger, tokenManager, discovery, + pluginId, }: NotificationServiceOptions): DefaultNotificationService; // (undocumented) - forPlugin(pluginId: string): NotificationService; - // (undocumented) send(notification: NotificationSendOptions): Promise; } +// @public (undocumented) +export interface NotificationProcessor { + decorate?(notification: Notification_2): Promise; + send?(notification: Notification_2): Promise; +} + // @public (undocumented) export type NotificationRecipients = { type: 'entity'; @@ -38,8 +42,6 @@ export type NotificationSendOptions = { // @public (undocumented) export interface NotificationService { - // (undocumented) - forPlugin(pluginId: string): NotificationService; // (undocumented) send(options: NotificationSendOptions): Promise; } @@ -49,9 +51,19 @@ export const notificationService: ServiceRef; // @public (undocumented) export type NotificationServiceOptions = { - logger: LoggerService; discovery: DiscoveryService; tokenManager: TokenManager; - signalService: SignalService; + pluginId: string; }; + +// @public (undocumented) +export interface NotificationsProcessingExtensionPoint { + // (undocumented) + addProcessor( + ...processors: Array> + ): void; +} + +// @public (undocumented) +export const notificationsProcessingExtensionPoint: ExtensionPoint; ``` diff --git a/plugins/notifications-backend/src/types.ts b/plugins/notifications-node/src/extensions.ts similarity index 66% rename from plugins/notifications-backend/src/types.ts rename to plugins/notifications-node/src/extensions.ts index 57fe648f30..f8ab26462d 100644 --- a/plugins/notifications-backend/src/types.ts +++ b/plugins/notifications-node/src/extensions.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,10 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { createExtensionPoint } from '@backstage/backend-plugin-api'; import { Notification } from '@backstage/plugin-notifications-common'; -/** @public */ -export type NotificationProcessor = { +/** + * @public + */ +export interface NotificationProcessor { /** * Decorate notification before sending it * @@ -31,4 +34,21 @@ export type NotificationProcessor = { * @param notification - The notification to send */ send?(notification: Notification): Promise; -}; +} + +/** + * @public + */ +export interface NotificationsProcessingExtensionPoint { + addProcessor( + ...processors: Array> + ): void; +} + +/** + * @public + */ +export const notificationsProcessingExtensionPoint = + createExtensionPoint({ + id: 'notifications.processing', + }); diff --git a/plugins/notifications-node/src/index.ts b/plugins/notifications-node/src/index.ts index 8e1a590b3d..05ca83957b 100644 --- a/plugins/notifications-node/src/index.ts +++ b/plugins/notifications-node/src/index.ts @@ -22,3 +22,4 @@ export * from './service'; export * from './lib'; +export * from './extensions'; diff --git a/plugins/notifications-node/src/lib.ts b/plugins/notifications-node/src/lib.ts index 67bb3c315d..c79d871ff8 100644 --- a/plugins/notifications-node/src/lib.ts +++ b/plugins/notifications-node/src/lib.ts @@ -20,7 +20,6 @@ import { } from '@backstage/backend-plugin-api'; import { DefaultNotificationService } from './service'; import { NotificationService } from './service/NotificationService'; -import { signalService } from '@backstage/plugin-signals-node'; /** @public */ export const notificationService = createServiceRef({ @@ -30,20 +29,16 @@ export const notificationService = createServiceRef({ createServiceFactory({ service, deps: { - logger: coreServices.rootLogger, discovery: coreServices.discovery, tokenManager: coreServices.tokenManager, pluginMetadata: coreServices.pluginMetadata, - signals: signalService, }, - factory({ logger, discovery, tokenManager, signals, pluginMetadata }) { - // TODO: Convert to use createRootContext + factory({ discovery, tokenManager, pluginMetadata }) { return DefaultNotificationService.create({ - logger, discovery, tokenManager, - signalService: signals, - }).forPlugin(pluginMetadata.getId()); + pluginId: pluginMetadata.getId(), + }); }, }), }); diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts index 3348c02a6e..0f28f3bc18 100644 --- a/plugins/notifications-node/src/service/DefaultNotificationService.ts +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -15,16 +15,14 @@ */ import { TokenManager } from '@backstage/backend-common'; import { NotificationService } from './NotificationService'; -import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; -import { SignalService } from '@backstage/plugin-signals-node'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import { NotificationPayload } from '@backstage/plugin-notifications-common'; /** @public */ export type NotificationServiceOptions = { - logger: LoggerService; discovery: DiscoveryService; tokenManager: TokenManager; - signalService: SignalService; + pluginId: string; }; /** @public */ @@ -45,34 +43,20 @@ export type NotificationSendOptions = { /** @public */ export class DefaultNotificationService implements NotificationService { private constructor( - private readonly logger: LoggerService, private readonly discovery: DiscoveryService, private readonly tokenManager: TokenManager, - private readonly pluginId?: string, + private readonly pluginId: string, ) {} static create({ - logger, tokenManager, discovery, + pluginId, }: NotificationServiceOptions): DefaultNotificationService { - return new DefaultNotificationService(logger, discovery, tokenManager); - } - - forPlugin(pluginId: string): NotificationService { - return new DefaultNotificationService( - this.logger, - this.discovery, - this.tokenManager, - pluginId, - ); + return new DefaultNotificationService(discovery, tokenManager, pluginId); } async send(notification: NotificationSendOptions): Promise { - if (!this.pluginId) { - throw new Error('Invalid initialization of the NotificationService'); - } - try { const baseUrl = await this.discovery.getBaseUrl('notifications'); const { token } = await this.tokenManager.getToken(); @@ -80,6 +64,7 @@ export class DefaultNotificationService implements NotificationService { method: 'POST', body: JSON.stringify({ ...notification, + // TODO: Should retrieve this in the backend from service auth instead origin: `plugin-${this.pluginId}`, }), headers: { @@ -88,7 +73,8 @@ export class DefaultNotificationService implements NotificationService { }, }); } catch (error) { - this.logger.error(`Failed to send notifications: ${error}`); + // TODO: Should not throw in optimal case, see BEP + throw new Error(`Failed to send notifications: ${error}`); } } } diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts index 5c76dd8088..08b63599cc 100644 --- a/plugins/notifications-node/src/service/NotificationService.ts +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -18,7 +18,5 @@ import { NotificationSendOptions } from './DefaultNotificationService'; /** @public */ export interface NotificationService { - forPlugin(pluginId: string): NotificationService; - send(options: NotificationSendOptions): Promise; } diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index 3d96934438..e2071dad14 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -68,7 +68,10 @@ export const notificationsPlugin: BackstagePlugin< >; // @public (undocumented) -export const NotificationsSidebarItem: () => React_2.JSX.Element; +export const NotificationsSidebarItem: (props?: { + webNotificationsEnabled?: boolean; + titleCounterEnabled?: boolean; +}) => React_2.JSX.Element; // @public (undocumented) export const NotificationsTable: (props: { diff --git a/plugins/notifications/config.d.ts b/plugins/notifications/config.d.ts deleted file mode 100644 index 0ae7ea2a1c..0000000000 --- a/plugins/notifications/config.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface Config { - /** @visibility frontend */ - notifications?: { - /** - * Enable or disable the Web Notification API for notifications, defaults to false - * @visibility frontend - */ - enableWebNotifications?: boolean; - /** - * Enable or disable the title override to show notification count, defaults to true - * @visibility frontend - */ - enableTitleCounter?: boolean; - }; -} diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index ae641439d9..c7b90d5a1f 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -52,8 +52,6 @@ "msw": "^1.0.0" }, "files": [ - "dist", - "config.d.ts" - ], - "configSchema": "config.d.ts" + "dist" + ] } diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx index e13956d7d6..90fba009a3 100644 --- a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx +++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { useNotificationsApi } from '../../hooks'; import { SidebarItem } from '@backstage/core-components'; import NotificationsIcon from '@material-ui/icons/Notifications'; -import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { useRouteRef } from '@backstage/core-plugin-api'; import { rootRouteRef } from '../../routes'; import { useSignal } from '@backstage/plugin-signals-react'; import { useWebNotifications } from '../../hooks/useWebNotifications'; @@ -25,11 +25,16 @@ import { useTitleCounter } from '../../hooks/useTitleCounter'; import { JsonObject } from '@backstage/types'; /** @public */ -export const NotificationsSidebarItem = () => { +export const NotificationsSidebarItem = (props?: { + webNotificationsEnabled?: boolean; + titleCounterEnabled?: boolean; +}) => { + const { webNotificationsEnabled = false, titleCounterEnabled = true } = + props ?? { webNotificationsEnabled: false, titleCounterEnabled: true }; + const { loading, error, value, retry } = useNotificationsApi(api => api.getStatus(), ); - 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 @@ -37,16 +42,6 @@ export const NotificationsSidebarItem = () => { const { lastSignal } = useSignal('notifications'); const { sendWebNotification } = useWebNotifications(); const [refresh, setRefresh] = React.useState(false); - const webNotificationsEnabled = useMemo( - () => - config.getOptionalBoolean('notifications.enableWebNotifications') ?? - false, - [config], - ); - const titleCounterEnabled = useMemo( - () => config.getOptionalString('notifications.enableTitleCounter') ?? true, - [config], - ); const { setNotificationCount } = useTitleCounter(); useEffect(() => { diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md index 52d7e37dac..cbf4142daf 100644 --- a/plugins/signals-node/api-report.md +++ b/plugins/signals-node/api-report.md @@ -22,9 +22,9 @@ export type SignalPayload = { }; // @public (undocumented) -export type SignalService = { +export interface SignalService { publish(signal: SignalPayload): Promise; -}; +} // @public (undocumented) export const signalService: ServiceRef; diff --git a/plugins/signals-node/src/SignalService.ts b/plugins/signals-node/src/SignalService.ts index f08a12661f..7d021ccf4e 100644 --- a/plugins/signals-node/src/SignalService.ts +++ b/plugins/signals-node/src/SignalService.ts @@ -16,9 +16,9 @@ import { SignalPayload } from './types'; /** @public */ -export type SignalService = { +export interface SignalService { /** * Publishes a message to user refs to specific topic */ publish(signal: SignalPayload): Promise; -}; +} diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md index 438b3e95cd..ef4bc1b7fc 100644 --- a/plugins/signals-react/api-report.md +++ b/plugins/signals-react/api-report.md @@ -7,18 +7,23 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; // @public (undocumented) -export type SignalApi = { +export interface SignalApi { + // (undocumented) subscribe( channel: string, onMessage: (message: JsonObject) => void, - ): { - unsubscribe: () => void; - }; -}; + ): SignalSubscriber; +} // @public (undocumented) export const signalApiRef: ApiRef; +// @public (undocumented) +export interface SignalSubscriber { + // (undocumented) + unsubscribe(): void; +} + // @public (undocumented) export const useSignal: (channel: string) => { lastSignal: JsonObject | null; diff --git a/plugins/signals-react/src/api/SignalApi.ts b/plugins/signals-react/src/api/SignalApi.ts index b37b3ae2f5..b67b2ea0dc 100644 --- a/plugins/signals-react/src/api/SignalApi.ts +++ b/plugins/signals-react/src/api/SignalApi.ts @@ -22,9 +22,14 @@ export const signalApiRef = createApiRef({ }); /** @public */ -export type SignalApi = { +export interface SignalSubscriber { + unsubscribe(): void; +} + +/** @public */ +export interface SignalApi { subscribe( channel: string, onMessage: (message: JsonObject) => void, - ): { unsubscribe: () => void }; -}; + ): SignalSubscriber; +} diff --git a/plugins/signals/src/api/SignalClient.ts b/plugins/signals/src/api/SignalClient.ts index ee4ed8756b..d6c4634264 100644 --- a/plugins/signals/src/api/SignalClient.ts +++ b/plugins/signals/src/api/SignalClient.ts @@ -146,20 +146,6 @@ export class SignalClient implements SignalApi { url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'; this.ws = new WebSocket(url.toString(), token); - this.ws.onmessage = (data: MessageEvent) => { - this.handleMessage(data); - }; - - this.ws.onerror = () => { - this.reconnect(); - }; - - this.ws.onclose = (ev: CloseEvent) => { - if (ev.code !== WS_CLOSE_NORMAL && ev.code !== WS_CLOSE_GOING_AWAY) { - this.reconnect(); - } - }; - // Wait until connection is open let connectSleep = 0; while ( @@ -174,6 +160,20 @@ export class SignalClient implements SignalApi { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error('Connect timeout'); } + + this.ws.onmessage = (data: MessageEvent) => { + this.handleMessage(data); + }; + + this.ws.onerror = () => { + this.reconnect(); + }; + + this.ws.onclose = (ev: CloseEvent) => { + if (ev.code !== WS_CLOSE_NORMAL && ev.code !== WS_CLOSE_GOING_AWAY) { + this.reconnect(); + } + }; } private handleMessage(data: MessageEvent) { diff --git a/yarn.lock b/yarn.lock index 8a1035010a..06d186eaab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7785,6 +7785,7 @@ __metadata: dependencies: "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" @@ -27226,6 +27227,7 @@ __metadata: "@backstage/plugin-lighthouse-backend": "workspace:^" "@backstage/plugin-linguist-backend": "workspace:^" "@backstage/plugin-nomad-backend": "workspace:^" + "@backstage/plugin-notifications-backend": "workspace:^" "@backstage/plugin-permission-backend": "workspace:^" "@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" @@ -27238,6 +27240,7 @@ __metadata: "@backstage/plugin-search-backend-module-explore": "workspace:^" "@backstage/plugin-search-backend-module-techdocs": "workspace:^" "@backstage/plugin-search-backend-node": "workspace:^" + "@backstage/plugin-signals-backend": "workspace:^" "@backstage/plugin-sonarqube-backend": "workspace:^" "@backstage/plugin-techdocs-backend": "workspace:^" "@backstage/plugin-todo-backend": "workspace:^" From 6ea8b0d106b051899752f1235142ec79dc8c22b9 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 6 Feb 2024 09:38:09 +0200 Subject: [PATCH 21/25] test: add tests for notification clients Signed-off-by: Heikki Hellgren --- .../src/service/router.ts | 4 +- plugins/notifications-node/package.json | 4 +- .../DefaultNotificationService.test.ts | 90 +++++++++++++++ .../src/service/DefaultNotificationService.ts | 7 +- .../src/api/NotificationsClient.test.ts | 104 ++++++++++++++++++ .../src/api/NotificationsClient.ts | 6 +- yarn.lock | 2 + 7 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 plugins/notifications-node/src/service/DefaultNotificationService.test.ts create mode 100644 plugins/notifications/src/api/NotificationsClient.test.ts diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 2176f6854c..c834ec2839 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -184,7 +184,7 @@ export async function createRouter( response.json({ status: 'ok' }); }); - router.get('/notifications', async (req, res) => { + router.get('/', async (req, res) => { const user = await getUser(req); const opts: NotificationGetOptions = { user: user, @@ -274,7 +274,7 @@ export async function createRouter( // Add new notification // Allowed only for service-to-service authentication, uses `getUsersForEntityRef` to retrieve recipients for // specific entity reference - router.post('/notifications', async (req, res) => { + router.post('/', async (req, res) => { const { recipients, origin, payload } = req.body; const notifications = []; let users = []; diff --git a/plugins/notifications-node/package.json b/plugins/notifications-node/package.json index fd27d3c63c..b7393c1123 100644 --- a/plugins/notifications-node/package.json +++ b/plugins/notifications-node/package.json @@ -22,7 +22,9 @@ "postpack": "backstage-cli package postpack" }, "devDependencies": { - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "msw": "^1.0.0" }, "files": [ "dist" diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.test.ts b/plugins/notifications-node/src/service/DefaultNotificationService.test.ts new file mode 100644 index 0000000000..a9b204ca7f --- /dev/null +++ b/plugins/notifications-node/src/service/DefaultNotificationService.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { NotificationPayload } from '@backstage/plugin-notifications-common'; +import { + DefaultNotificationService, + NotificationSendOptions, +} from './DefaultNotificationService'; + +const server = setupServer(); + +const testNotification: NotificationPayload = { + title: 'Notification 1', + link: '/catalog', + severity: 'normal', +}; + +describe('DefaultNotificationService', () => { + setupRequestMockHandlers(server); + const mockBaseUrl = 'http://backstage/api/notifications'; + const discoveryApi = { + getBaseUrl: async () => mockBaseUrl, + getExternalBaseUrl: async () => mockBaseUrl, + }; + const tokenManager = { + getToken: async () => ({ token: '1234' }), + authenticate: jest.fn(), + }; + + let service: DefaultNotificationService; + beforeEach(() => { + service = DefaultNotificationService.create({ + discovery: discoveryApi, + tokenManager, + pluginId: 'test', + }); + }); + + describe('getNotifications', () => { + it('should create notification', async () => { + const body: NotificationSendOptions = { + recipients: { type: 'entity', entityRef: ['user:default/john.doe'] }, + payload: testNotification, + }; + + server.use( + rest.post(`${mockBaseUrl}/`, async (req, res, ctx) => { + const json = await req.json(); + expect(json).toEqual({ ...body, origin: 'plugin-test' }); + expect(req.headers.get('Authorization')).toEqual('Bearer 1234'); + return res(ctx.status(200)); + }), + ); + await expect(service.send(body)).resolves.not.toThrow(); + }); + + it('should throw error if failing', async () => { + const body: NotificationSendOptions = { + recipients: { type: 'entity', entityRef: ['user:default/john.doe'] }, + payload: testNotification, + }; + + server.use( + rest.post(`${mockBaseUrl}/`, async (req, res, ctx) => { + const json = await req.json(); + expect(json).toEqual({ ...body, origin: 'plugin-test' }); + expect(req.headers.get('Authorization')).toEqual('Bearer 1234'); + return res(ctx.status(400)); + }), + ); + await expect(service.send(body)).rejects.toThrow(); + }); + }); +}); diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts index 0f28f3bc18..7e5cf58f53 100644 --- a/plugins/notifications-node/src/service/DefaultNotificationService.ts +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -60,7 +60,7 @@ export class DefaultNotificationService implements NotificationService { try { const baseUrl = await this.discovery.getBaseUrl('notifications'); const { token } = await this.tokenManager.getToken(); - await fetch(`${baseUrl}/notifications`, { + const response = await fetch(`${baseUrl}/`, { method: 'POST', body: JSON.stringify({ ...notification, @@ -69,9 +69,14 @@ export class DefaultNotificationService implements NotificationService { }), headers: { 'Content-Type': 'application/json', + Accept: 'application/json', Authorization: `Bearer ${token}`, }, }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } } catch (error) { // TODO: Should not throw in optimal case, see BEP throw new Error(`Failed to send notifications: ${error}`); diff --git a/plugins/notifications/src/api/NotificationsClient.test.ts b/plugins/notifications/src/api/NotificationsClient.test.ts new file mode 100644 index 0000000000..a7a2d1e5a7 --- /dev/null +++ b/plugins/notifications/src/api/NotificationsClient.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { NotificationsClient } from './NotificationsClient'; +import { Notification } from '@backstage/plugin-notifications-common'; + +const server = setupServer(); + +const testNotification: Partial = { + user: 'user:default/john.doe', + origin: 'plugin-test', + payload: { + title: 'Notification 1', + link: '/catalog', + severity: 'normal', + }, +}; + +describe('NotificationsClient', () => { + setupRequestMockHandlers(server); + const mockBaseUrl = 'http://backstage/api/notifications'; + const discoveryApi = { getBaseUrl: async () => mockBaseUrl }; + const fetchApi = new MockFetchApi(); + + let client: NotificationsClient; + beforeEach(() => { + client = new NotificationsClient({ discoveryApi, fetchApi }); + }); + + describe('getNotifications', () => { + const expectedResp = [testNotification]; + + it('should fetch notifications from correct endpoint', async () => { + server.use( + rest.get(`${mockBaseUrl}/`, (_, res, ctx) => + res(ctx.json(expectedResp)), + ), + ); + const response = await client.getNotifications(); + expect(response).toEqual(expectedResp); + }); + + it('should fetch notifications with options', async () => { + server.use( + rest.get(`${mockBaseUrl}/`, (req, res, ctx) => { + expect(req.url.search).toBe( + '?type=undone&limit=10&offset=0&search=find+me', + ); + return res(ctx.json(expectedResp)); + }), + ); + const response = await client.getNotifications({ + type: 'undone', + limit: 10, + offset: 0, + search: 'find me', + }); + expect(response).toEqual(expectedResp); + }); + + it('should fetch status from correct endpoint', async () => { + server.use( + rest.get(`${mockBaseUrl}/status`, (_, res, ctx) => + res(ctx.json({ read: 1, unread: 1 })), + ), + ); + const response = await client.getStatus(); + expect(response).toEqual({ read: 1, unread: 1 }); + }); + + it('should update notifications', async () => { + server.use( + rest.post(`${mockBaseUrl}/update`, async (req, res, ctx) => { + expect(await req.json()).toEqual({ + ids: ['acdaa8ca-262b-43c1-b74b-de06e5f3b3c7'], + done: true, + }); + return res(ctx.json(expectedResp)); + }), + ); + const response = await client.updateNotifications({ + ids: ['acdaa8ca-262b-43c1-b74b-de06e5f3b3c7'], + done: true, + }); + expect(response).toEqual(expectedResp); + }); + }); +}); diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index 3dd5651ec5..fe7a90d3e8 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -45,17 +45,17 @@ export class NotificationsClient implements NotificationsApi { if (options?.type) { queryString.append('type', options.type); } - if (options?.limit) { + if (options?.limit !== undefined) { queryString.append('limit', options.limit.toString(10)); } - if (options?.offset) { + if (options?.offset !== undefined) { queryString.append('offset', options.offset.toString(10)); } if (options?.search) { queryString.append('search', options.search); } - const urlSegment = `notifications?${queryString}`; + const urlSegment = `?${queryString}`; return await this.request(urlSegment); } diff --git a/yarn.lock b/yarn.lock index 06d186eaab..e4224b56d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7830,7 +7830,9 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/plugin-notifications-common": "workspace:^" "@backstage/plugin-signals-node": "workspace:^" + "@backstage/test-utils": "workspace:^" knex: ^3.0.0 + msw: ^1.0.0 uuid: ^8.0.0 languageName: unknown linkType: soft From 765269331dec8399ef40a59f98aa374c8a3fe777 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 6 Feb 2024 10:06:43 +0200 Subject: [PATCH 22/25] docs: add info how to add notification processors Signed-off-by: Heikki Hellgren --- plugins/notifications-backend/README.md | 64 +++++++++++++++++++++++-- plugins/notifications-node/README.md | 62 ++++++++++-------------- 2 files changed, 87 insertions(+), 39 deletions(-) diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md index e12f540aa5..38f8720f17 100644 --- a/plugins/notifications-backend/README.md +++ b/plugins/notifications-backend/README.md @@ -4,9 +4,6 @@ Welcome to the notifications backend plugin! ## Getting started -First you have to install `@backstage/plugin-notifications-node` and `@backstage/plugin-signals-node` -packages. - Add the notifications to your backend: ```ts @@ -15,7 +12,68 @@ const backend = createBackend(); backend.add(import('@backstage/plugin-notifications-backend')); ``` +For users to be able to see notifications in real-time, you have to install also +the signals plugin (`@backstage/plugin-signals-node`, `@backstage/plugin-signals-backend`, and +`@backstage/plugin-signals`). + ## Extending Notifications The notifications can be extended with `NotificationProcessor`. These processors allow to decorate notifications before they are sent or/and send the notifications to external services. + +Start off by creating a notification processor: + +```ts +import { Notification } from '@backstage/plugin-notifications-common'; +import { NotificationProcessor } from '@backstage/plugin-notifications-node'; + +class MyNotificationProcessor implements NotificationProcessor { + async decorate(notification: Notification): Promise { + if (notification.origin === 'plugin-my-plugin') { + notification.payload.icon = 'my-icon'; + } + return notification; + } + + async send(notification: Notification): Promise { + nodemailer.sendEmail({ + from: 'backstage', + to: 'user', + subject: notification.payload.title, + text: notification.payload.description, + }); + } +} +``` + +Both of the processing functions are optional, and you can implement only one of them. + +Add the notification processor to the notification system by: + +```ts +import { notificationsProcessingExtensionPoint } from '@backstage/plugin-notifications-node'; +import { Notification } from '@backstage/plugin-notifications-common'; + +export const myPlugin = createBackendPlugin({ + pluginId: 'myPlugin', + register(env) { + env.registerInit({ + deps: { + notifications: notificationsProcessingExtensionPoint, + // ... + }, + async init({ notifications }) { + // ... + notifications.addProcessor(new MyNotificationProcessor()); + }, + }); + }, +}); +``` + +## Sending notifications + +To be able to send notifications to users, you have to integrate the `@backstage/plugin-notifications-node` +to your application and plugins. +See [README](https://github.com/backstage/backstage/blob/master/plugins/plugin-notifications-node/README.md) +for more information. diff --git a/plugins/notifications-node/README.md b/plugins/notifications-node/README.md index 60d1e35c24..13475ab209 100644 --- a/plugins/notifications-node/README.md +++ b/plugins/notifications-node/README.md @@ -5,44 +5,34 @@ Welcome to the Node.js library package for the notifications plugin! ## Getting Started To be able to send notifications from other backend plugins, the `NotificationService` must be initialized for the -environment. This can be done by adding the following changes to `packages/backend/src/index.ts` and -`packages/backend/src/types.ts`: - -`index.ts`: +environment. Add notification service to your `plugin.ts` as a dependency for init ```ts -import { NotificationService } from '@backstage/plugin-notifications-node'; +import { notificationService } from '@backstage/plugin-notifications-node'; -function makeCreateEnv(config: Config) { - // ... - const defaultNotificationService = DefaultNotificationService.create({ - logger: root.child({ type: 'plugin' }), - discovery, - tokenManager, - signalService, - }); - - // ... - return (plugin: string): PluginEnvironment => { - // ... - const notificationService = defaultNotificationService.forPlugin(plugin); - - return { - // ... - notificationService, - }; - }; -} -``` - -`types.ts`: - -```ts -import { NotificationService } from '@backstage/plugin-notifications-node'; -export type PluginEnvironment = { - // ... - notificationService: NotificationService; -}; +export const myPlugin = createBackendPlugin({ + pluginId: 'myPlugin', + register(env) { + env.registerInit({ + deps: { + config: coreServices.rootConfig, + logger: coreServices.logger, + httpRouter: coreServices.httpRouter, + notificationService: notificationService, + }, + async init({ config, logger, httpRouter, notificationService }) { + httpRouter.use( + await createRouter({ + config, + logger, + permissions, + notificationService, + }), + ); + }, + }); + }, +}); ``` You also need to set up the `@backstage/plugin-notifications-backend` and `@backstage/plugin-notifications` @@ -57,5 +47,5 @@ When sending notifications, you can specify the entity reference of the notifica a user, the notification will be sent to only that user. If it's a group, the notification will be sent to all members of the group. If it's some other entity, the notification will be sent to the owner of that entity. -If the notification has `topic` set and user already has notification with that topic, the existing notification +If the notification has `scope` set and user already has notification with that scope, the existing notification will be updated with the new notification values and moved to inbox as unread. From e6079349d9886baede6bd55e737987ce8738bb29 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 6 Feb 2024 10:34:52 +0200 Subject: [PATCH 23/25] docs: remove link for docs Signed-off-by: Heikki Hellgren --- plugins/notifications-backend/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md index 38f8720f17..233d0e5e21 100644 --- a/plugins/notifications-backend/README.md +++ b/plugins/notifications-backend/README.md @@ -75,5 +75,3 @@ export const myPlugin = createBackendPlugin({ To be able to send notifications to users, you have to integrate the `@backstage/plugin-notifications-node` to your application and plugins. -See [README](https://github.com/backstage/backstage/blob/master/plugins/plugin-notifications-node/README.md) -for more information. From 15769753a05f2c27f8ccb95624c3c832908d72f7 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 6 Feb 2024 10:52:14 +0200 Subject: [PATCH 24/25] fix: move notifications db to internal + clean-up Signed-off-by: Heikki Hellgren --- packages/backend/package.json | 2 - plugins/notifications-backend/api-report.md | 32 ----------- .../database/DatabaseNotificationsStore.ts | 2 +- .../src/database/NotificationsStore.ts | 6 +- plugins/notifications-backend/src/index.ts | 1 - .../src/service/router.ts | 4 +- plugins/notifications/package.json | 4 +- yarn.lock | 57 ++----------------- 8 files changed, 12 insertions(+), 96 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index bb0b0e0ed7..2386d799a9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -55,8 +55,6 @@ "@backstage/plugin-lighthouse-backend": "workspace:^", "@backstage/plugin-linguist-backend": "workspace:^", "@backstage/plugin-nomad-backend": "workspace:^", - "@backstage/plugin-notifications-backend": "workspace:^", - "@backstage/plugin-notifications-node": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-permission-node": "workspace:^", diff --git a/plugins/notifications-backend/api-report.md b/plugins/notifications-backend/api-report.md index 4828db197b..6add3000f1 100644 --- a/plugins/notifications-backend/api-report.md +++ b/plugins/notifications-backend/api-report.md @@ -4,42 +4,10 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; -import { CatalogApi } from '@backstage/catalog-client'; -import { DiscoveryService } from '@backstage/backend-plugin-api'; -import express from 'express'; -import { IdentityApi } from '@backstage/plugin-auth-node'; -import { LoggerService } from '@backstage/backend-plugin-api'; -import { NotificationProcessor } from '@backstage/plugin-notifications-node'; -import { PluginDatabaseManager } from '@backstage/backend-common'; -import { SignalService } from '@backstage/plugin-signals-node'; -import { TokenManager } from '@backstage/backend-common'; - -// @public (undocumented) -export function createRouter(options: RouterOptions): Promise; // @public const notificationsPlugin: () => BackendFeature; export default notificationsPlugin; -// @public (undocumented) -export interface RouterOptions { - // (undocumented) - catalog?: CatalogApi; - // (undocumented) - database: PluginDatabaseManager; - // (undocumented) - discovery: DiscoveryService; - // (undocumented) - identity: IdentityApi; - // (undocumented) - logger: LoggerService; - // (undocumented) - processors?: NotificationProcessor[]; - // (undocumented) - signalService?: SignalService; - // (undocumented) - tokenManager: TokenManager; -} - // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index a02c8fcae2..7e5060106b 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -30,7 +30,7 @@ const migrationsDir = resolvePackagePath( 'migrations', ); -/** @public */ +/** @internal */ export class DatabaseNotificationsStore implements NotificationsStore { private constructor(private readonly db: Knex) {} diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index dfc260e76f..4e3a2fa547 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -20,7 +20,7 @@ import { NotificationType, } from '@backstage/plugin-notifications-common'; -/** @public */ +/** @internal */ export type NotificationGetOptions = { user: string; ids?: string[]; @@ -32,12 +32,12 @@ export type NotificationGetOptions = { sortOrder?: 'asc' | 'desc'; }; -/** @public */ +/** @internal */ export type NotificationModifyOptions = { ids: string[]; } & NotificationGetOptions; -/** @public */ +/** @internal */ export interface NotificationsStore { getNotifications(options: NotificationGetOptions): Promise; diff --git a/plugins/notifications-backend/src/index.ts b/plugins/notifications-backend/src/index.ts index 6e5d3a7c8c..b47321aa39 100644 --- a/plugins/notifications-backend/src/index.ts +++ b/plugins/notifications-backend/src/index.ts @@ -13,5 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './service/router'; export { notificationsPlugin as default } from './plugin'; diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index c834ec2839..b293ea4a1f 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -46,7 +46,7 @@ import { NotificationType, } from '@backstage/plugin-notifications-common'; -/** @public */ +/** @internal */ export interface RouterOptions { logger: LoggerService; identity: IdentityApi; @@ -58,7 +58,7 @@ export interface RouterOptions { processors?: NotificationProcessor[]; } -/** @public */ +/** @internal */ export async function createRouter( options: RouterOptions, ): Promise { diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index c7b90d5a1f..b6177bda8a 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -46,8 +46,8 @@ "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", "@backstage/test-utils": "workspace:^", - "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^12.1.3", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.0.0", "msw": "^1.0.0" }, diff --git a/yarn.lock b/yarn.lock index e4224b56d2..0ded362b83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,7 @@ __metadata: languageName: node linkType: hard -"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.3.2": +"@adobe/css-tools@npm:^4.3.2": version: 4.3.2 resolution: "@adobe/css-tools@npm:4.3.2" checksum: 9667d61d55dc3b0a315c530ae84e016ce5267c4dd8ac00abb40108dc98e07b98e3090ce8b87acd51a41a68d9e84dcccb08cdf21c902572a9cf9dcaf830da4ae3 @@ -7855,8 +7855,8 @@ __metadata: "@material-ui/core": ^4.9.13 "@material-ui/icons": ^4.9.1 "@material-ui/lab": ^4.0.0-alpha.61 - "@testing-library/jest-dom": ^5.10.1 - "@testing-library/react": ^12.1.3 + "@testing-library/jest-dom": ^6.0.0 + "@testing-library/react": ^14.0.0 "@testing-library/user-event": ^14.0.0 "@types/react": ^16.13.1 || ^17.0.0 msw: ^1.0.0 @@ -17675,22 +17675,6 @@ __metadata: languageName: unknown linkType: soft -"@testing-library/dom@npm:^8.0.0": - version: 8.20.1 - resolution: "@testing-library/dom@npm:8.20.1" - dependencies: - "@babel/code-frame": ^7.10.4 - "@babel/runtime": ^7.12.5 - "@types/aria-query": ^5.0.1 - aria-query: 5.1.3 - chalk: ^4.1.0 - dom-accessibility-api: ^0.5.9 - lz-string: ^1.5.0 - pretty-format: ^27.0.2 - checksum: 06fc8dc67849aadb726cbbad0e7546afdf8923bd39acb64c576d706249bd7d0d05f08e08a31913fb621162e3b9c2bd0dce15964437f030f9fa4476326fdd3007 - languageName: node - linkType: hard - "@testing-library/dom@npm:^9.0.0": version: 9.3.4 resolution: "@testing-library/dom@npm:9.3.4" @@ -17707,23 +17691,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/jest-dom@npm:^5.10.1": - version: 5.17.0 - resolution: "@testing-library/jest-dom@npm:5.17.0" - dependencies: - "@adobe/css-tools": ^4.0.1 - "@babel/runtime": ^7.9.2 - "@types/testing-library__jest-dom": ^5.9.1 - aria-query: ^5.0.0 - chalk: ^3.0.0 - css.escape: ^1.5.1 - dom-accessibility-api: ^0.5.6 - lodash: ^4.17.15 - redent: ^3.0.0 - checksum: 9f28dbca8b50d7c306aae40c3aa8e06f0e115f740360004bd87d57f95acf7ab4b4f4122a7399a76dbf2bdaaafb15c99cc137fdcb0ae457a92e2de0f3fbf9b03b - languageName: node - linkType: hard - "@testing-library/jest-dom@npm:^6.0.0": version: 6.3.0 resolution: "@testing-library/jest-dom@npm:6.3.0" @@ -17779,20 +17746,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:^12.1.3": - version: 12.1.5 - resolution: "@testing-library/react@npm:12.1.5" - dependencies: - "@babel/runtime": ^7.12.5 - "@testing-library/dom": ^8.0.0 - "@types/react-dom": <18.0.0 - peerDependencies: - react: <18.0.0 - react-dom: <18.0.0 - checksum: 4abd0490405e709a7df584a0db604e508a4612398bb1326e8fa32dd9393b15badc826dcf6d2f7525437886d507871f719f127b9860ed69ddd204d1fa834f576a - languageName: node - linkType: hard - "@testing-library/react@npm:^14.0.0": version: 14.2.1 resolution: "@testing-library/react@npm:14.2.1" @@ -25327,7 +25280,7 @@ __metadata: languageName: node linkType: hard -"dom-accessibility-api@npm:^0.5.6, dom-accessibility-api@npm:^0.5.9": +"dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" checksum: 005eb283caef57fc1adec4d5df4dd49189b628f2f575af45decb210e04d634459e3f1ee64f18b41e2dcf200c844bc1d9279d80807e686a30d69a4756151ad248 @@ -27283,8 +27236,6 @@ __metadata: "@backstage/plugin-lighthouse-backend": "workspace:^" "@backstage/plugin-linguist-backend": "workspace:^" "@backstage/plugin-nomad-backend": "workspace:^" - "@backstage/plugin-notifications-backend": "workspace:^" - "@backstage/plugin-notifications-node": "workspace:^" "@backstage/plugin-permission-backend": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" From 2fec2198b7dd052f9626235a5a343069f70f8b93 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 6 Feb 2024 11:13:59 +0200 Subject: [PATCH 25/25] fix: move express types to dev deps Signed-off-by: Heikki Hellgren --- plugins/notifications-backend/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index 89cf7a1e57..455acb3bd4 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -33,7 +33,6 @@ "@backstage/plugin-notifications-common": "workspace:^", "@backstage/plugin-notifications-node": "workspace:^", "@backstage/plugin-signals-node": "workspace:^", - "@types/express": "*", "express": "^4.17.1", "express-promise-router": "^4.1.0", "knex": "^3.0.0", @@ -45,6 +44,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@types/express": "^4.17.6", "@types/supertest": "^2.0.8", "msw": "^1.0.0", "supertest": "^6.2.4" diff --git a/yarn.lock b/yarn.lock index 0ded362b83..c9084565ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7796,7 +7796,7 @@ __metadata: "@backstage/plugin-notifications-common": "workspace:^" "@backstage/plugin-notifications-node": "workspace:^" "@backstage/plugin-signals-node": "workspace:^" - "@types/express": "*" + "@types/express": ^4.17.6 "@types/supertest": ^2.0.8 express: ^4.17.1 express-promise-router: ^4.1.0