diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index eca36208a9..2743a46335 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -45,6 +45,7 @@ "@backstage/plugin-lighthouse-backend": "workspace:^", "@backstage/plugin-linguist-backend": "workspace:^", "@backstage/plugin-nomad-backend": "workspace:^", + "@backstage/plugin-notifications-backend": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", "@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", @@ -57,6 +58,7 @@ "@backstage/plugin-search-backend-module-explore": "workspace:^", "@backstage/plugin-search-backend-module-techdocs": "workspace:^", "@backstage/plugin-search-backend-node": "workspace:^", + "@backstage/plugin-signals-backend": "workspace:^", "@backstage/plugin-sonarqube-backend": "workspace:^", "@backstage/plugin-techdocs-backend": "workspace:^", "@backstage/plugin-todo-backend": "workspace:^" diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index 53a51fcfa7..9c61b98ec4 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -51,5 +51,7 @@ backend.add(import('@backstage/plugin-search-backend/alpha')); backend.add(import('@backstage/plugin-techdocs-backend/alpha')); backend.add(import('@backstage/plugin-todo-backend')); backend.add(import('@backstage/plugin-sonarqube-backend')); +backend.add(import('@backstage/plugin-signals-backend')); +backend.add(import('@backstage/plugin-notifications-backend')); backend.start(); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 032bcfbb9f..bade028597 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -66,7 +66,6 @@ import linguist from './plugins/linguist'; import devTools from './plugins/devtools'; import nomad from './plugins/nomad'; import signals from './plugins/signals'; -import notifications from './plugins/notifications'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; @@ -75,7 +74,6 @@ import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { metrics } from '@opentelemetry/api'; import { DefaultSignalService } from '@backstage/plugin-signals-node'; -import { DefaultNotificationService } from '@backstage/plugin-notifications-node'; // Expose opentelemetry metrics using a Prometheus exporter on // http://localhost:9464/metrics . See prometheus.yml in packages/backend for @@ -105,12 +103,6 @@ function makeCreateEnv(config: Config) { const signalService = DefaultSignalService.create({ eventBroker, }); - const defaultNotificationService = DefaultNotificationService.create({ - logger: root.child({ type: 'plugin' }), - discovery, - tokenManager, - signalService, - }); root.info(`Created UrlReader ${reader}`); @@ -119,7 +111,6 @@ function makeCreateEnv(config: Config) { const database = databaseManager.forPlugin(plugin); const cache = cacheManager.forPlugin(plugin); const scheduler = taskScheduler.forPlugin(plugin); - const notificationService = defaultNotificationService.forPlugin(plugin); return { logger, @@ -134,7 +125,6 @@ function makeCreateEnv(config: Config) { scheduler, identity, signalService, - notificationService, }; }; } @@ -189,9 +179,6 @@ async function main() { const devToolsEnv = useHotMemoize(module, () => createEnv('devtools')); const nomadEnv = useHotMemoize(module, () => createEnv('nomad')); const signalsEnv = useHotMemoize(module, () => createEnv('signals')); - const notificationsEnv = useHotMemoize(module, () => - createEnv('notifications'), - ); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -219,7 +206,6 @@ async function main() { apiRouter.use('/devtools', await devTools(devToolsEnv)); apiRouter.use('/nomad', await nomad(nomadEnv)); apiRouter.use('/signals', await signals(signalsEnv)); - apiRouter.use('/notifications', await notifications(notificationsEnv)); apiRouter.use(notFoundHandler()); await lighthouse(lighthouseEnv); diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts deleted file mode 100644 index 8bf5f9a740..0000000000 --- a/packages/backend/src/plugins/notifications.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createRouter } from '@backstage/plugin-notifications-backend'; -import { Router } from 'express'; -import { PluginEnvironment } from '../types'; - -export default async function createPlugin( - env: PluginEnvironment, -): Promise { - return await createRouter({ - logger: env.logger, - identity: env.identity, - tokenManager: env.tokenManager, - database: env.database, - discovery: env.discovery, - signalService: env.signalService, - }); -} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 6a48804d1c..d76e68c1c9 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -28,7 +28,6 @@ import { IdentityApi } from '@backstage/plugin-auth-node'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { EventBroker } from '@backstage/plugin-events-node'; import { SignalService } from '@backstage/plugin-signals-node'; -import { NotificationService } from '@backstage/plugin-notifications-node'; export type PluginEnvironment = { logger: Logger; @@ -43,5 +42,4 @@ export type PluginEnvironment = { identity: IdentityApi; eventBroker: EventBroker; signalService: SignalService; - notificationService: NotificationService; }; diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md index 376d38c855..e12f540aa5 100644 --- a/plugins/notifications-backend/README.md +++ b/plugins/notifications-backend/README.md @@ -7,39 +7,12 @@ Welcome to the notifications backend plugin! First you have to install `@backstage/plugin-notifications-node` and `@backstage/plugin-signals-node` packages. -Then create a new file to `packages/backend/src/plugins/notifications.ts`: +Add the notifications to your backend: ```ts -import { createRouter } from '@backstage/plugin-notifications-backend'; -import { Router } from 'express'; -import { PluginEnvironment } from '../types'; - -export default async function createPlugin( - env: PluginEnvironment, -): Promise { - return await createRouter({ - logger: env.logger, - identity: env.identity, - tokenManager: env.tokenManager, - database: env.database, - discovery: env.discovery, - signalService: env.signalService, - }); -} -``` - -and add it to `packages/backend/src/index.ts`: - -```ts -async function main() { - //... - const notificationsEnv = useHotMemoize(module, () => - createEnv('notifications'), - ); - - // ... - apiRouter.use('/notifications', await notifications(notificationsEnv)); -} +const backend = createBackend(); +// ... +backend.add(import('@backstage/plugin-notifications-backend')); ``` ## Extending Notifications diff --git a/plugins/notifications-backend/api-report.md b/plugins/notifications-backend/api-report.md index b11c2269bd..4828db197b 100644 --- a/plugins/notifications-backend/api-report.md +++ b/plugins/notifications-backend/api-report.md @@ -9,7 +9,7 @@ import { DiscoveryService } from '@backstage/backend-plugin-api'; import express from 'express'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { NotificationProcessor } from '@backstage/plugin-notifications-node'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { SignalService } from '@backstage/plugin-signals-node'; import { TokenManager } from '@backstage/backend-common'; @@ -17,14 +17,9 @@ import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; -// @public (undocumented) -export type NotificationProcessor = { - decorate?(notification: Notification_2): Promise; - send?(notification: Notification_2): Promise; -}; - // @public -export const notificationsPlugin: () => BackendFeature; +const notificationsPlugin: () => BackendFeature; +export default notificationsPlugin; // @public (undocumented) export interface RouterOptions { diff --git a/plugins/notifications-backend/migrations/20231215_init.js b/plugins/notifications-backend/migrations/20231215_init.js index 65139e6d07..a1e57237b6 100644 --- a/plugins/notifications-backend/migrations/20231215_init.js +++ b/plugins/notifications-backend/migrations/20231215_init.js @@ -15,21 +15,24 @@ */ exports.up = async function up(knex) { - await knex.schema.createTable('notifications', table => { + await knex.schema.createTable('notification', table => { table.uuid('id').primary(); - table.string('userRef').notNullable(); + table.string('user', 255).notNullable(); table.string('title').notNullable(); table.text('description').nullable(); - table.text('severity').notNullable(); + table.string('severity', 8).notNullable(); table.text('link').notNullable(); - table.text('origin').notNullable(); - table.text('scope').nullable(); - table.text('topic').nullable(); + table.string('origin', 255).notNullable(); + table.string('scope', 255).nullable(); + table.string('topic', 255).nullable(); table.datetime('created').defaultTo(knex.fn.now()).notNullable(); table.datetime('updated').nullable(); table.datetime('read').nullable(); table.datetime('done').nullable(); table.datetime('saved').nullable(); + + table.index(['user'], 'notification_user_idx'); + table.index(['scope', 'origin'], 'notification_scope_origin_idx'); }); }; @@ -37,5 +40,5 @@ exports.up = async function up(knex) { * @param {import('knex').Knex} knex */ exports.down = async function down(knex) { - await knex.schema.dropTable('notifications'); + await knex.schema.dropTable('notification'); }; diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index 810fa95085..89cf7a1e57 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -43,6 +43,7 @@ "yn": "^4.0.0" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@types/supertest": "^2.0.8", "msw": "^1.0.0", diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts new file mode 100644 index 0000000000..35964ff582 --- /dev/null +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts @@ -0,0 +1,338 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { DatabaseNotificationsStore } from './DatabaseNotificationsStore'; +import { Knex } from 'knex'; +import { v4 as uuid } from 'uuid'; +import { Notification } from '@backstage/plugin-notifications-common'; + +jest.setTimeout(60_000); + +const databases = TestDatabases.create(); + +async function createStore(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + const mgr = { + getClient: async () => knex, + migrations: { + skip: false, + }, + }; + return { + knex, + storage: await DatabaseNotificationsStore.create({ database: mgr }), + }; +} + +const user = 'user:default/john.doe'; +const testNotification: Partial = { + user, + created: new Date(), + origin: 'plugin-test', + payload: { + title: 'Notification 1', + link: '/catalog', + severity: 'normal', + }, +}; + +const otherUserNotification: Partial = { + ...testNotification, + user: 'user:default/jane.doe', +}; + +describe.each(databases.eachSupportedId())( + 'DatabaseNotificationsStore (%s)', + databaseId => { + let storage: DatabaseNotificationsStore; + let knex: Knex; + const insertNotification = async ( + notification: Partial & { + id: string; + done?: Date; + saved?: Date; + read?: Date; + }, + ) => + ( + await knex('notification') + .insert({ + id: notification.id, + user: notification.user, + origin: notification.origin, + created: notification.created, + link: notification.payload?.link, + title: notification.payload?.title, + severity: notification.payload?.severity, + scope: notification.payload?.scope, + done: notification.done, + saved: notification.saved, + read: notification.read, + }) + .returning('id') + )[0].id ?? -1; + + beforeAll(async () => { + ({ storage, knex } = await createStore(databaseId)); + }); + + afterEach(async () => { + jest.resetAllMocks(); + await knex('notification').del(); + }); + + describe('getNotifications', () => { + it('should return all notifications for user', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ id: id1, ...testNotification }); + await insertNotification({ id: id2, ...testNotification }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const notifications = await storage.getNotifications({ user }); + expect(notifications.length).toBe(2); + }); + + it('should return undone notifications for user', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + done: new Date(), + }); + await insertNotification({ id: id2, ...testNotification }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const notifications = await storage.getNotifications({ + user, + type: 'undone', + }); + expect(notifications.length).toBe(1); + expect(notifications.at(0)?.id).toEqual(id2); + }); + + it('should return done notifications for user', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + done: new Date(), + }); + await insertNotification({ id: id2, ...testNotification }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const notifications = await storage.getNotifications({ + user, + type: 'done', + }); + expect(notifications.length).toBe(1); + expect(notifications.at(0)?.id).toEqual(id1); + }); + + it('should allow searching for notifications', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + payload: { + link: '/catalog', + severity: 'normal', + title: 'Please find me', + }, + }); + await insertNotification({ id: id2, ...testNotification }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const notifications = await storage.getNotifications({ + user, + search: 'find me', + }); + expect(notifications.length).toBe(1); + expect(notifications.at(0)?.id).toEqual(id1); + }); + }); + + describe('getStatus', () => { + it('should return status for user', async () => { + const id1 = uuid(); + const id2 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + read: new Date(), + }); + await insertNotification({ id: id2, ...testNotification }); + await insertNotification({ id: uuid(), ...otherUserNotification }); + + const status = await storage.getStatus({ user }); + expect(status.read).toEqual(1); + expect(status.unread).toEqual(1); + }); + }); + + describe('getExistingScopeNotification', () => { + it('should return existing scope notification', async () => { + const id1 = uuid(); + const notification: any = { + ...testNotification, + id: id1, + payload: { + title: 'Notification', + link: '/scaffolder/task/1234', + severity: 'normal', + scope: 'scaffolder-1234', + }, + }; + await insertNotification(notification); + + const existing = await storage.getExistingScopeNotification({ + user, + origin: 'plugin-test', + scope: 'scaffolder-1234', + }); + expect(existing).not.toBeNull(); + expect(existing?.id).toEqual(id1); + }); + }); + + describe('restoreExistingNotification', () => { + it('should return restore existing scope notification', async () => { + const id1 = uuid(); + const notification: any = { + ...testNotification, + id: id1, + read: new Date(), + done: new Date(), + payload: { + title: 'Notification', + link: '/scaffolder/task/1234', + severity: 'normal', + scope: 'scaffolder-1234', + }, + }; + await insertNotification(notification); + + const existing = await storage.restoreExistingNotification({ + id: id1, + notification: { + user: notification.user, + payload: { + title: 'New notification', + link: '/scaffolder/task/1234', + severity: 'normal', + }, + } as any, + }); + expect(existing).not.toBeNull(); + expect(existing?.id).toEqual(id1); + expect(existing?.payload.title).toEqual('New notification'); + expect(existing?.done).toBeNull(); + expect(existing?.read).toBeNull(); + }); + }); + + describe('getNotification', () => { + it('should return notification by id', async () => { + const id1 = uuid(); + await insertNotification({ id: id1, ...testNotification }); + + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.id).toEqual(id1); + }); + }); + + describe('markRead', () => { + it('should mark notification read', async () => { + const id1 = uuid(); + await insertNotification({ id: id1, ...testNotification }); + + await storage.markRead({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.read).not.toBeNull(); + }); + }); + + describe('markUnread', () => { + it('should mark notification unread', async () => { + const id1 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + read: new Date(), + }); + + await storage.markUnread({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.read).toBeNull(); + }); + }); + + describe('markDone', () => { + it('should mark notification done', async () => { + const id1 = uuid(); + await insertNotification({ id: id1, ...testNotification }); + + await storage.markDone({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.done).not.toBeNull(); + }); + }); + + describe('markUndone', () => { + it('should mark notification undone', async () => { + const id1 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + done: new Date(), + }); + + await storage.markUndone({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.done).toBeNull(); + }); + }); + + describe('markSaved', () => { + it('should mark notification saved', async () => { + const id1 = uuid(); + await insertNotification({ id: id1, ...testNotification }); + + await storage.markSaved({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.saved).not.toBeNull(); + }); + }); + + describe('markUnsaved', () => { + it('should mark notification not saved', async () => { + const id1 = uuid(); + await insertNotification({ + id: id1, + ...testNotification, + saved: new Date(), + }); + + await storage.markUnsaved({ ids: [id1], user }); + const notification = await storage.getNotification({ id: id1 }); + expect(notification?.saved).toBeNull(); + }); + }); + }, +); diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index 34fa42423b..a02c8fcae2 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -56,20 +56,46 @@ export class DatabaseNotificationsStore implements NotificationsStore { return typeof val === 'string' ? Number.parseInt(val, 10) : val ?? 0; }; + private mapToNotifications = (rows: any[]): Notification[] => { + return rows.map(row => ({ + id: row.id, + user: row.user, + created: row.created, + done: row.done, + saved: row.saved, + read: row.read, + updated: row.updated, + origin: row.origin, + payload: { + title: row.title, + description: row.description, + link: row.link, + topic: row.topic, + severity: row.severity, + scope: row.scope, + icon: row.icon, + }, + })); + }; + private getNotificationsBaseQuery = ( options: NotificationGetOptions | NotificationModifyOptions, ) => { - const { user_ref, type } = options; - const query = this.db('notifications') - .where('userRef', user_ref) - .orderBy('created', 'desc'); + const { user, type } = options; + const query = this.db('notification').where('user', user); + + if (options.sort !== undefined && options.sort !== null) { + query.orderBy(options.sort, options.sortOrder ?? 'desc'); + } else if (options.sort !== null) { + query.orderBy('created', options.sortOrder ?? 'desc'); + } if (type === 'undone') { query.whereNull('done'); } else if (type === 'done') { query.whereNotNull('done'); } else if (type === 'saved') { - query.where('saved', true); + query.whereNotNull('saved'); } if (options.limit) { @@ -81,22 +107,14 @@ export class DatabaseNotificationsStore implements NotificationsStore { } if (options.search) { - if (this.db.client.config.client === 'pg') { - query.whereRaw( - `(to_tsvector('english', notifications.title || ' ' || notifications.description) @@ websearch_to_tsquery('english', quote_literal(?)) - or to_tsvector('english', notifications.title || ' ' || notifications.description) @@ to_tsquery('english',quote_literal(?)))`, - [`${options.search}`, `${options.search.replaceAll(/\s/g, '+')}:*`], - ); - } else { - query.whereRaw( - `LOWER(notifications.title || ' ' || notifications.description) LIKE LOWER(?)`, - [`%${options.search}%`], - ); - } + query.whereRaw( + `(LOWER(notification.title) LIKE LOWER(?) OR LOWER(notification.description) LIKE LOWER(?))`, + [`%${options.search}%`, `%${options.search}%`], + ); } if (options.ids) { - query.whereIn('id', options.ids); + query.whereIn('notification.id', options.ids); } return query; @@ -104,43 +122,50 @@ export class DatabaseNotificationsStore implements NotificationsStore { async getNotifications(options: NotificationGetOptions) { const notificationQuery = this.getNotificationsBaseQuery(options); - const notifications = await notificationQuery.select('*'); - return notifications; + const notifications = await notificationQuery.select(); + return this.mapToNotifications(notifications); } async saveNotification(notification: Notification) { - await this.db.insert(notification).into('notifications'); + await this.db.insert(notification).into('notification'); } async getStatus(options: NotificationGetOptions) { - const notificationQuery = this.getNotificationsBaseQuery(options); - const unreadQuery = await notificationQuery - .clone() - .whereNull('read') - .count('id as UNREAD') - .first(); - const readQuery = await notificationQuery + const notificationQuery = this.getNotificationsBaseQuery({ + ...options, + sort: null, + }); + const readSubQuery = notificationQuery .clone() + .count('id') .whereNotNull('read') - .count('id as READ') + .as('READ'); + const unreadSubQuery = notificationQuery + .clone() + .count('id') + .whereNull('read') + .as('UNREAD'); + + const query = await notificationQuery + .select(readSubQuery, unreadSubQuery) .first(); return { - unread: this.mapToInteger((unreadQuery as any)?.UNREAD), - read: this.mapToInteger((readQuery as any)?.READ), + unread: this.mapToInteger((query as any)?.UNREAD), + read: this.mapToInteger((query as any)?.READ), }; } async getExistingScopeNotification(options: { - user_ref: string; + user: string; scope: string; origin: string; }) { - const query = this.db('notifications') - .where('userRef', options.user_ref) + const query = this.db('notification') + .where('user', options.user) .where('scope', options.scope) .where('origin', options.origin) - .select('*') + .select() .limit(1); const rows = await query; @@ -154,10 +179,11 @@ export class DatabaseNotificationsStore implements NotificationsStore { id: string; notification: Notification; }) { - const query = this.db('notifications') + const query = this.db('notification') .where('id', options.id) - .where('userRef', options.notification.userRef); - const rows = await query.update({ + .where('user', options.notification.user); + + await query.update({ title: options.notification.payload.title, description: options.notification.payload.description, link: options.notification.payload.link, @@ -168,22 +194,18 @@ export class DatabaseNotificationsStore implements NotificationsStore { done: null, }); - if (!rows) { - return null; - } - return await this.getNotification(options); } - async getNotification(options: { id: string }) { - const rows = await this.db('notifications') + async getNotification(options: { id: string }): Promise { + const rows = await this.db('notification') .where('id', options.id) - .select('*') + .select() .limit(1); if (!rows || rows.length === 0) { return null; } - return rows[0] as Notification; + return this.mapToNotifications(rows)[0]; } async markRead(options: NotificationModifyOptions): Promise { diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index d24a5e6991..dfc260e76f 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -22,13 +22,14 @@ import { /** @public */ export type NotificationGetOptions = { - user_ref: string; - + user: string; ids?: string[]; type?: NotificationType; offset?: number; limit?: number; search?: string; + sort?: 'created' | 'read' | 'updated' | null; + sortOrder?: 'asc' | 'desc'; }; /** @public */ @@ -43,7 +44,7 @@ export interface NotificationsStore { saveNotification(notification: Notification): Promise; getExistingScopeNotification(options: { - user_ref: string; + user: string; scope: string; origin: string; }): Promise; diff --git a/plugins/notifications-backend/src/index.ts b/plugins/notifications-backend/src/index.ts index 44891496fd..6e5d3a7c8c 100644 --- a/plugins/notifications-backend/src/index.ts +++ b/plugins/notifications-backend/src/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ export * from './service/router'; -export * from './plugin'; -export type { NotificationProcessor } from './types'; +export { notificationsPlugin as default } from './plugin'; diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts index 3d9937525c..1d8806cf49 100644 --- a/plugins/notifications-backend/src/plugin.ts +++ b/plugins/notifications-backend/src/plugin.ts @@ -19,6 +19,27 @@ import { } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; import { signalService } from '@backstage/plugin-signals-node'; +import { + NotificationProcessor, + notificationsProcessingExtensionPoint, + NotificationsProcessingExtensionPoint, +} from '@backstage/plugin-notifications-node'; + +class NotificationsProcessingExtensionPointImpl + implements NotificationsProcessingExtensionPoint +{ + #processors = new Array(); + + addProcessor( + ...processors: Array> + ): void { + this.#processors.push(...processors.flat()); + } + + get processors() { + return this.#processors; + } +} /** * Notifications backend plugin @@ -28,6 +49,13 @@ import { signalService } from '@backstage/plugin-signals-node'; export const notificationsPlugin = createBackendPlugin({ pluginId: 'notifications', register(env) { + const processingExtensions = + new NotificationsProcessingExtensionPointImpl(); + env.registerExtensionPoint( + notificationsProcessingExtensionPoint, + processingExtensions, + ); + env.registerInit({ deps: { httpRouter: coreServices.httpRouter, @@ -55,6 +83,7 @@ export const notificationsPlugin = createBackendPlugin({ tokenManager, discovery, signalService: signals, + processors: processingExtensions.processors, }), ); }, diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index ee66e410c8..2176f6854c 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -37,8 +37,8 @@ import { RELATION_HAS_MEMBER, stringifyEntityRef, } from '@backstage/catalog-model'; -import { NotificationProcessor } from '../types'; -import { AuthenticationError } from '@backstage/errors'; +import { NotificationProcessor } from '@backstage/plugin-notifications-node'; +import { AuthenticationError, InputError } from '@backstage/errors'; import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; import { SignalService } from '@backstage/plugin-signals-node'; import { @@ -79,7 +79,10 @@ export async function createRouter( const getUser = async (req: Request) => { const user = await identity.getIdentity({ request: req }); - return user ? user.identity.userEntityRef : 'user:default/guest'; + if (!user) { + throw new AuthenticationError(); + } + return user.identity.userEntityRef; }; const authenticateService = async (req: Request) => { @@ -184,7 +187,7 @@ export async function createRouter( router.get('/notifications', async (req, res) => { const user = await getUser(req); const opts: NotificationGetOptions = { - user_ref: user, + user: user, }; if (req.query.type) { opts.type = req.query.type.toString() as NotificationType; @@ -205,7 +208,7 @@ export async function createRouter( router.get('/status', async (req, res) => { const user = await getUser(req); - const status = await store.getStatus({ user_ref: user, type: 'undone' }); + const status = await store.getStatus({ user, type: 'undone' }); res.send(status); }); @@ -213,11 +216,11 @@ export async function createRouter( const user = await getUser(req); const { ids, done, read, saved } = req.body; if (!ids || !Array.isArray(ids)) { - res.status(400).send(); - return; + throw new InputError(); } + if (done === true) { - await store.markDone({ user_ref: user, ids }); + await store.markDone({ user, ids }); if (signalService) { await signalService.publish({ recipients: [user], @@ -226,7 +229,7 @@ export async function createRouter( }); } } else if (done === false) { - await store.markUndone({ user_ref: user, ids }); + await store.markUndone({ user, ids }); if (signalService) { await signalService.publish({ recipients: [user], @@ -237,7 +240,7 @@ export async function createRouter( } if (read === true) { - await store.markRead({ user_ref: user, ids }); + await store.markRead({ user, ids }); if (signalService) { await signalService.publish({ @@ -247,7 +250,7 @@ export async function createRouter( }); } } else if (read === false) { - await store.markUnread({ user_ref: user, ids }); + await store.markUnread({ user: user, ids }); if (signalService) { await signalService.publish({ @@ -259,15 +262,18 @@ export async function createRouter( } if (saved === true) { - await store.markSaved({ user_ref: user, ids }); + await store.markSaved({ user: user, ids }); } else if (saved === false) { - await store.markUnsaved({ user_ref: user, ids }); + await store.markUnsaved({ user: user, ids }); } - const notifications = await store.getNotifications({ ids, user_ref: user }); + const notifications = await store.getNotifications({ ids, user: user }); res.status(200).send(notifications); }); + // Add new notification + // Allowed only for service-to-service authentication, uses `getUsersForEntityRef` to retrieve recipients for + // specific entity reference router.post('/notifications', async (req, res) => { const { recipients, origin, payload } = req.body; const notifications = []; @@ -276,20 +282,18 @@ export async function createRouter( try { await authenticateService(req); } catch (e) { - logger.error(`Failed to authenticate notification request ${e}`); - res.status(401).send(); - return; + throw new AuthenticationError(); } const { title, link, description, scope } = payload; if (!recipients || !title || !origin || !link) { logger.error(`Invalid notification request received`); - res.status(400).send(); - return; + throw new InputError(); } let entityRef = null; + // TODO: Support for broadcast notifications if (recipients.entityRef && recipients.type === 'entity') { entityRef = recipients.entityRef; } @@ -297,12 +301,10 @@ export async function createRouter( try { users = await getUsersForEntityRef(entityRef); } catch (e) { - logger.error(`Failed to resolve notification receiver ${e}`); - res.status(400).send(); - return; + throw new InputError(); } - const baseNotification: Omit = { + const baseNotification: Omit = { payload: { ...payload, severity: payload.severity ?? 'normal', @@ -311,18 +313,19 @@ export async function createRouter( created: new Date(), }; - for (const user of users) { + const uniqueUsers = [...new Set(users)]; + for (const user of uniqueUsers) { const userNotification = { ...baseNotification, id: uuid(), - userRef: user, + user, }; const notification = await decorateNotification(userNotification); let existingNotification; if (scope) { existingNotification = await store.getExistingScopeNotification({ - user_ref: user, + user, scope, origin, }); @@ -345,7 +348,7 @@ export async function createRouter( if (signalService) { await signalService.publish({ - recipients: entityRef === null ? null : users, + recipients: entityRef === null ? null : uniqueUsers, message: { action: 'new_notification', notification: { title, description, link }, @@ -354,7 +357,7 @@ export async function createRouter( }); } - res.send(notifications); + res.json(notifications); }); router.use(errorHandler()); diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index 9ada705f31..d2dc1afb9d 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -6,10 +6,11 @@ // @public (undocumented) type Notification_2 = { id: string; - userRef: string; + user: string; created: Date; saved?: Date; read?: Date; + done?: Date; updated?: Date; origin: string; payload: NotificationPayload; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index c717073403..1e7469244f 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -36,10 +36,11 @@ export type NotificationPayload = { /** @public */ export type Notification = { id: string; - userRef: string; + user: string; created: Date; saved?: Date; read?: Date; + done?: Date; updated?: Date; origin: string; payload: NotificationPayload; diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 17b172a8e5..75a0fb68fd 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -4,26 +4,30 @@ ```ts import { DiscoveryService } from '@backstage/backend-plugin-api'; -import { LoggerService } from '@backstage/backend-plugin-api'; +import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; import { NotificationPayload } from '@backstage/plugin-notifications-common'; import { ServiceRef } from '@backstage/backend-plugin-api'; -import { SignalService } from '@backstage/plugin-signals-node'; import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) export class DefaultNotificationService implements NotificationService { // (undocumented) static create({ - logger, tokenManager, discovery, + pluginId, }: NotificationServiceOptions): DefaultNotificationService; // (undocumented) - forPlugin(pluginId: string): NotificationService; - // (undocumented) send(notification: NotificationSendOptions): Promise; } +// @public (undocumented) +export interface NotificationProcessor { + decorate?(notification: Notification_2): Promise; + send?(notification: Notification_2): Promise; +} + // @public (undocumented) export type NotificationRecipients = { type: 'entity'; @@ -38,8 +42,6 @@ export type NotificationSendOptions = { // @public (undocumented) export interface NotificationService { - // (undocumented) - forPlugin(pluginId: string): NotificationService; // (undocumented) send(options: NotificationSendOptions): Promise; } @@ -49,9 +51,19 @@ export const notificationService: ServiceRef; // @public (undocumented) export type NotificationServiceOptions = { - logger: LoggerService; discovery: DiscoveryService; tokenManager: TokenManager; - signalService: SignalService; + pluginId: string; }; + +// @public (undocumented) +export interface NotificationsProcessingExtensionPoint { + // (undocumented) + addProcessor( + ...processors: Array> + ): void; +} + +// @public (undocumented) +export const notificationsProcessingExtensionPoint: ExtensionPoint; ``` diff --git a/plugins/notifications-backend/src/types.ts b/plugins/notifications-node/src/extensions.ts similarity index 66% rename from plugins/notifications-backend/src/types.ts rename to plugins/notifications-node/src/extensions.ts index 57fe648f30..f8ab26462d 100644 --- a/plugins/notifications-backend/src/types.ts +++ b/plugins/notifications-node/src/extensions.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,10 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { createExtensionPoint } from '@backstage/backend-plugin-api'; import { Notification } from '@backstage/plugin-notifications-common'; -/** @public */ -export type NotificationProcessor = { +/** + * @public + */ +export interface NotificationProcessor { /** * Decorate notification before sending it * @@ -31,4 +34,21 @@ export type NotificationProcessor = { * @param notification - The notification to send */ send?(notification: Notification): Promise; -}; +} + +/** + * @public + */ +export interface NotificationsProcessingExtensionPoint { + addProcessor( + ...processors: Array> + ): void; +} + +/** + * @public + */ +export const notificationsProcessingExtensionPoint = + createExtensionPoint({ + id: 'notifications.processing', + }); diff --git a/plugins/notifications-node/src/index.ts b/plugins/notifications-node/src/index.ts index 8e1a590b3d..05ca83957b 100644 --- a/plugins/notifications-node/src/index.ts +++ b/plugins/notifications-node/src/index.ts @@ -22,3 +22,4 @@ export * from './service'; export * from './lib'; +export * from './extensions'; diff --git a/plugins/notifications-node/src/lib.ts b/plugins/notifications-node/src/lib.ts index 67bb3c315d..c79d871ff8 100644 --- a/plugins/notifications-node/src/lib.ts +++ b/plugins/notifications-node/src/lib.ts @@ -20,7 +20,6 @@ import { } from '@backstage/backend-plugin-api'; import { DefaultNotificationService } from './service'; import { NotificationService } from './service/NotificationService'; -import { signalService } from '@backstage/plugin-signals-node'; /** @public */ export const notificationService = createServiceRef({ @@ -30,20 +29,16 @@ export const notificationService = createServiceRef({ createServiceFactory({ service, deps: { - logger: coreServices.rootLogger, discovery: coreServices.discovery, tokenManager: coreServices.tokenManager, pluginMetadata: coreServices.pluginMetadata, - signals: signalService, }, - factory({ logger, discovery, tokenManager, signals, pluginMetadata }) { - // TODO: Convert to use createRootContext + factory({ discovery, tokenManager, pluginMetadata }) { return DefaultNotificationService.create({ - logger, discovery, tokenManager, - signalService: signals, - }).forPlugin(pluginMetadata.getId()); + pluginId: pluginMetadata.getId(), + }); }, }), }); diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts index 3348c02a6e..0f28f3bc18 100644 --- a/plugins/notifications-node/src/service/DefaultNotificationService.ts +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -15,16 +15,14 @@ */ import { TokenManager } from '@backstage/backend-common'; import { NotificationService } from './NotificationService'; -import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; -import { SignalService } from '@backstage/plugin-signals-node'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import { NotificationPayload } from '@backstage/plugin-notifications-common'; /** @public */ export type NotificationServiceOptions = { - logger: LoggerService; discovery: DiscoveryService; tokenManager: TokenManager; - signalService: SignalService; + pluginId: string; }; /** @public */ @@ -45,34 +43,20 @@ export type NotificationSendOptions = { /** @public */ export class DefaultNotificationService implements NotificationService { private constructor( - private readonly logger: LoggerService, private readonly discovery: DiscoveryService, private readonly tokenManager: TokenManager, - private readonly pluginId?: string, + private readonly pluginId: string, ) {} static create({ - logger, tokenManager, discovery, + pluginId, }: NotificationServiceOptions): DefaultNotificationService { - return new DefaultNotificationService(logger, discovery, tokenManager); - } - - forPlugin(pluginId: string): NotificationService { - return new DefaultNotificationService( - this.logger, - this.discovery, - this.tokenManager, - pluginId, - ); + return new DefaultNotificationService(discovery, tokenManager, pluginId); } async send(notification: NotificationSendOptions): Promise { - if (!this.pluginId) { - throw new Error('Invalid initialization of the NotificationService'); - } - try { const baseUrl = await this.discovery.getBaseUrl('notifications'); const { token } = await this.tokenManager.getToken(); @@ -80,6 +64,7 @@ export class DefaultNotificationService implements NotificationService { method: 'POST', body: JSON.stringify({ ...notification, + // TODO: Should retrieve this in the backend from service auth instead origin: `plugin-${this.pluginId}`, }), headers: { @@ -88,7 +73,8 @@ export class DefaultNotificationService implements NotificationService { }, }); } catch (error) { - this.logger.error(`Failed to send notifications: ${error}`); + // TODO: Should not throw in optimal case, see BEP + throw new Error(`Failed to send notifications: ${error}`); } } } diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts index 5c76dd8088..08b63599cc 100644 --- a/plugins/notifications-node/src/service/NotificationService.ts +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -18,7 +18,5 @@ import { NotificationSendOptions } from './DefaultNotificationService'; /** @public */ export interface NotificationService { - forPlugin(pluginId: string): NotificationService; - send(options: NotificationSendOptions): Promise; } diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md index 3d96934438..e2071dad14 100644 --- a/plugins/notifications/api-report.md +++ b/plugins/notifications/api-report.md @@ -68,7 +68,10 @@ export const notificationsPlugin: BackstagePlugin< >; // @public (undocumented) -export const NotificationsSidebarItem: () => React_2.JSX.Element; +export const NotificationsSidebarItem: (props?: { + webNotificationsEnabled?: boolean; + titleCounterEnabled?: boolean; +}) => React_2.JSX.Element; // @public (undocumented) export const NotificationsTable: (props: { diff --git a/plugins/notifications/config.d.ts b/plugins/notifications/config.d.ts deleted file mode 100644 index 0ae7ea2a1c..0000000000 --- a/plugins/notifications/config.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface Config { - /** @visibility frontend */ - notifications?: { - /** - * Enable or disable the Web Notification API for notifications, defaults to false - * @visibility frontend - */ - enableWebNotifications?: boolean; - /** - * Enable or disable the title override to show notification count, defaults to true - * @visibility frontend - */ - enableTitleCounter?: boolean; - }; -} diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json index ae641439d9..c7b90d5a1f 100644 --- a/plugins/notifications/package.json +++ b/plugins/notifications/package.json @@ -52,8 +52,6 @@ "msw": "^1.0.0" }, "files": [ - "dist", - "config.d.ts" - ], - "configSchema": "config.d.ts" + "dist" + ] } diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx index e13956d7d6..90fba009a3 100644 --- a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx +++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { useNotificationsApi } from '../../hooks'; import { SidebarItem } from '@backstage/core-components'; import NotificationsIcon from '@material-ui/icons/Notifications'; -import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { useRouteRef } from '@backstage/core-plugin-api'; import { rootRouteRef } from '../../routes'; import { useSignal } from '@backstage/plugin-signals-react'; import { useWebNotifications } from '../../hooks/useWebNotifications'; @@ -25,11 +25,16 @@ import { useTitleCounter } from '../../hooks/useTitleCounter'; import { JsonObject } from '@backstage/types'; /** @public */ -export const NotificationsSidebarItem = () => { +export const NotificationsSidebarItem = (props?: { + webNotificationsEnabled?: boolean; + titleCounterEnabled?: boolean; +}) => { + const { webNotificationsEnabled = false, titleCounterEnabled = true } = + props ?? { webNotificationsEnabled: false, titleCounterEnabled: true }; + const { loading, error, value, retry } = useNotificationsApi(api => api.getStatus(), ); - const config = useApi(configApiRef); const [unreadCount, setUnreadCount] = React.useState(0); const notificationsRoute = useRouteRef(rootRouteRef); // TODO: Add signal type support to `useSignal` to make it a bit easier to use @@ -37,16 +42,6 @@ export const NotificationsSidebarItem = () => { const { lastSignal } = useSignal('notifications'); const { sendWebNotification } = useWebNotifications(); const [refresh, setRefresh] = React.useState(false); - const webNotificationsEnabled = useMemo( - () => - config.getOptionalBoolean('notifications.enableWebNotifications') ?? - false, - [config], - ); - const titleCounterEnabled = useMemo( - () => config.getOptionalString('notifications.enableTitleCounter') ?? true, - [config], - ); const { setNotificationCount } = useTitleCounter(); useEffect(() => { diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md index 52d7e37dac..cbf4142daf 100644 --- a/plugins/signals-node/api-report.md +++ b/plugins/signals-node/api-report.md @@ -22,9 +22,9 @@ export type SignalPayload = { }; // @public (undocumented) -export type SignalService = { +export interface SignalService { publish(signal: SignalPayload): Promise; -}; +} // @public (undocumented) export const signalService: ServiceRef; diff --git a/plugins/signals-node/src/SignalService.ts b/plugins/signals-node/src/SignalService.ts index f08a12661f..7d021ccf4e 100644 --- a/plugins/signals-node/src/SignalService.ts +++ b/plugins/signals-node/src/SignalService.ts @@ -16,9 +16,9 @@ import { SignalPayload } from './types'; /** @public */ -export type SignalService = { +export interface SignalService { /** * Publishes a message to user refs to specific topic */ publish(signal: SignalPayload): Promise; -}; +} diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md index 438b3e95cd..ef4bc1b7fc 100644 --- a/plugins/signals-react/api-report.md +++ b/plugins/signals-react/api-report.md @@ -7,18 +7,23 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; // @public (undocumented) -export type SignalApi = { +export interface SignalApi { + // (undocumented) subscribe( channel: string, onMessage: (message: JsonObject) => void, - ): { - unsubscribe: () => void; - }; -}; + ): SignalSubscriber; +} // @public (undocumented) export const signalApiRef: ApiRef; +// @public (undocumented) +export interface SignalSubscriber { + // (undocumented) + unsubscribe(): void; +} + // @public (undocumented) export const useSignal: (channel: string) => { lastSignal: JsonObject | null; diff --git a/plugins/signals-react/src/api/SignalApi.ts b/plugins/signals-react/src/api/SignalApi.ts index b37b3ae2f5..b67b2ea0dc 100644 --- a/plugins/signals-react/src/api/SignalApi.ts +++ b/plugins/signals-react/src/api/SignalApi.ts @@ -22,9 +22,14 @@ export const signalApiRef = createApiRef({ }); /** @public */ -export type SignalApi = { +export interface SignalSubscriber { + unsubscribe(): void; +} + +/** @public */ +export interface SignalApi { subscribe( channel: string, onMessage: (message: JsonObject) => void, - ): { unsubscribe: () => void }; -}; + ): SignalSubscriber; +} diff --git a/plugins/signals/src/api/SignalClient.ts b/plugins/signals/src/api/SignalClient.ts index ee4ed8756b..d6c4634264 100644 --- a/plugins/signals/src/api/SignalClient.ts +++ b/plugins/signals/src/api/SignalClient.ts @@ -146,20 +146,6 @@ export class SignalClient implements SignalApi { url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'; this.ws = new WebSocket(url.toString(), token); - this.ws.onmessage = (data: MessageEvent) => { - this.handleMessage(data); - }; - - this.ws.onerror = () => { - this.reconnect(); - }; - - this.ws.onclose = (ev: CloseEvent) => { - if (ev.code !== WS_CLOSE_NORMAL && ev.code !== WS_CLOSE_GOING_AWAY) { - this.reconnect(); - } - }; - // Wait until connection is open let connectSleep = 0; while ( @@ -174,6 +160,20 @@ export class SignalClient implements SignalApi { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error('Connect timeout'); } + + this.ws.onmessage = (data: MessageEvent) => { + this.handleMessage(data); + }; + + this.ws.onerror = () => { + this.reconnect(); + }; + + this.ws.onclose = (ev: CloseEvent) => { + if (ev.code !== WS_CLOSE_NORMAL && ev.code !== WS_CLOSE_GOING_AWAY) { + this.reconnect(); + } + }; } private handleMessage(data: MessageEvent) { diff --git a/yarn.lock b/yarn.lock index 8a1035010a..06d186eaab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7785,6 +7785,7 @@ __metadata: dependencies: "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" @@ -27226,6 +27227,7 @@ __metadata: "@backstage/plugin-lighthouse-backend": "workspace:^" "@backstage/plugin-linguist-backend": "workspace:^" "@backstage/plugin-nomad-backend": "workspace:^" + "@backstage/plugin-notifications-backend": "workspace:^" "@backstage/plugin-permission-backend": "workspace:^" "@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" @@ -27238,6 +27240,7 @@ __metadata: "@backstage/plugin-search-backend-module-explore": "workspace:^" "@backstage/plugin-search-backend-module-techdocs": "workspace:^" "@backstage/plugin-search-backend-node": "workspace:^" + "@backstage/plugin-signals-backend": "workspace:^" "@backstage/plugin-sonarqube-backend": "workspace:^" "@backstage/plugin-techdocs-backend": "workspace:^" "@backstage/plugin-todo-backend": "workspace:^"