chore: refactor service and backend to use REST

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-01-12 15:39:03 +02:00
parent 13346970b4
commit 1a38100a8e
27 changed files with 407 additions and 359 deletions
+4 -3
View File
@@ -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}`);
+12 -1
View File
@@ -21,9 +21,20 @@ import { PluginEnvironment } from '../types';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
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,
});
}
+8 -1
View File
@@ -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.
+22 -4
View File
@@ -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<express.Router>;
// @public (undocumented)
export type NotificationProcessor = {
decorate?(notification: Notification_2): Promise<Notification_2>;
send?(notification: Notification_2): Promise<void>;
};
// @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)
@@ -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"
},
@@ -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',
);
@@ -15,3 +15,4 @@
*/
export * from './service/router';
export * from './plugin';
export type { NotificationProcessor } from './types';
+15 -6
View File
@@ -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,
}),
);
},
@@ -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<TokenManager> = {
getToken: jest.fn(),
authenticate: jest.fn(),
};
const notificationServiceMock: jest.Mocked<Partial<NotificationService>> = {
getStore: jest.fn(),
send: jest.fn(),
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
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);
});
@@ -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<express.Router> {
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<unknown>) => {
const user = await identity.getIdentity({ request: req });
return user ? user.identity.userEntityRef : 'user:default/guest';
};
const authenticateService = async (req: Request<unknown>) => {
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
if (!token) {
throw new AuthenticationError();
}
await tokenManager.authenticate(token);
};
const getUsersForEntityRef = async (
entityRef: string | string[],
): Promise<string[]> => {
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<string[]> => {
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;
}
@@ -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<CatalogApi> as CatalogApi;
const identityMock: IdentityApi = {
async getIdentity({ request }: { request: Request<unknown> }) {
@@ -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)
@@ -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[];
@@ -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;
+4 -7
View File
@@ -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.
+12 -79
View File
@@ -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<DatabaseNotificationsStore>;
logger,
tokenManager,
discovery,
}: NotificationServiceOptions): DefaultNotificationService;
// (undocumented)
getNotifications(options: NotificationGetOptions): Promise<any[]>;
// (undocumented)
getStatus(options: NotificationGetOptions): Promise<{
unread: number;
read: number;
}>;
// (undocumented)
markRead(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markSaved(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markUnread(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markUnsaved(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
saveNotification(notification: Notification_2): Promise<void>;
send(options: NotificationSendOptions): Promise<Notification_2[]>;
}
// @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<Notification_2>;
send?(notification: Notification_2): Promise<void>;
};
// @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<NotificationsStore>;
// (undocumented)
export type NotificationService = {
send(options: NotificationSendOptions): Promise<Notification_2[]>;
}
};
// @public (undocumented)
export const notificationService: ServiceRef<NotificationService, 'plugin'>;
// @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<Notification_2[]>;
// (undocumented)
getStatus(options: NotificationGetOptions): Promise<NotificationStatus>;
// (undocumented)
markRead(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markSaved(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markUnread(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
markUnsaved(options: NotificationModifyOptions): Promise<void>;
// (undocumented)
saveNotification(notification: Notification_2): Promise<void>;
}
```
-1
View File
@@ -20,6 +20,5 @@
* @packageDocumentation
*/
export * from './database';
export * from './service';
export * from './lib';
+10 -4
View File
@@ -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<NotificationService>({
@@ -28,12 +29,17 @@ export const notificationService = createServiceRef<NotificationService>({
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,
});
},
}),
});
@@ -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<Notification[]> {
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 [];
}
}
}
@@ -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<Notification[]>;
};
/** @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<Notification[]> {
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<NotificationsStore> {
if (!this.store) {
this.store = await DatabaseNotificationsStore.create({
database: this.database,
});
}
return this.store;
}
private async getUsersForEntityRef(
entityRef: string | string[],
): Promise<string[]> {
const refs = Array.isArray(entityRef) ? entityRef : [entityRef];
const entities = await this.catalog.getEntitiesByRefs({ entityRefs: refs });
const mapEntity = async (entity: Entity | undefined): Promise<string[]> => {
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;
}
}
@@ -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';
@@ -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 <Icon fontSize="small" />;
}
if (notification.image) {
return (
<Avatar
src={notification.image}
style={{ width: '20px', height: '20px' }}
/>
);
}
return <NotificationsIcon fontSize="small" />;
};
@@ -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 (
<Table size="small">
<Table size="small" className={styles.table}>
<TableHead>
<TableRow>
<TableCell colSpan={3}>
@@ -172,9 +176,13 @@ export const NotificationsTable = (props: {
</TableHead>
{props.notifications?.map(notification => {
return (
<TableRow key={notification.id} className={styles.notificationRow}>
<TableRow
key={notification.id}
className={styles.notificationRow}
hover
>
<TableCell
width="80px"
width="60px"
style={{ verticalAlign: 'center', paddingRight: '0px' }}
>
<Checkbox
@@ -183,7 +191,6 @@ export const NotificationsTable = (props: {
checked={isChecked(notification.id)}
onClick={() => onCheckBoxClick(notification.id)}
/>
<NotificationIcon notification={notification} />
</TableCell>
<TableCell
onClick={() => navigate(notification.link)}
+6
View File
@@ -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