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 diff --git a/packages/app/package.json b/packages/app/package.json index 31be0e1610..43b12a1cb7 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": "workspace:^", "@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-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/types.ts b/packages/backend/src/types.ts index 3dad2f739b..d76e68c1c9 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -27,7 +27,7 @@ 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'; export type PluginEnvironment = { logger: Logger; @@ -41,5 +41,5 @@ export type PluginEnvironment = { scheduler: PluginTaskScheduler; identity: IdentityApi; eventBroker: EventBroker; - signalService: DefaultSignalService; + signalService: SignalService; }; 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..233d0e5e21 --- /dev/null +++ b/plugins/notifications-backend/README.md @@ -0,0 +1,77 @@ +# notifications + +Welcome to the notifications backend plugin! + +## Getting started + +Add the notifications to your backend: + +```ts +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. diff --git a/plugins/notifications-backend/api-report.md b/plugins/notifications-backend/api-report.md new file mode 100644 index 0000000000..6add3000f1 --- /dev/null +++ b/plugins/notifications-backend/api-report.md @@ -0,0 +1,13 @@ +## 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 { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public +const notificationsPlugin: () => BackendFeature; +export default notificationsPlugin; + +// (No @packageDocumentation comment for this package) +``` 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-backend/migrations/20231215_init.js b/plugins/notifications-backend/migrations/20231215_init.js new file mode 100644 index 0000000000..a1e57237b6 --- /dev/null +++ b/plugins/notifications-backend/migrations/20231215_init.js @@ -0,0 +1,44 @@ +/* + * 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('notification', table => { + table.uuid('id').primary(); + table.string('user', 255).notNullable(); + table.string('title').notNullable(); + table.text('description').nullable(); + table.string('severity', 8).notNullable(); + table.text('link').notNullable(); + 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'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('notification'); +}; diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json new file mode 100644 index 0000000000..455acb3bd4 --- /dev/null +++ b/plugins/notifications-backend/package.json @@ -0,0 +1,55 @@ +{ + "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/backend-plugin-api": "workspace:^", + "@backstage/catalog-client": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/config": "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:^", + "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" + }, + "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" + }, + "files": [ + "dist" + ] +} 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 new file mode 100644 index 0000000000..7e5060106b --- /dev/null +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -0,0 +1,240 @@ +/* + * 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, + NotificationModifyOptions, + NotificationsStore, +} from './NotificationsStore'; +import { Notification } from '@backstage/plugin-notifications-common'; +import { Knex } from 'knex'; + +const migrationsDir = resolvePackagePath( + '@backstage/plugin-notifications-backend', + 'migrations', +); + +/** @internal */ +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; + }; + + 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, 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.whereNotNull('saved'); + } + + if (options.limit) { + query.limit(options.limit); + } + + if (options.offset) { + query.offset(options.offset); + } + + if (options.search) { + query.whereRaw( + `(LOWER(notification.title) LIKE LOWER(?) OR LOWER(notification.description) LIKE LOWER(?))`, + [`%${options.search}%`, `%${options.search}%`], + ); + } + + if (options.ids) { + query.whereIn('notification.id', options.ids); + } + + return query; + }; + + async getNotifications(options: NotificationGetOptions) { + const notificationQuery = this.getNotificationsBaseQuery(options); + const notifications = await notificationQuery.select(); + return this.mapToNotifications(notifications); + } + + async saveNotification(notification: Notification) { + await this.db.insert(notification).into('notification'); + } + + async getStatus(options: NotificationGetOptions) { + const notificationQuery = this.getNotificationsBaseQuery({ + ...options, + sort: null, + }); + const readSubQuery = notificationQuery + .clone() + .count('id') + .whereNotNull('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((query as any)?.UNREAD), + read: this.mapToInteger((query as any)?.READ), + }; + } + + async getExistingScopeNotification(options: { + user: string; + scope: string; + origin: string; + }) { + const query = this.db('notification') + .where('user', options.user) + .where('scope', options.scope) + .where('origin', options.origin) + .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('notification') + .where('id', options.id) + .where('user', options.notification.user); + + await query.update({ + 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, + }); + + return await this.getNotification(options); + } + + async getNotification(options: { id: string }): Promise { + const rows = await this.db('notification') + .where('id', options.id) + .select() + .limit(1); + if (!rows || rows.length === 0) { + return null; + } + return this.mapToNotifications(rows)[0]; + } + + 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 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: new Date() }); + } + + async markUnsaved(options: NotificationModifyOptions): Promise { + const notificationQuery = this.getNotificationsBaseQuery(options); + await notificationQuery.update({ saved: null }); + } +} diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts new file mode 100644 index 0000000000..4e3a2fa547 --- /dev/null +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -0,0 +1,72 @@ +/* + * 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, + NotificationType, +} from '@backstage/plugin-notifications-common'; + +/** @internal */ +export type NotificationGetOptions = { + user: string; + ids?: string[]; + type?: NotificationType; + offset?: number; + limit?: number; + search?: string; + sort?: 'created' | 'read' | 'updated' | null; + sortOrder?: 'asc' | 'desc'; +}; + +/** @internal */ +export type NotificationModifyOptions = { + ids: string[]; +} & NotificationGetOptions; + +/** @internal */ +export interface NotificationsStore { + getNotifications(options: NotificationGetOptions): Promise; + + saveNotification(notification: Notification): Promise; + + getExistingScopeNotification(options: { + user: string; + scope: string; + origin: string; + }): Promise; + + restoreExistingNotification(options: { + id: string; + notification: Notification; + }): Promise; + + getNotification(options: { id: string }): Promise; + + getStatus(options: NotificationGetOptions): Promise; + + markRead(options: NotificationModifyOptions): Promise; + + 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/database/index.ts b/plugins/notifications-backend/src/database/index.ts new file mode 100644 index 0000000000..6d2f549f38 --- /dev/null +++ b/plugins/notifications-backend/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-backend/src/index.ts b/plugins/notifications-backend/src/index.ts new file mode 100644 index 0000000000..b47321aa39 --- /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 { notificationsPlugin as default } from './plugin'; diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts new file mode 100644 index 0000000000..1d8806cf49 --- /dev/null +++ b/plugins/notifications-backend/src/plugin.ts @@ -0,0 +1,92 @@ +/* + * 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, + createBackendPlugin, +} 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 + * + * @public + */ +export const notificationsPlugin = createBackendPlugin({ + pluginId: 'notifications', + register(env) { + const processingExtensions = + new NotificationsProcessingExtensionPointImpl(); + env.registerExtensionPoint( + notificationsProcessingExtensionPoint, + processingExtensions, + ); + + env.registerInit({ + deps: { + httpRouter: coreServices.httpRouter, + logger: coreServices.logger, + identity: coreServices.identity, + database: coreServices.database, + tokenManager: coreServices.tokenManager, + discovery: coreServices.discovery, + signals: signalService, + }, + async init({ + httpRouter, + logger, + identity, + database, + tokenManager, + discovery, + signals, + }) { + httpRouter.use( + await createRouter({ + logger, + identity, + database, + tokenManager, + discovery, + signalService: signals, + processors: processingExtensions.processors, + }), + ); + }, + }); + }, +}); 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..3152b1b07f --- /dev/null +++ b/plugins/notifications-backend/src/service/router.test.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 { + 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 { ConfigReader } from '@backstage/config'; +import { SignalService } from '@backstage/plugin-signals-node'; + +function createDatabase(): PluginDatabaseManager { + return DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('notifications'); +} + +describe('createRouter', () => { + let app: express.Express; + + const identityMock: IdentityApi = { + async getIdentity() { + return { + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'user:default/guest', + }, + token: 'no-token', + }; + }, + }; + const mockedTokenManager: jest.Mocked = { + getToken: jest.fn(), + authenticate: jest.fn(), + }; + + const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), + }; + + const signalService: jest.Mocked = { + publish: jest.fn(), + }; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + identity: identityMock, + database: createDatabase(), + tokenManager: mockedTokenManager, + discovery, + signalService, + }); + 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..b293ea4a1f --- /dev/null +++ b/plugins/notifications-backend/src/service/router.ts @@ -0,0 +1,365 @@ +/* + * 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, + PluginDatabaseManager, + TokenManager, +} from '@backstage/backend-common'; +import express, { Request } from 'express'; +import Router from 'express-promise-router'; +import { + getBearerTokenFromAuthorizationHeader, + IdentityApi, +} from '@backstage/plugin-auth-node'; +import { + DatabaseNotificationsStore, + NotificationGetOptions, +} 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 '@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 { + Notification, + NotificationType, +} from '@backstage/plugin-notifications-common'; + +/** @internal */ +export interface RouterOptions { + logger: LoggerService; + identity: IdentityApi; + database: PluginDatabaseManager; + tokenManager: TokenManager; + discovery: DiscoveryService; + signalService?: SignalService; + catalog?: CatalogApi; + processors?: NotificationProcessor[]; +} + +/** @internal */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { + logger, + database, + identity, + discovery, + catalog, + tokenManager, + processors, + signalService, + } = options; + + 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 }); + if (!user) { + throw new AuthenticationError(); + } + return user.identity.userEntityRef; + }; + + 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[] | null, + ): Promise => { + const { token } = await tokenManager.getToken(); + + // TODO: Support for broadcast + if (entityRef === null) { + return []; + } + + const refs = Array.isArray(entityRef) ? entityRef : [entityRef]; + const entities = await catalogClient.getEntitiesByRefs( + { + entityRefs: refs, + fields: ['kind', 'metadata.name', 'metadata.namespace'], + }, + { token }, + ); + const mapEntity = async (entity: Entity | undefined): Promise => { + if (!entity) { + return []; + } + + if (isUserEntity(entity)) { + return [stringifyEntityRef(entity)]; + } else if (isGroupEntity(entity) && 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, + { 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 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); + } + } + }; + + // TODO: Move to use OpenAPI router instead + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.json({ status: 'ok' }); + }); + + router.get('/', async (req, res) => { + const user = await getUser(req); + const opts: NotificationGetOptions = { + user: user, + }; + if (req.query.type) { + 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); + res.send(notifications); + }); + + router.get('/status', async (req, res) => { + const user = await getUser(req); + const status = await store.getStatus({ user, type: 'undone' }); + res.send(status); + }); + + router.post('/update', async (req, res) => { + const user = await getUser(req); + const { ids, done, read, saved } = req.body; + if (!ids || !Array.isArray(ids)) { + throw new InputError(); + } + + if (done === true) { + await store.markDone({ 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, ids }); + if (signalService) { + await signalService.publish({ + recipients: [user], + message: { action: 'undone', notification_ids: ids }, + channel: 'notifications', + }); + } + } + + if (read === true) { + await store.markRead({ user, 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: user, ids }); + + if (signalService) { + await signalService.publish({ + recipients: [user], + message: { action: 'mark_unread', notification_ids: ids }, + channel: 'notifications', + }); + } + } + + if (saved === true) { + await store.markSaved({ user: user, ids }); + } else if (saved === false) { + await store.markUnsaved({ user: user, ids }); + } + + 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('/', async (req, res) => { + const { recipients, origin, payload } = req.body; + const notifications = []; + let users = []; + + try { + await authenticateService(req); + } catch (e) { + throw new AuthenticationError(); + } + + const { title, link, description, scope } = payload; + + if (!recipients || !title || !origin || !link) { + logger.error(`Invalid notification request received`); + throw new InputError(); + } + + let entityRef = null; + // TODO: Support for broadcast notifications + if (recipients.entityRef && recipients.type === 'entity') { + entityRef = recipients.entityRef; + } + + try { + users = await getUsersForEntityRef(entityRef); + } catch (e) { + throw new InputError(); + } + + const baseNotification: Omit = { + payload: { + ...payload, + severity: payload.severity ?? 'normal', + }, + origin, + created: new Date(), + }; + + const uniqueUsers = [...new Set(users)]; + for (const user of uniqueUsers) { + const userNotification = { + ...baseNotification, + id: uuid(), + user, + }; + const notification = await decorateNotification(userNotification); + + let existingNotification; + if (scope) { + existingNotification = await store.getExistingScopeNotification({ + user, + scope, + origin, + }); + } + + let ret = notification; + if (existingNotification) { + const restored = await store.restoreExistingNotification({ + id: existingNotification.id, + notification, + }); + ret = restored ?? notification; + } else { + await store.saveNotification(notification); + } + + processorSendNotification(ret); + notifications.push(ret); + } + + if (signalService) { + await signalService.publish({ + recipients: entityRef === null ? null : uniqueUsers, + message: { + action: 'new_notification', + notification: { title, description, link }, + }, + channel: 'notifications', + }); + } + + res.json(notifications); + }); + + 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..e2841543b1 --- /dev/null +++ b/plugins/notifications-backend/src/service/standaloneServer.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 { + createServiceBuilder, + HostDiscovery, + loadBackendConfig, + PluginDatabaseManager, + ServerTokenManager, +} 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 { + CatalogApi, + 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; + 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 tokenManager = ServerTokenManager.fromConfig(config, { + logger, + }); + const discovery = HostDiscovery.fromConfig(config); + + const dbMock: PluginDatabaseManager = { + async getClient() { + return db; + }, + }; + + const catalogApi = { + async getEntitiesByRefs( + _request: GetEntitiesByRefsRequest, + __options?: CatalogRequestOptions, + ) { + return { + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'user', namespace: 'default' }, + spec: {}, + }, + ], + }; + }, + } as Partial as CatalogApi; + + 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 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, + database: dbMock, + catalog: catalogApi, + discovery, + tokenManager, + signalService, + }); + + 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..d2dc1afb9d --- /dev/null +++ b/plugins/notifications-common/api-report.md @@ -0,0 +1,42 @@ +## 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; + user: string; + created: Date; + saved?: Date; + read?: Date; + done?: Date; + updated?: Date; + origin: string; + payload: NotificationPayload; +}; +export { Notification_2 as Notification }; + +// @public (undocumented) +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; + read: number; +}; + +// @public (undocumented) +export type NotificationType = 'undone' | 'done' | 'saved'; +``` 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-common/package.json b/plugins/notifications-common/package.json new file mode 100644 index 0000000000..cda38bd1cb --- /dev/null +++ b/plugins/notifications-common/package.json @@ -0,0 +1,35 @@ +{ + "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" + ], + "dependencies": { + "@material-ui/icons": "^4.9.1" + } +} 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..1e7469244f --- /dev/null +++ b/plugins/notifications-common/src/types.ts @@ -0,0 +1,53 @@ +/* + * 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 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; + user: string; + created: Date; + saved?: Date; + read?: Date; + done?: Date; + updated?: Date; + origin: string; + payload: NotificationPayload; +}; + +/** @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..13475ab209 --- /dev/null +++ b/plugins/notifications-node/README.md @@ -0,0 +1,51 @@ +# @backstage/plugin-notifications-node + +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. Add notification service to your `plugin.ts` as a dependency for init + +```ts +import { notificationService } from '@backstage/plugin-notifications-node'; + +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` +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. + +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. diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md new file mode 100644 index 0000000000..75a0fb68fd --- /dev/null +++ b/plugins/notifications-node/api-report.md @@ -0,0 +1,69 @@ +## 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 { DiscoveryService } 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 { TokenManager } from '@backstage/backend-common'; + +// @public (undocumented) +export class DefaultNotificationService implements NotificationService { + // (undocumented) + static create({ + tokenManager, + discovery, + pluginId, + }: NotificationServiceOptions): DefaultNotificationService; + // (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'; + entityRef: string | string[]; +}; + +// @public (undocumented) +export type NotificationSendOptions = { + recipients: NotificationRecipients; + payload: NotificationPayload; +}; + +// @public (undocumented) +export interface NotificationService { + // (undocumented) + send(options: NotificationSendOptions): Promise; +} + +// @public (undocumented) +export const notificationService: ServiceRef; + +// @public (undocumented) +export type NotificationServiceOptions = { + discovery: DiscoveryService; + tokenManager: TokenManager; + pluginId: string; +}; + +// @public (undocumented) +export interface NotificationsProcessingExtensionPoint { + // (undocumented) + addProcessor( + ...processors: Array> + ): void; +} + +// @public (undocumented) +export const notificationsProcessingExtensionPoint: ExtensionPoint; +``` 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-node/package.json b/plugins/notifications-node/package.json new file mode 100644 index 0000000000..b7393c1123 --- /dev/null +++ b/plugins/notifications-node/package.json @@ -0,0 +1,42 @@ +{ + "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:^", + "@backstage/test-utils": "workspace:^", + "msw": "^1.0.0" + }, + "files": [ + "dist" + ], + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@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/extensions.ts b/plugins/notifications-node/src/extensions.ts new file mode 100644 index 0000000000..f8ab26462d --- /dev/null +++ b/plugins/notifications-node/src/extensions.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 { createExtensionPoint } from '@backstage/backend-plugin-api'; +import { Notification } from '@backstage/plugin-notifications-common'; + +/** + * @public + */ +export interface 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; +} + +/** + * @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 new file mode 100644 index 0000000000..05ca83957b --- /dev/null +++ b/plugins/notifications-node/src/index.ts @@ -0,0 +1,25 @@ +/* + * 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 './service'; +export * from './lib'; +export * from './extensions'; diff --git a/plugins/notifications-node/src/lib.ts b/plugins/notifications-node/src/lib.ts new file mode 100644 index 0000000000..c79d871ff8 --- /dev/null +++ b/plugins/notifications-node/src/lib.ts @@ -0,0 +1,44 @@ +/* + * 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 { DefaultNotificationService } from './service'; +import { NotificationService } from './service/NotificationService'; + +/** @public */ +export const notificationService = createServiceRef({ + id: 'notifications.service', + scope: 'plugin', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + discovery: coreServices.discovery, + tokenManager: coreServices.tokenManager, + pluginMetadata: coreServices.pluginMetadata, + }, + factory({ discovery, tokenManager, pluginMetadata }) { + return DefaultNotificationService.create({ + discovery, + tokenManager, + pluginId: pluginMetadata.getId(), + }); + }, + }), +}); 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 new file mode 100644 index 0000000000..7e5cf58f53 --- /dev/null +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -0,0 +1,85 @@ +/* + * 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 { TokenManager } from '@backstage/backend-common'; +import { NotificationService } from './NotificationService'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; +import { NotificationPayload } from '@backstage/plugin-notifications-common'; + +/** @public */ +export type NotificationServiceOptions = { + discovery: DiscoveryService; + tokenManager: TokenManager; + pluginId: string; +}; + +/** @public */ +export type NotificationRecipients = { + type: 'entity'; + entityRef: string | string[]; +}; + +// TODO: Support for broadcast messages +// | { type: 'broadcast' }; + +/** @public */ +export type NotificationSendOptions = { + recipients: NotificationRecipients; + payload: NotificationPayload; +}; + +/** @public */ +export class DefaultNotificationService implements NotificationService { + private constructor( + private readonly discovery: DiscoveryService, + private readonly tokenManager: TokenManager, + private readonly pluginId: string, + ) {} + + static create({ + tokenManager, + discovery, + pluginId, + }: NotificationServiceOptions): DefaultNotificationService { + return new DefaultNotificationService(discovery, tokenManager, pluginId); + } + + async send(notification: NotificationSendOptions): Promise { + try { + const baseUrl = await this.discovery.getBaseUrl('notifications'); + const { token } = await this.tokenManager.getToken(); + const response = await fetch(`${baseUrl}/`, { + method: 'POST', + body: JSON.stringify({ + ...notification, + // TODO: Should retrieve this in the backend from service auth instead + origin: `plugin-${this.pluginId}`, + }), + 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-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts new file mode 100644 index 0000000000..08b63599cc --- /dev/null +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -0,0 +1,22 @@ +/* + * 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 { NotificationSendOptions } from './DefaultNotificationService'; + +/** @public */ +export interface NotificationService { + send(options: NotificationSendOptions): Promise; +} diff --git a/plugins/notifications-node/src/service/index.ts b/plugins/notifications-node/src/service/index.ts new file mode 100644 index 0000000000..37c3bff908 --- /dev/null +++ b/plugins/notifications-node/src/service/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 './DefaultNotificationService'; +export type { NotificationService } 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..63212d2c16 --- /dev/null +++ b/plugins/notifications/README.md @@ -0,0 +1,41 @@ +# notifications + +Welcome to the notifications plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +First, install the `@backstage/plugin-notifications-backend` and `@backstage/plugin-notifications-node` packages. +See the documentation for installation instructions. + +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. diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md new file mode 100644 index 0000000000..e2071dad14 --- /dev/null +++ b/plugins/notifications/api-report.md @@ -0,0 +1,135 @@ +## 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 { 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; + offset?: number; + limit?: number; + search?: string; +}; + +// @public (undocumented) +export interface NotificationsApi { + // (undocumented) + getNotifications( + options?: GetNotificationsOptions, + ): Promise; + // (undocumented) + getStatus(): Promise; + // (undocumented) + updateNotifications( + options: UpdateNotificationsOptions, + ): Promise; +} + +// @public (undocumented) +export const notificationsApiRef: ApiRef; + +// @public (undocumented) +export class NotificationsClient implements NotificationsApi { + constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }); + // (undocumented) + getNotifications( + options?: GetNotificationsOptions, + ): Promise; + // (undocumented) + getStatus(): Promise; + // (undocumented) + updateNotifications( + options: UpdateNotificationsOptions, + ): Promise; +} + +// @public (undocumented) +export const NotificationsPage: () => JSX_2.Element; + +// @public (undocumented) +export const notificationsPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {} +>; + +// @public (undocumented) +export const NotificationsSidebarItem: (props?: { + webNotificationsEnabled?: boolean; + titleCounterEnabled?: boolean; +}) => React_2.JSX.Element; + +// @public (undocumented) +export const NotificationsTable: (props: { + onUpdate: () => void; + type: NotificationType; + 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, + 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; + }; + +// @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/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/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..b6177bda8a --- /dev/null +++ b/plugins/notifications/package.json @@ -0,0 +1,57 @@ +{ + "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/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", + "@types/react": "^16.13.1 || ^17.0.0", + "react-relative-time": "^0.0.9", + "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": "^6.0.0", + "@testing-library/react": "^14.0.0", + "@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..775dc6b244 --- /dev/null +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -0,0 +1,53 @@ +/* + * 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, + NotificationType, +} from '@backstage/plugin-notifications-common'; + +/** @public */ +export const notificationsApiRef = createApiRef({ + id: 'plugin.notifications.service', +}); + +/** @public */ +export type GetNotificationsOptions = { + type?: NotificationType; + offset?: number; + limit?: number; + search?: string; +}; + +/** @public */ +export type UpdateNotificationsOptions = { + ids: string[]; + done?: boolean; + read?: boolean; + saved?: boolean; +}; + +/** @public */ +export interface NotificationsApi { + getNotifications(options?: GetNotificationsOptions): Promise; + + getStatus(): Promise; + + updateNotifications( + options: UpdateNotificationsOptions, + ): Promise; +} 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 new file mode 100644 index 0000000000..fe7a90d3e8 --- /dev/null +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -0,0 +1,89 @@ +/* + * 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 { + GetNotificationsOptions, + NotificationsApi, + UpdateNotificationsOptions, +} 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( + options?: GetNotificationsOptions, + ): Promise { + const queryString = new URLSearchParams(); + if (options?.type) { + queryString.append('type', options.type); + } + if (options?.limit !== undefined) { + queryString.append('limit', options.limit.toString(10)); + } + if (options?.offset !== undefined) { + queryString.append('offset', options.offset.toString(10)); + } + if (options?.search) { + queryString.append('search', options.search); + } + + const urlSegment = `?${queryString}`; + + return await this.request(urlSegment); + } + + async getStatus(): Promise { + return await this.request('status'); + } + + async updateNotifications( + options: UpdateNotificationsOptions, + ): Promise { + return await this.request('update', { + method: 'POST', + body: JSON.stringify(options), + 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(), init); + + 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..85e0937ca3 --- /dev/null +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -0,0 +1,112 @@ +/* + * 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, useState } from 'react'; +import { + Content, + ErrorPanel, + PageWithHeader, +} from '@backstage/core-components'; +import { NotificationsTable } from '../NotificationsTable'; +import { useNotificationsApi } from '../../hooks'; +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'; +import { NotificationType } from '@backstage/plugin-notifications-common'; +import { useSignal } from '@backstage/plugin-signals-react'; + +const useStyles = makeStyles(_theme => ({ + filterButton: { + width: '100%', + justifyContent: 'start', + }, +})); + +export const NotificationsPage = () => { + const [type, setType] = useState('undone'); + const [refresh, setRefresh] = React.useState(false); + + 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) { + setRefresh(true); + } + }, [lastSignal]); + + const onUpdate = () => { + setRefresh(true); + }; + + const styles = useStyles(); + if (error) { + return ; + } + + 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..90fba009a3 --- /dev/null +++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx @@ -0,0 +1,107 @@ +/* + * 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'; +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 = (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 [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); + const { setNotificationCount } = useTitleCounter(); + + useEffect(() => { + if (refresh) { + retry(); + setRefresh(false); + } + }, [refresh, retry]); + + useEffect(() => { + 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]); + + useEffect(() => { + if (!loading && !error && value) { + setUnreadCount(value.unread); + if (titleCounterEnabled) { + setNotificationCount(value.unread); + } + } + }, [loading, error, value, titleCounterEnabled, setNotificationCount]); + + // TODO: Figure out if the 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..8328da1c6d --- /dev/null +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -0,0 +1,308 @@ +/* + * 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, useState } from 'react'; +import { + Box, + Button, + IconButton, + makeStyles, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@material-ui/core'; +import { + Notification, + NotificationType, +} from '@backstage/plugin-notifications-common'; +import { useNavigate } from 'react-router-dom'; +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'; +// @ts-ignore +import RelativeTime from 'react-relative-time'; +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', + '&.unread': { + border: '1px solid rgba(255, 255, 255, .3)', + }, + '& .hideOnHover': { + display: 'initial', + }, + '& .showOnHover': { + display: 'none', + }, + '&:hover': { + '& .hideOnHover': { + display: 'none', + }, + '& .showOnHover': { + display: 'initial', + }, + }, + }, + actionButton: { + padding: '9px', + }, + checkBox: { + padding: '0 10px 10px 0', + }, +})); + +/** @public */ +export const NotificationsTable = (props: { + onUpdate: () => void; + type: NotificationType; + notifications?: Notification[]; +}) => { + const { notifications, type } = props; + const navigate = useNavigate(); + const styles = useStyles(); + 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 + ); + }; + + return ( + + + + + {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 === 'done' && selected.length > 0 && ( + + )} + + {type === 'undone' && selected.length > 0 && ( + + )} + + + + + {props.notifications?.map(notification => { + return ( + + + onCheckBoxClick(notification.id)} + /> + + + notificationsApi + .updateNotifications({ ids: [notification.id], read: true }) + .then(() => navigate(notification.payload.link)) + } + style={{ paddingLeft: 0 }} + > + + {notification.payload.title} + + + {notification.payload.description} + + + + + + + + + + notificationsApi + .updateNotifications({ + ids: [notification.id], + read: true, + }) + .then(() => navigate(notification.payload.link)) + } + > + + + + + { + if (notification.read) { + notificationsApi + .updateNotifications({ + ids: [notification.id], + done: false, + }) + .then(() => { + props.onUpdate(); + }); + } else { + notificationsApi + .updateNotifications({ + ids: [notification.id], + done: true, + }) + .then(() => { + props.onUpdate(); + }); + } + }} + > + {notification.read ? ( + + ) : ( + + )} + + + + { + if (notification.saved) { + notificationsApi + .updateNotifications({ + ids: [notification.id], + saved: false, + }) + .then(() => { + props.onUpdate(); + }); + } else { + notificationsApi + .updateNotifications({ + ids: [notification.id], + saved: true, + }) + .then(() => { + props.onUpdate(); + }); + } + }} + > + {notification.saved ? ( + + ) : ( + + )} + + + + + + ); + })} + +
+ ); +}; 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..516901aa04 --- /dev/null +++ b/plugins/notifications/src/hooks/index.ts @@ -0,0 +1,18 @@ +/* + * 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'; +export * from './useWebNotifications'; +export * from './useTitleCounter'; 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/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 }; +} 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 bda35a3dc9..a640721347 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7783,6 +7783,95 @@ __metadata: languageName: unknown linkType: soft +"@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: + "@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:^" + "@backstage/config": "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": ^4.17.6 + "@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 + uuid: ^8.0.0 + 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:^" + "@material-ui/icons": ^4.9.1 + 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/backend-plugin-api": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@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 + +"@backstage/plugin-notifications@workspace:^, @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/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 + "@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 + react-relative-time: ^0.0.9 + 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" @@ -25247,9 +25336,9 @@ __metadata: linkType: hard "dom-accessibility-api@npm:^0.5.9": - version: 0.5.13 - resolution: "dom-accessibility-api@npm:0.5.13" - checksum: a5a5f14c01e466d424750aaac9225f1dc43cf16d101a1c40e01a554abce63c48084707002c39b805f2ce212273c179dd6d2258175997cd06d5f79851bf52dd40 + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 005eb283caef57fc1adec4d5df4dd49189b628f2f575af45decb210e04d634459e3f1ee64f18b41e2dcf200c844bc1d9279d80807e686a30d69a4756151ad248 languageName: node linkType: hard @@ -27062,6 +27151,7 @@ __metadata: "@backstage/plugin-newrelic": "workspace:^" "@backstage/plugin-newrelic-dashboard": "workspace:^" "@backstage/plugin-nomad": "workspace:^" + "@backstage/plugin-notifications": "workspace:^" "@backstage/plugin-octopus-deploy": "workspace:^" "@backstage/plugin-org": "workspace:^" "@backstage/plugin-pagerduty": "workspace:^" @@ -27147,6 +27237,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:^" @@ -27159,6 +27250,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:^" @@ -39087,6 +39179,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"