From 1a38100a8e0f15fcc62750d5670aa596d52a9b99 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 12 Jan 2024 15:39:03 +0200 Subject: [PATCH] chore: refactor service and backend to use REST Signed-off-by: Heikki Hellgren --- packages/backend/src/index.ts | 7 +- packages/backend/src/plugins/notifications.ts | 13 +- plugins/notifications-backend/README.md | 9 +- plugins/notifications-backend/api-report.md | 26 ++- .../migrations/20231215_init.js | 0 plugins/notifications-backend/package.json | 6 + .../database/DatabaseNotificationsStore.ts | 2 +- .../src/database/NotificationsStore.ts | 0 .../src/database/index.ts | 0 plugins/notifications-backend/src/index.ts | 1 + plugins/notifications-backend/src/plugin.ts | 21 ++- .../src/service/router.test.ts | 37 +++- .../src/service/router.ts | 158 ++++++++++++++++- .../src/service/standaloneServer.ts | 48 +++-- .../src/types.ts} | 0 plugins/notifications-common/api-report.md | 7 - plugins/notifications-common/src/types.ts | 7 - plugins/notifications-node/README.md | 11 +- plugins/notifications-node/api-report.md | 91 ++-------- plugins/notifications-node/src/index.ts | 1 - plugins/notifications-node/src/lib.ts | 14 +- .../src/service/DefaultNotificationService.ts | 73 ++++++++ .../src/service/NotificationService.ts | 164 +----------------- .../notifications-node/src/service/index.ts | 4 +- .../NotificationsTable/NotificationIcon.tsx | 41 ----- .../NotificationsTable/NotificationsTable.tsx | 19 +- yarn.lock | 6 + 27 files changed, 407 insertions(+), 359 deletions(-) rename plugins/{notifications-node => notifications-backend}/migrations/20231215_init.js (100%) rename plugins/{notifications-node => notifications-backend}/src/database/DatabaseNotificationsStore.ts (98%) rename plugins/{notifications-node => notifications-backend}/src/database/NotificationsStore.ts (100%) rename plugins/{notifications-node => notifications-backend}/src/database/index.ts (100%) rename plugins/{notifications-node/src/service/NotificationProcessor.ts => notifications-backend/src/types.ts} (100%) create mode 100644 plugins/notifications-node/src/service/DefaultNotificationService.ts delete mode 100644 plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index fd29c4a8c7..c60d3d8feb 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -75,7 +75,7 @@ import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { metrics } from '@opentelemetry/api'; import { DefaultSignalService } from '@backstage/plugin-signals-node'; -import { NotificationService } from '@backstage/plugin-notifications-node'; +import { DefaultNotificationService } from '@backstage/plugin-notifications-node'; // Expose opentelemetry metrics using a Prometheus exporter on // http://localhost:9464/metrics . See prometheus.yml in packages/backend for @@ -105,9 +105,10 @@ function makeCreateEnv(config: Config) { const signalService = DefaultSignalService.create({ eventBroker, }); - const notificationService = NotificationService.create({ - database: databaseManager.forPlugin('notifications'), + const notificationService = DefaultNotificationService.create({ + logger: root.child({ type: 'plugin' }), discovery, + tokenManager, }); root.info(`Created UrlReader ${reader}`); diff --git a/packages/backend/src/plugins/notifications.ts b/packages/backend/src/plugins/notifications.ts index 6bef7f0a01..583495cba5 100644 --- a/packages/backend/src/plugins/notifications.ts +++ b/packages/backend/src/plugins/notifications.ts @@ -21,9 +21,20 @@ import { PluginEnvironment } from '../types'; export default async function createPlugin( env: PluginEnvironment, ): Promise { + setInterval(() => { + env.notificationService.send({ + entityRef: 'user:default/guest', + title: 'Test', + description: 'Test', + link: '/catalog', + }); + }, 60000); + return await createRouter({ logger: env.logger, identity: env.identity, - notificationService: env.notificationService, + tokenManager: env.tokenManager, + database: env.database, + discovery: env.discovery, }); } diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md index bb965b6695..da1d64cf6d 100644 --- a/plugins/notifications-backend/README.md +++ b/plugins/notifications-backend/README.md @@ -19,7 +19,9 @@ export default async function createPlugin( return await createRouter({ logger: env.logger, identity: env.identity, - notificationService: env.notificationService, + tokenManager: env.tokenManager, + database: env.database, + discovery: env.discovery, }); } ``` @@ -37,3 +39,8 @@ async function main() { apiRouter.use('/notifications', await notifications(notificationsEnv)); } ``` + +## Extending Notifications + +The notifications can be extended with `NotificationProcessor`. These processors allow to decorate notifications +before they are sent or/and send the notifications to external services. diff --git a/plugins/notifications-backend/api-report.md b/plugins/notifications-backend/api-report.md index 02e86293f9..b10bc65e8c 100644 --- a/plugins/notifications-backend/api-report.md +++ b/plugins/notifications-backend/api-report.md @@ -4,25 +4,43 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CatalogApi } from '@backstage/catalog-client'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; import express from 'express'; import { IdentityApi } from '@backstage/plugin-auth-node'; -import { Logger } from 'winston'; -import { NotificationService } from '@backstage/plugin-notifications-node'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; +// @public (undocumented) +export type NotificationProcessor = { + decorate?(notification: Notification_2): Promise; + send?(notification: Notification_2): Promise; +}; + // @public export const notificationsPlugin: () => BackendFeature; // @public (undocumented) export interface RouterOptions { + // (undocumented) + catalog?: CatalogApi; + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + discovery: DiscoveryApi; // (undocumented) identity: IdentityApi; // (undocumented) - logger: Logger; + logger: LoggerService; // (undocumented) - notificationService: NotificationService; + processors?: NotificationProcessor[]; + // (undocumented) + tokenManager: TokenManager; } // (No @packageDocumentation comment for this package) diff --git a/plugins/notifications-node/migrations/20231215_init.js b/plugins/notifications-backend/migrations/20231215_init.js similarity index 100% rename from plugins/notifications-node/migrations/20231215_init.js rename to plugins/notifications-backend/migrations/20231215_init.js diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index 1ee25ec127..8d56034b47 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -24,14 +24,20 @@ "dependencies": { "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-client": "workspace:^", + "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", + "@backstage/plugin-notifications-common": "workspace:^", "@backstage/plugin-notifications-node": "workspace:^", "@types/express": "*", "express": "^4.17.1", "express-promise-router": "^4.1.0", "knex": "^3.0.0", "node-fetch": "^2.6.7", + "uuid": "^8.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts similarity index 98% rename from plugins/notifications-node/src/database/DatabaseNotificationsStore.ts rename to plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index 836bb50022..b0ab25f2f9 100644 --- a/plugins/notifications-node/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -26,7 +26,7 @@ import { Notification } from '@backstage/plugin-notifications-common'; import { Knex } from 'knex'; const migrationsDir = resolvePackagePath( - '@backstage/plugin-notifications-node', + '@backstage/plugin-notifications-backend', 'migrations', ); diff --git a/plugins/notifications-node/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts similarity index 100% rename from plugins/notifications-node/src/database/NotificationsStore.ts rename to plugins/notifications-backend/src/database/NotificationsStore.ts diff --git a/plugins/notifications-node/src/database/index.ts b/plugins/notifications-backend/src/database/index.ts similarity index 100% rename from plugins/notifications-node/src/database/index.ts rename to plugins/notifications-backend/src/database/index.ts diff --git a/plugins/notifications-backend/src/index.ts b/plugins/notifications-backend/src/index.ts index e3a02aca06..44891496fd 100644 --- a/plugins/notifications-backend/src/index.ts +++ b/plugins/notifications-backend/src/index.ts @@ -15,3 +15,4 @@ */ export * from './service/router'; export * from './plugin'; +export type { NotificationProcessor } from './types'; diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts index d125332c4c..6ab3c464db 100644 --- a/plugins/notifications-backend/src/plugin.ts +++ b/plugins/notifications-backend/src/plugin.ts @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { loggerToWinstonLogger } from '@backstage/backend-common'; import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; -import { notificationService } from '@backstage/plugin-notifications-node'; /** * Notifications backend plugin @@ -34,14 +32,25 @@ export const notificationsPlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, logger: coreServices.logger, identity: coreServices.identity, - service: notificationService, + database: coreServices.database, + tokenManager: coreServices.tokenManager, + discovery: coreServices.discovery, }, - async init({ httpRouter, logger, identity, service }) { + async init({ + httpRouter, + logger, + identity, + database, + tokenManager, + discovery, + }) { httpRouter.use( await createRouter({ - logger: loggerToWinstonLogger(logger), + logger, identity, - notificationService: service, + database, + tokenManager, + discovery, }), ); }, diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index c0f09b3ef9..3ad9b4c531 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -13,13 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { + DatabaseManager, + getVoidLogger, + PluginDatabaseManager, + PluginEndpointDiscovery, + TokenManager, +} from '@backstage/backend-common'; import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; import { IdentityApi } from '@backstage/plugin-auth-node'; -import { NotificationService } from '@backstage/plugin-notifications-node'; +import { ConfigReader } from '@backstage/config'; + +function createDatabase(): PluginDatabaseManager { + return DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('notifications'); +} describe('createRouter', () => { let app: express.Express; @@ -36,17 +55,23 @@ describe('createRouter', () => { }; }, }; + const mockedTokenManager: jest.Mocked = { + getToken: jest.fn(), + authenticate: jest.fn(), + }; - const notificationServiceMock: jest.Mocked> = { - getStore: jest.fn(), - send: jest.fn(), + const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), }; beforeAll(async () => { const router = await createRouter({ logger: getVoidLogger(), identity: identityMock, - notificationService: notificationServiceMock as NotificationService, + database: createDatabase(), + tokenManager: mockedTokenManager, + discovery, }); app = express().use(router); }); diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 06a702f9d1..af5a5a826f 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -13,36 +13,125 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { errorHandler } from '@backstage/backend-common'; +import { + errorHandler, + PluginDatabaseManager, + TokenManager, +} from '@backstage/backend-common'; import express, { Request } from 'express'; import Router from 'express-promise-router'; -import { Logger } from 'winston'; import { + getBearerTokenFromAuthorizationHeader, + IdentityApi, +} from '@backstage/plugin-auth-node'; +import { + DatabaseNotificationsStore, NotificationGetOptions, - NotificationService, -} from '@backstage/plugin-notifications-node'; -import { IdentityApi } from '@backstage/plugin-auth-node'; +} from '../database'; +import { v4 as uuid } from 'uuid'; +import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; +import { + Entity, + isGroupEntity, + isUserEntity, + RELATION_HAS_MEMBER, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { NotificationProcessor } from '../types'; +import { AuthenticationError } from '@backstage/errors'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { LoggerService } from '@backstage/backend-plugin-api'; /** @public */ export interface RouterOptions { - logger: Logger; + logger: LoggerService; identity: IdentityApi; - notificationService: NotificationService; + database: PluginDatabaseManager; + tokenManager: TokenManager; + discovery: DiscoveryApi; + catalog?: CatalogApi; + processors?: NotificationProcessor[]; } /** @public */ export async function createRouter( options: RouterOptions, ): Promise { - const { logger, notificationService, identity } = options; + const { + logger, + database, + identity, + discovery, + catalog, + tokenManager, + processors, + } = options; - const store = await notificationService.getStore(); + const catalogClient = + catalog ?? new CatalogClient({ discoveryApi: discovery }); + const store = await DatabaseNotificationsStore.create({ database }); const getUser = async (req: Request) => { const user = await identity.getIdentity({ request: req }); return user ? user.identity.userEntityRef : 'user:default/guest'; }; + const authenticateService = async (req: Request) => { + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + if (!token) { + throw new AuthenticationError(); + } + await tokenManager.authenticate(token); + }; + + const getUsersForEntityRef = async ( + entityRef: string | string[], + ): Promise => { + const refs = Array.isArray(entityRef) ? entityRef : [entityRef]; + const { token } = await tokenManager.getToken(); + const entities = await catalogClient.getEntitiesByRefs( + { + entityRefs: refs, + }, + { token }, + ); + const mapEntity = async (entity: Entity | undefined): Promise => { + if (!entity) { + return []; + } + + if (isUserEntity(entity)) { + return [stringifyEntityRef(entity)]; + } else if (isGroupEntity(entity) && entity.relations) { + return entity.relations + .filter( + relation => + relation.type === RELATION_HAS_MEMBER && relation.targetRef, + ) + .map(r => r.targetRef); + } else if (!isGroupEntity(entity) && entity.spec?.owner) { + const owner = await catalogClient.getEntityByRef( + entity.spec.owner as string, + { token }, + ); + if (owner) { + return mapEntity(owner); + } + } + + return []; + }; + + const users: string[] = []; + for (const entity of entities.items) { + const u = await mapEntity(entity); + users.push(...u); + } + return users; + }; + const router = Router(); router.use(express.json()); @@ -114,6 +203,57 @@ export async function createRouter( res.status(200).send({ ids }); }); + router.post('/notifications', async (req, res) => { + const { entityRef, title, description, link } = req.body; + const notifications = []; + let users = []; + + try { + await authenticateService(req); + } catch (e) { + logger.error(`Failed to authenticate notification request ${e}`); + res.status(401).send(); + return; + } + + try { + users = await getUsersForEntityRef(entityRef); + } catch (e) { + logger.error(`Failed to resolve notification receiver ${e}`); + res.status(400).send(); + return; + } + + const baseNotification = { + id: uuid(), + title, + description, + link, + created: new Date(), + saved: false, + }; + + for (const user of users) { + let notification = { ...baseNotification, userRef: user }; + for (const processor of processors ?? []) { + notification = processor.decorate + ? await processor.decorate(notification) + : notification; + } + + await store.saveNotification(notification); + for (const processor of processors ?? []) { + if (processor.send) { + processor.send(notification); + } + } + notifications.push(notification); + // TODO: Signal service + } + + res.send(notifications); + }); + router.use(errorHandler()); return router; } diff --git a/plugins/notifications-backend/src/service/standaloneServer.ts b/plugins/notifications-backend/src/service/standaloneServer.ts index c19ae01b74..4a8ba80f72 100644 --- a/plugins/notifications-backend/src/service/standaloneServer.ts +++ b/plugins/notifications-backend/src/service/standaloneServer.ts @@ -15,9 +15,10 @@ */ import { createServiceBuilder, + HostDiscovery, loadBackendConfig, PluginDatabaseManager, - PluginEndpointDiscovery, + ServerTokenManager, } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -25,7 +26,11 @@ import { createRouter } from './router'; import Knex from 'knex'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { Request } from 'express'; -import { NotificationService } from '@backstage/plugin-notifications-node'; +import { + CatalogApi, + CatalogRequestOptions, + GetEntitiesByRefsRequest, +} from '@backstage/catalog-client'; export interface ServerOptions { port: number; @@ -42,26 +47,34 @@ export async function startStandaloneServer( const config = await loadBackendConfig({ logger, argv: process.argv }); const db = Knex(config.get('backend.database')); + const tokenManager = ServerTokenManager.fromConfig(config, { + logger, + }); + const discovery = HostDiscovery.fromConfig(config); + const dbMock: PluginDatabaseManager = { async getClient() { return db; }, }; - const discoveryMock: PluginEndpointDiscovery = { - async getBaseUrl(pluginId: string) { - return `http://localhost:7007/api/${pluginId}`; + const catalogApi = { + async getEntitiesByRefs( + _request: GetEntitiesByRefsRequest, + __options?: CatalogRequestOptions, + ) { + return { + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'user', namespace: 'default' }, + spec: {}, + }, + ], + }; }, - - async getExternalBaseUrl(pluginId: string) { - return `http://localhost:7007/api/${pluginId}`; - }, - }; - - const notificationService = NotificationService.create({ - database: dbMock, - discovery: discoveryMock, - }); + } as Partial as CatalogApi; const identityMock: IdentityApi = { async getIdentity({ request }: { request: Request }) { @@ -80,7 +93,10 @@ export async function startStandaloneServer( const router = await createRouter({ logger, identity: identityMock, - notificationService, + database: dbMock, + catalog: catalogApi, + discovery, + tokenManager, }); let service = createServiceBuilder(module) diff --git a/plugins/notifications-node/src/service/NotificationProcessor.ts b/plugins/notifications-backend/src/types.ts similarity index 100% rename from plugins/notifications-node/src/service/NotificationProcessor.ts rename to plugins/notifications-backend/src/types.ts diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md index 9c085ff521..1fb928f3c4 100644 --- a/plugins/notifications-common/api-report.md +++ b/plugins/notifications-common/api-report.md @@ -3,8 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import * as muiIcons from '@material-ui/icons'; - // @public (undocumented) type Notification_2 = { id: string; @@ -12,17 +10,12 @@ type Notification_2 = { title: string; description: string; link: string; - icon?: NotificationIcon; - image?: string; created: Date; read?: Date; saved: boolean; }; export { Notification_2 as Notification }; -// @public (undocumented) -export type NotificationIcon = keyof typeof muiIcons; - // @public (undocumented) export type NotificationIds = { ids: string[]; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index f0e6feae30..2dd3f9bcb7 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -14,14 +14,9 @@ * limitations under the License. */ -import * as muiIcons from '@material-ui/icons'; - /** @public */ export type NotificationType = 'read' | 'unread' | 'saved'; -/** @public */ -export type NotificationIcon = keyof typeof muiIcons; - /** @public */ export type Notification = { id: string; @@ -29,8 +24,6 @@ export type Notification = { title: string; description: string; link: string; - icon?: NotificationIcon; - image?: string; created: Date; read?: Date; saved: boolean; diff --git a/plugins/notifications-node/README.md b/plugins/notifications-node/README.md index 2a1e61638d..31fe22baf9 100644 --- a/plugins/notifications-node/README.md +++ b/plugins/notifications-node/README.md @@ -15,10 +15,12 @@ import { NotificationService } from '@backstage/plugin-notifications-node'; function makeCreateEnv(config: Config) { // ... - const notificationService = NotificationService.create({ - database: databaseManager.forPlugin('notifications'), + const notificationService = DefaultNotificationService.create({ + logger: root.child({ type: 'plugin' }), discovery, + tokenManager, }); + // ... return (plugin: string): PluginEnvironment => { // ... @@ -51,8 +53,3 @@ save the notification and optionally signal the frontend to show the latest stat When sending notifications, you can specify the entity reference of the notification. If the entity reference is a user, the notification will be sent to only that user. If it's a group, the notification will be sent to all members of the group. If it's some other entity, the notification will be sent to the owner of that entity. - -## Extending Notification Service - -The `NotificationService` can be extended with `NotificationProcessor`. These processors allow to decorate notifications -before they are sent or/and send the notifications to external services. diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md index 7762d41550..b7f8047983 100644 --- a/plugins/notifications-node/api-report.md +++ b/plugins/notifications-node/api-report.md @@ -3,111 +3,44 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { LoggerService } from '@backstage/backend-plugin-api'; import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; -import { NotificationIcon } from '@backstage/plugin-notifications-common'; -import { NotificationStatus } from '@backstage/plugin-notifications-common'; -import { NotificationType } from '@backstage/plugin-notifications-common'; -import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { ServiceRef } from '@backstage/backend-plugin-api'; +import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) -export class DatabaseNotificationsStore implements NotificationsStore { +export class DefaultNotificationService implements NotificationService { // (undocumented) static create({ - database, - skipMigrations, - }: { - database: PluginDatabaseManager; - skipMigrations?: boolean; - }): Promise; + logger, + tokenManager, + discovery, + }: NotificationServiceOptions): DefaultNotificationService; // (undocumented) - getNotifications(options: NotificationGetOptions): Promise; - // (undocumented) - getStatus(options: NotificationGetOptions): Promise<{ - unread: number; - read: number; - }>; - // (undocumented) - markRead(options: NotificationModifyOptions): Promise; - // (undocumented) - markSaved(options: NotificationModifyOptions): Promise; - // (undocumented) - markUnread(options: NotificationModifyOptions): Promise; - // (undocumented) - markUnsaved(options: NotificationModifyOptions): Promise; - // (undocumented) - saveNotification(notification: Notification_2): Promise; + send(options: NotificationSendOptions): Promise; } -// @public (undocumented) -export type NotificationGetOptions = { - user_ref: string; - type?: NotificationType; -}; - -// @public (undocumented) -export type NotificationModifyOptions = { - ids: string[]; -} & NotificationGetOptions; - -// @public (undocumented) -export type NotificationProcessor = { - decorate?(notification: Notification_2): Promise; - send?(notification: Notification_2): Promise; -}; - // @public (undocumented) export type NotificationSendOptions = { entityRef: string | string[]; title: string; description: string; link: string; - image?: string; - icon?: NotificationIcon; }; // @public (undocumented) -export class NotificationService { - // (undocumented) - addProcessor(processor: NotificationProcessor): this; - // (undocumented) - static create({ - database, - discovery, - processors, - }: NotificationServiceOptions): NotificationService; - // (undocumented) - getStore(): Promise; - // (undocumented) +export type NotificationService = { send(options: NotificationSendOptions): Promise; -} +}; // @public (undocumented) export const notificationService: ServiceRef; // @public (undocumented) export type NotificationServiceOptions = { - database: PluginDatabaseManager; + logger: LoggerService; discovery: PluginEndpointDiscovery; - processors?: NotificationProcessor[]; + tokenManager: TokenManager; }; - -// @public (undocumented) -export interface NotificationsStore { - // (undocumented) - getNotifications(options: NotificationGetOptions): Promise; - // (undocumented) - getStatus(options: NotificationGetOptions): Promise; - // (undocumented) - markRead(options: NotificationModifyOptions): Promise; - // (undocumented) - markSaved(options: NotificationModifyOptions): Promise; - // (undocumented) - markUnread(options: NotificationModifyOptions): Promise; - // (undocumented) - markUnsaved(options: NotificationModifyOptions): Promise; - // (undocumented) - saveNotification(notification: Notification_2): Promise; -} ``` diff --git a/plugins/notifications-node/src/index.ts b/plugins/notifications-node/src/index.ts index f820946cc0..8e1a590b3d 100644 --- a/plugins/notifications-node/src/index.ts +++ b/plugins/notifications-node/src/index.ts @@ -20,6 +20,5 @@ * @packageDocumentation */ -export * from './database'; export * from './service'; export * from './lib'; diff --git a/plugins/notifications-node/src/lib.ts b/plugins/notifications-node/src/lib.ts index 05a1321bc1..2cf396bbc3 100644 --- a/plugins/notifications-node/src/lib.ts +++ b/plugins/notifications-node/src/lib.ts @@ -18,7 +18,8 @@ import { createServiceFactory, createServiceRef, } from '@backstage/backend-plugin-api'; -import { NotificationService } from './service'; +import { DefaultNotificationService } from './service'; +import { NotificationService } from './service/NotificationService'; /** @public */ export const notificationService = createServiceRef({ @@ -28,12 +29,17 @@ export const notificationService = createServiceRef({ createServiceFactory({ service, deps: { + logger: coreServices.logger, discovery: coreServices.discovery, - database: coreServices.database, + tokenManager: coreServices.tokenManager, // TODO: Signals }, - factory({ discovery, database }) { - return NotificationService.create({ discovery, database }); + factory({ logger, discovery, tokenManager }) { + return DefaultNotificationService.create({ + logger, + discovery, + tokenManager, + }); }, }), }); diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts new file mode 100644 index 0000000000..5580773151 --- /dev/null +++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Notification } from '@backstage/plugin-notifications-common'; +import { + PluginEndpointDiscovery, + TokenManager, +} from '@backstage/backend-common'; +import { NotificationService } from './NotificationService'; +import { LoggerService } from '@backstage/backend-plugin-api'; + +/** @public */ +export type NotificationServiceOptions = { + logger: LoggerService; + discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; +}; + +/** @public */ +export type NotificationSendOptions = { + entityRef: string | string[]; + title: string; + description: string; + link: string; +}; + +/** @public */ +export class DefaultNotificationService implements NotificationService { + private constructor( + private readonly logger: LoggerService, + private readonly discovery: PluginEndpointDiscovery, + private readonly tokenManager: TokenManager, + ) {} + + static create({ + logger, + tokenManager, + discovery, + }: NotificationServiceOptions): DefaultNotificationService { + return new DefaultNotificationService(logger, discovery, tokenManager); + } + + async send(options: NotificationSendOptions): Promise { + try { + const baseUrl = await this.discovery.getBaseUrl('notifications'); + const { token } = await this.tokenManager.getToken(); + const response = await fetch(`${baseUrl}/notifications`, { + method: 'POST', + body: JSON.stringify(options), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + return await response.json(); + } catch (error) { + this.logger.error(`Failed to send notifications: ${error}`); + return []; + } + } +} diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts index 288da01dc4..b2fc4f8a3d 100644 --- a/plugins/notifications-node/src/service/NotificationService.ts +++ b/plugins/notifications-node/src/service/NotificationService.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,163 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Notification, - NotificationIcon, -} from '@backstage/plugin-notifications-common'; -import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; -import { NotificationsStore } from '../database/NotificationsStore'; -import { v4 as uuid } from 'uuid'; -import { - Entity, - isGroupEntity, - isUserEntity, - RELATION_HAS_MEMBER, - stringifyEntityRef, -} from '@backstage/catalog-model'; -import { - PluginDatabaseManager, - PluginEndpointDiscovery, -} from '@backstage/backend-common'; -import { DatabaseNotificationsStore } from '../database'; -import { NotificationProcessor } from './NotificationProcessor'; + +import { NotificationSendOptions } from './DefaultNotificationService'; +import { Notification } from '@backstage/plugin-notifications-common'; /** @public */ -export type NotificationServiceOptions = { - database: PluginDatabaseManager; - discovery: PluginEndpointDiscovery; - processors?: NotificationProcessor[]; +export type NotificationService = { + send(options: NotificationSendOptions): Promise; }; - -/** @public */ -export type NotificationSendOptions = { - entityRef: string | string[]; - title: string; - description: string; - link: string; - image?: string; - icon?: NotificationIcon; -}; - -/** @public */ -export class NotificationService { - private store: NotificationsStore | null = null; - private readonly processors: NotificationProcessor[]; - - private constructor( - private readonly database: PluginDatabaseManager, - private readonly catalog: CatalogApi, - processors?: NotificationProcessor[], - ) { - this.processors = processors ?? []; - } - - static create({ - database, - discovery, - processors, - }: NotificationServiceOptions): NotificationService { - const catalogClient = new CatalogClient({ - discoveryApi: discovery, - }); - - return new NotificationService(database, catalogClient, processors); - } - - addProcessor(processor: NotificationProcessor) { - this.processors.push(processor); - return this; - } - - async send(options: NotificationSendOptions): Promise { - const { entityRef, title, description, link, icon, image } = options; - const notifications = []; - let users = []; - try { - users = await this.getUsersForEntityRef(entityRef); - } catch (e) { - return []; - } - - const store = await this.getStore(); - const baseNotification = { - id: uuid(), - title, - description, - link, - created: new Date(), - icon, - image, - saved: false, - }; - - for (const user of users) { - let notification: Notification = { ...baseNotification, userRef: user }; - for (const processor of this.processors) { - notification = processor.decorate - ? await processor.decorate(notification) - : notification; - } - - await store.saveNotification(notification); - for (const processor of this.processors) { - if (processor.send) { - processor.send(notification); - } - } - notifications.push(notification); - // TODO: Signal service - } - - return notifications; - } - - async getStore(): Promise { - if (!this.store) { - this.store = await DatabaseNotificationsStore.create({ - database: this.database, - }); - } - return this.store; - } - - private async getUsersForEntityRef( - entityRef: string | string[], - ): Promise { - const refs = Array.isArray(entityRef) ? entityRef : [entityRef]; - const entities = await this.catalog.getEntitiesByRefs({ entityRefs: refs }); - - const mapEntity = async (entity: Entity | undefined): Promise => { - if (!entity) { - return []; - } - - if (isUserEntity(entity)) { - return [stringifyEntityRef(entity)]; - } else if (isGroupEntity(entity) && entity.relations) { - return entity.relations - .filter( - relation => - relation.type === RELATION_HAS_MEMBER && relation.targetRef, - ) - .map(r => r.targetRef); - } else if (!isGroupEntity(entity) && entity.spec?.owner) { - const owner = await this.catalog.getEntityByRef( - entity.spec.owner as string, - ); - if (owner) { - return mapEntity(owner); - } - } - - return []; - }; - - const users: string[] = []; - for (const entity of entities.items) { - const u = await mapEntity(entity); - users.push(...u); - } - return users; - } -} diff --git a/plugins/notifications-node/src/service/index.ts b/plugins/notifications-node/src/service/index.ts index 718e9a5567..37c3bff908 100644 --- a/plugins/notifications-node/src/service/index.ts +++ b/plugins/notifications-node/src/service/index.ts @@ -13,5 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './NotificationService'; -export * from './NotificationProcessor'; +export * from './DefaultNotificationService'; +export type { NotificationService } from './NotificationService'; diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx deleted file mode 100644 index f5ae2e8c22..0000000000 --- a/plugins/notifications/src/components/NotificationsTable/NotificationIcon.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import NotificationsIcon from '@material-ui/icons/Notifications'; -import { Notification } from '@backstage/plugin-notifications-common'; -// eslint-disable-next-line no-restricted-imports -import * as muiIcons from '@material-ui/icons'; -import Avatar from '@material-ui/core/Avatar'; - -/** @internal */ -export const NotificationIcon = (props: { notification: Notification }) => { - const { notification } = props; - if (notification.icon && notification.icon in muiIcons) { - const Icon = muiIcons[notification.icon]; - return ; - } - - if (notification.image) { - return ( - - ); - } - - return ; -}; diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx index acdb2b8b59..1ebd63e1d4 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx @@ -41,10 +41,15 @@ import CloseIcon from '@material-ui/icons/Close'; import { Skeleton } from '@material-ui/lab'; // @ts-ignore import RelativeTime from 'react-relative-time'; -import { NotificationIcon } from './NotificationIcon'; import ArrowForwardIcon from '@material-ui/icons/ArrowForward'; const useStyles = makeStyles(theme => ({ + table: { + border: `1px solid ${theme.palette.divider}`, + }, + header: { + borderBottom: `1px solid ${theme.palette.divider}`, + }, notificationRow: { cursor: 'pointer', '&.hideOnHover': { @@ -54,7 +59,6 @@ const useStyles = makeStyles(theme => ({ display: 'none', }, '&:hover': { - backgroundColor: theme.palette.background.paper, '& .hideOnHover': { display: 'none', }, @@ -112,7 +116,7 @@ export const NotificationsTable = (props: { } return ( - +
@@ -172,9 +176,13 @@ export const NotificationsTable = (props: { {props.notifications?.map(notification => { return ( - + onCheckBoxClick(notification.id)} /> - navigate(notification.link)} diff --git a/yarn.lock b/yarn.lock index fe739b0049..11c6352623 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7785,9 +7785,14 @@ __metadata: dependencies: "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" + "@backstage/plugin-notifications-common": "workspace:^" "@backstage/plugin-notifications-node": "workspace:^" "@types/express": "*" "@types/supertest": ^2.0.8 @@ -7797,6 +7802,7 @@ __metadata: msw: ^1.0.0 node-fetch: ^2.6.7 supertest: ^6.2.4 + uuid: ^8.0.0 winston: ^3.2.1 yn: ^4.0.0 languageName: unknown