fix: code review findings

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-02-05 10:37:50 +02:00
parent acbe630b9d
commit 819a7302a2
33 changed files with 608 additions and 296 deletions
+4 -31
View File
@@ -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
+3 -8
View File
@@ -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>;
+1 -2
View File
@@ -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());
@@ -1,34 +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 { Notification } from '@backstage/plugin-notifications-common';
/** @public */
export type NotificationProcessor = {
/**
* Decorate notification before sending it
*
* @param notification - The notification to decorate
* @returns The same notification or a modified version of it
*/
decorate?(notification: Notification): Promise<Notification>;
/**
* Send notification using this processor.
*
* @param notification - The notification to send
*/
send?(notification: Notification): Promise<void>;
};