fix: code review findings
Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
@@ -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:^"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Router> {
|
||||
return await createRouter({
|
||||
logger: env.logger,
|
||||
identity: env.identity,
|
||||
tokenManager: env.tokenManager,
|
||||
database: env.database,
|
||||
discovery: env.discovery,
|
||||
signalService: env.signalService,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<Router> {
|
||||
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
|
||||
|
||||
@@ -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<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;
|
||||
const notificationsPlugin: () => BackendFeature;
|
||||
export default notificationsPlugin;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface RouterOptions {
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Notification> = {
|
||||
user,
|
||||
created: new Date(),
|
||||
origin: 'plugin-test',
|
||||
payload: {
|
||||
title: 'Notification 1',
|
||||
link: '/catalog',
|
||||
severity: 'normal',
|
||||
},
|
||||
};
|
||||
|
||||
const otherUserNotification: Partial<Notification> = {
|
||||
...testNotification,
|
||||
user: 'user:default/jane.doe',
|
||||
};
|
||||
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DatabaseNotificationsStore (%s)',
|
||||
databaseId => {
|
||||
let storage: DatabaseNotificationsStore;
|
||||
let knex: Knex;
|
||||
const insertNotification = async (
|
||||
notification: Partial<Notification> & {
|
||||
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();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<Notification | null> {
|
||||
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<void> {
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
getExistingScopeNotification(options: {
|
||||
user_ref: string;
|
||||
user: string;
|
||||
scope: string;
|
||||
origin: string;
|
||||
}): Promise<Notification | null>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<NotificationProcessor>();
|
||||
|
||||
addProcessor(
|
||||
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
|
||||
): 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<unknown>) => {
|
||||
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<unknown>) => {
|
||||
@@ -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<Notification, 'id' | 'userRef'> = {
|
||||
const baseNotification: Omit<Notification, 'id' | 'user'> = {
|
||||
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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface NotificationProcessor {
|
||||
decorate?(notification: Notification_2): Promise<Notification_2>;
|
||||
send?(notification: Notification_2): Promise<void>;
|
||||
}
|
||||
|
||||
// @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<void>;
|
||||
}
|
||||
@@ -49,9 +51,19 @@ export const notificationService: ServiceRef<NotificationService, 'plugin'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type NotificationServiceOptions = {
|
||||
logger: LoggerService;
|
||||
discovery: DiscoveryService;
|
||||
tokenManager: TokenManager;
|
||||
signalService: SignalService;
|
||||
pluginId: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface NotificationsProcessingExtensionPoint {
|
||||
// (undocumented)
|
||||
addProcessor(
|
||||
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
|
||||
): void;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const notificationsProcessingExtensionPoint: ExtensionPoint<NotificationsProcessingExtensionPoint>;
|
||||
```
|
||||
|
||||
+24
-4
@@ -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<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface NotificationsProcessingExtensionPoint {
|
||||
addProcessor(
|
||||
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const notificationsProcessingExtensionPoint =
|
||||
createExtensionPoint<NotificationsProcessingExtensionPoint>({
|
||||
id: 'notifications.processing',
|
||||
});
|
||||
@@ -22,3 +22,4 @@
|
||||
|
||||
export * from './service';
|
||||
export * from './lib';
|
||||
export * from './extensions';
|
||||
|
||||
@@ -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<NotificationService>({
|
||||
@@ -30,20 +29,16 @@ export const notificationService = createServiceRef<NotificationService>({
|
||||
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(),
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,5 @@ import { NotificationSendOptions } from './DefaultNotificationService';
|
||||
|
||||
/** @public */
|
||||
export interface NotificationService {
|
||||
forPlugin(pluginId: string): NotificationService;
|
||||
|
||||
send(options: NotificationSendOptions): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Vendored
-31
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -52,8 +52,6 @@
|
||||
"msw": "^1.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
||||
+9
-14
@@ -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(() => {
|
||||
|
||||
@@ -22,9 +22,9 @@ export type SignalPayload = {
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type SignalService = {
|
||||
export interface SignalService {
|
||||
publish(signal: SignalPayload): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const signalService: ServiceRef<SignalService, 'plugin'>;
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<SignalApi>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SignalSubscriber {
|
||||
// (undocumented)
|
||||
unsubscribe(): void;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const useSignal: (channel: string) => {
|
||||
lastSignal: JsonObject | null;
|
||||
|
||||
@@ -22,9 +22,14 @@ export const signalApiRef = createApiRef<SignalApi>({
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export type SignalApi = {
|
||||
export interface SignalSubscriber {
|
||||
unsubscribe(): void;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface SignalApi {
|
||||
subscribe(
|
||||
channel: string,
|
||||
onMessage: (message: JsonObject) => void,
|
||||
): { unsubscribe: () => void };
|
||||
};
|
||||
): SignalSubscriber;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
Reference in New Issue
Block a user