diff --git a/.changeset/wicked-ants-reflect.md b/.changeset/wicked-ants-reflect.md
new file mode 100644
index 0000000000..4882d12a34
--- /dev/null
+++ b/.changeset/wicked-ants-reflect.md
@@ -0,0 +1,8 @@
+---
+'@backstage/plugin-notifications-backend': patch
+'@backstage/plugin-notifications-common': patch
+'@backstage/plugin-notifications-node': patch
+'@backstage/plugin-notifications': patch
+---
+
+Initial notifications system for backstage
diff --git a/packages/app/package.json b/packages/app/package.json
index 31be0e1610..43b12a1cb7 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -59,6 +59,7 @@
"@backstage/plugin-newrelic": "workspace:^",
"@backstage/plugin-newrelic-dashboard": "workspace:^",
"@backstage/plugin-nomad": "workspace:^",
+ "@backstage/plugin-notifications": "workspace:^",
"@backstage/plugin-octopus-deploy": "workspace:^",
"@backstage/plugin-org": "workspace:^",
"@backstage/plugin-pagerduty": "workspace:^",
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 90190b50be..3d8bd45e5a 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -67,15 +67,15 @@ import { SearchPage } from '@backstage/plugin-search';
import { TechRadarPage } from '@backstage/plugin-tech-radar';
import {
TechDocsIndexPage,
- TechDocsReaderPage,
techdocsPlugin,
+ TechDocsReaderPage,
} from '@backstage/plugin-techdocs';
import { TechDocsAddons } from '@backstage/plugin-techdocs-react';
import {
ExpandableNavigation,
+ LightBox,
ReportIssue,
TextSize,
- LightBox,
} from '@backstage/plugin-techdocs-module-addons-contrib';
import {
SettingsLayout,
@@ -107,6 +107,7 @@ import { PuppetDbPage } from '@backstage/plugin-puppetdb';
import { DevToolsPage } from '@backstage/plugin-devtools';
import { customDevToolsPage } from './components/devtools/CustomDevToolsPage';
import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities';
+import { NotificationsPage } from '@backstage/plugin-notifications';
const app = createApp({
apis,
@@ -272,6 +273,7 @@ const routes = (
}>
{customDevToolsPage}
+ } />
);
diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx
index 5f11218bba..6294aa7856 100644
--- a/packages/app/src/components/Root/Root.tsx
+++ b/packages/app/src/components/Root/Root.tsx
@@ -34,6 +34,7 @@ import {
import { SidebarSearchModal } from '@backstage/plugin-search';
import { Shortcuts } from '@backstage/plugin-shortcuts';
import {
+ Link,
Sidebar,
sidebarConfig,
SidebarDivider,
@@ -42,16 +43,16 @@ import {
SidebarPage,
SidebarScrollWrapper,
SidebarSpace,
- Link,
- useSidebarOpenState,
SidebarSubmenu,
SidebarSubmenuItem,
+ useSidebarOpenState,
} from '@backstage/core-components';
import { MyGroupsSidebarItem } from '@backstage/plugin-org';
import { SearchModal } from '../search/SearchModal';
import Score from '@material-ui/icons/Score';
import { useApp } from '@backstage/core-plugin-api';
import BuildIcon from '@material-ui/icons/Build';
+import { NotificationsSidebarItem } from '@backstage/plugin-notifications';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -166,6 +167,8 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
+
+
diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json
index eca36208a9..2743a46335 100644
--- a/packages/backend-next/package.json
+++ b/packages/backend-next/package.json
@@ -45,6 +45,7 @@
"@backstage/plugin-lighthouse-backend": "workspace:^",
"@backstage/plugin-linguist-backend": "workspace:^",
"@backstage/plugin-nomad-backend": "workspace:^",
+ "@backstage/plugin-notifications-backend": "workspace:^",
"@backstage/plugin-permission-backend": "workspace:^",
"@backstage/plugin-permission-backend-module-allow-all-policy": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
@@ -57,6 +58,7 @@
"@backstage/plugin-search-backend-module-explore": "workspace:^",
"@backstage/plugin-search-backend-module-techdocs": "workspace:^",
"@backstage/plugin-search-backend-node": "workspace:^",
+ "@backstage/plugin-signals-backend": "workspace:^",
"@backstage/plugin-sonarqube-backend": "workspace:^",
"@backstage/plugin-techdocs-backend": "workspace:^",
"@backstage/plugin-todo-backend": "workspace:^"
diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts
index 53a51fcfa7..9c61b98ec4 100644
--- a/packages/backend-next/src/index.ts
+++ b/packages/backend-next/src/index.ts
@@ -51,5 +51,7 @@ backend.add(import('@backstage/plugin-search-backend/alpha'));
backend.add(import('@backstage/plugin-techdocs-backend/alpha'));
backend.add(import('@backstage/plugin-todo-backend'));
backend.add(import('@backstage/plugin-sonarqube-backend'));
+backend.add(import('@backstage/plugin-signals-backend'));
+backend.add(import('@backstage/plugin-notifications-backend'));
backend.start();
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 3dad2f739b..d76e68c1c9 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -27,7 +27,7 @@ import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { EventBroker } from '@backstage/plugin-events-node';
-import { DefaultSignalService } from '@backstage/plugin-signals-node';
+import { SignalService } from '@backstage/plugin-signals-node';
export type PluginEnvironment = {
logger: Logger;
@@ -41,5 +41,5 @@ export type PluginEnvironment = {
scheduler: PluginTaskScheduler;
identity: IdentityApi;
eventBroker: EventBroker;
- signalService: DefaultSignalService;
+ signalService: SignalService;
};
diff --git a/plugins/notifications-backend/.eslintrc.js b/plugins/notifications-backend/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/notifications-backend/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/notifications-backend/README.md b/plugins/notifications-backend/README.md
new file mode 100644
index 0000000000..233d0e5e21
--- /dev/null
+++ b/plugins/notifications-backend/README.md
@@ -0,0 +1,77 @@
+# notifications
+
+Welcome to the notifications backend plugin!
+
+## Getting started
+
+Add the notifications to your backend:
+
+```ts
+const backend = createBackend();
+// ...
+backend.add(import('@backstage/plugin-notifications-backend'));
+```
+
+For users to be able to see notifications in real-time, you have to install also
+the signals plugin (`@backstage/plugin-signals-node`, `@backstage/plugin-signals-backend`, and
+`@backstage/plugin-signals`).
+
+## 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.
+
+Start off by creating a notification processor:
+
+```ts
+import { Notification } from '@backstage/plugin-notifications-common';
+import { NotificationProcessor } from '@backstage/plugin-notifications-node';
+
+class MyNotificationProcessor implements NotificationProcessor {
+ async decorate(notification: Notification): Promise {
+ if (notification.origin === 'plugin-my-plugin') {
+ notification.payload.icon = 'my-icon';
+ }
+ return notification;
+ }
+
+ async send(notification: Notification): Promise {
+ nodemailer.sendEmail({
+ from: 'backstage',
+ to: 'user',
+ subject: notification.payload.title,
+ text: notification.payload.description,
+ });
+ }
+}
+```
+
+Both of the processing functions are optional, and you can implement only one of them.
+
+Add the notification processor to the notification system by:
+
+```ts
+import { notificationsProcessingExtensionPoint } from '@backstage/plugin-notifications-node';
+import { Notification } from '@backstage/plugin-notifications-common';
+
+export const myPlugin = createBackendPlugin({
+ pluginId: 'myPlugin',
+ register(env) {
+ env.registerInit({
+ deps: {
+ notifications: notificationsProcessingExtensionPoint,
+ // ...
+ },
+ async init({ notifications }) {
+ // ...
+ notifications.addProcessor(new MyNotificationProcessor());
+ },
+ });
+ },
+});
+```
+
+## Sending notifications
+
+To be able to send notifications to users, you have to integrate the `@backstage/plugin-notifications-node`
+to your application and plugins.
diff --git a/plugins/notifications-backend/api-report.md b/plugins/notifications-backend/api-report.md
new file mode 100644
index 0000000000..6add3000f1
--- /dev/null
+++ b/plugins/notifications-backend/api-report.md
@@ -0,0 +1,13 @@
+## API Report File for "@backstage/plugin-notifications-backend"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { BackendFeature } from '@backstage/backend-plugin-api';
+
+// @public
+const notificationsPlugin: () => BackendFeature;
+export default notificationsPlugin;
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/notifications-backend/catalog-info.yaml b/plugins/notifications-backend/catalog-info.yaml
new file mode 100644
index 0000000000..66b39c39df
--- /dev/null
+++ b/plugins/notifications-backend/catalog-info.yaml
@@ -0,0 +1,9 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: backstage-plugin-notifications-backend
+ title: '@backstage/plugin-notifications-backend'
+spec:
+ lifecycle: experimental
+ type: backstage-backend-plugin
+ owner: maintainers
diff --git a/plugins/notifications-backend/migrations/20231215_init.js b/plugins/notifications-backend/migrations/20231215_init.js
new file mode 100644
index 0000000000..a1e57237b6
--- /dev/null
+++ b/plugins/notifications-backend/migrations/20231215_init.js
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+exports.up = async function up(knex) {
+ await knex.schema.createTable('notification', table => {
+ table.uuid('id').primary();
+ table.string('user', 255).notNullable();
+ table.string('title').notNullable();
+ table.text('description').nullable();
+ table.string('severity', 8).notNullable();
+ table.text('link').notNullable();
+ 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');
+ });
+};
+
+/**
+ * @param {import('knex').Knex} knex
+ */
+exports.down = async function down(knex) {
+ await knex.schema.dropTable('notification');
+};
diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json
new file mode 100644
index 0000000000..455acb3bd4
--- /dev/null
+++ b/plugins/notifications-backend/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@backstage/plugin-notifications-backend",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "backend-plugin"
+ },
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/backend-common": "workspace:^",
+ "@backstage/backend-plugin-api": "workspace:^",
+ "@backstage/catalog-client": "workspace:^",
+ "@backstage/catalog-model": "workspace:^",
+ "@backstage/config": "workspace:^",
+ "@backstage/errors": "workspace:^",
+ "@backstage/plugin-auth-node": "workspace:^",
+ "@backstage/plugin-events-node": "workspace:^",
+ "@backstage/plugin-notifications-common": "workspace:^",
+ "@backstage/plugin-notifications-node": "workspace:^",
+ "@backstage/plugin-signals-node": "workspace:^",
+ "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"
+ },
+ "devDependencies": {
+ "@backstage/backend-test-utils": "workspace:^",
+ "@backstage/cli": "workspace:^",
+ "@types/express": "^4.17.6",
+ "@types/supertest": "^2.0.8",
+ "msw": "^1.0.0",
+ "supertest": "^6.2.4"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts
new file mode 100644
index 0000000000..35964ff582
--- /dev/null
+++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts
@@ -0,0 +1,338 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
+import { DatabaseNotificationsStore } from './DatabaseNotificationsStore';
+import { Knex } from 'knex';
+import { v4 as uuid } from 'uuid';
+import { Notification } from '@backstage/plugin-notifications-common';
+
+jest.setTimeout(60_000);
+
+const databases = TestDatabases.create();
+
+async function createStore(databaseId: TestDatabaseId) {
+ const knex = await databases.init(databaseId);
+ const mgr = {
+ getClient: async () => knex,
+ migrations: {
+ skip: false,
+ },
+ };
+ return {
+ knex,
+ storage: await DatabaseNotificationsStore.create({ database: mgr }),
+ };
+}
+
+const user = 'user:default/john.doe';
+const testNotification: Partial = {
+ user,
+ created: new Date(),
+ origin: 'plugin-test',
+ payload: {
+ title: 'Notification 1',
+ link: '/catalog',
+ severity: 'normal',
+ },
+};
+
+const otherUserNotification: Partial = {
+ ...testNotification,
+ user: 'user:default/jane.doe',
+};
+
+describe.each(databases.eachSupportedId())(
+ 'DatabaseNotificationsStore (%s)',
+ databaseId => {
+ let storage: DatabaseNotificationsStore;
+ let knex: Knex;
+ const insertNotification = async (
+ notification: Partial & {
+ id: string;
+ done?: Date;
+ saved?: Date;
+ read?: Date;
+ },
+ ) =>
+ (
+ await knex('notification')
+ .insert({
+ id: notification.id,
+ user: notification.user,
+ origin: notification.origin,
+ created: notification.created,
+ link: notification.payload?.link,
+ title: notification.payload?.title,
+ severity: notification.payload?.severity,
+ scope: notification.payload?.scope,
+ done: notification.done,
+ saved: notification.saved,
+ read: notification.read,
+ })
+ .returning('id')
+ )[0].id ?? -1;
+
+ beforeAll(async () => {
+ ({ storage, knex } = await createStore(databaseId));
+ });
+
+ afterEach(async () => {
+ jest.resetAllMocks();
+ await knex('notification').del();
+ });
+
+ describe('getNotifications', () => {
+ it('should return all notifications for user', async () => {
+ const id1 = uuid();
+ const id2 = uuid();
+ await insertNotification({ id: id1, ...testNotification });
+ await insertNotification({ id: id2, ...testNotification });
+ await insertNotification({ id: uuid(), ...otherUserNotification });
+
+ const notifications = await storage.getNotifications({ user });
+ expect(notifications.length).toBe(2);
+ });
+
+ it('should return undone notifications for user', async () => {
+ const id1 = uuid();
+ const id2 = uuid();
+ await insertNotification({
+ id: id1,
+ ...testNotification,
+ done: new Date(),
+ });
+ await insertNotification({ id: id2, ...testNotification });
+ await insertNotification({ id: uuid(), ...otherUserNotification });
+
+ const notifications = await storage.getNotifications({
+ user,
+ type: 'undone',
+ });
+ expect(notifications.length).toBe(1);
+ expect(notifications.at(0)?.id).toEqual(id2);
+ });
+
+ it('should return done notifications for user', async () => {
+ const id1 = uuid();
+ const id2 = uuid();
+ await insertNotification({
+ id: id1,
+ ...testNotification,
+ done: new Date(),
+ });
+ await insertNotification({ id: id2, ...testNotification });
+ await insertNotification({ id: uuid(), ...otherUserNotification });
+
+ const notifications = await storage.getNotifications({
+ user,
+ type: 'done',
+ });
+ expect(notifications.length).toBe(1);
+ expect(notifications.at(0)?.id).toEqual(id1);
+ });
+
+ it('should allow searching for notifications', async () => {
+ const id1 = uuid();
+ const id2 = uuid();
+ await insertNotification({
+ id: id1,
+ ...testNotification,
+ payload: {
+ link: '/catalog',
+ severity: 'normal',
+ title: 'Please find me',
+ },
+ });
+ await insertNotification({ id: id2, ...testNotification });
+ await insertNotification({ id: uuid(), ...otherUserNotification });
+
+ const notifications = await storage.getNotifications({
+ user,
+ search: 'find me',
+ });
+ expect(notifications.length).toBe(1);
+ expect(notifications.at(0)?.id).toEqual(id1);
+ });
+ });
+
+ describe('getStatus', () => {
+ it('should return status for user', async () => {
+ const id1 = uuid();
+ const id2 = uuid();
+ await insertNotification({
+ id: id1,
+ ...testNotification,
+ read: new Date(),
+ });
+ await insertNotification({ id: id2, ...testNotification });
+ await insertNotification({ id: uuid(), ...otherUserNotification });
+
+ const status = await storage.getStatus({ user });
+ expect(status.read).toEqual(1);
+ expect(status.unread).toEqual(1);
+ });
+ });
+
+ describe('getExistingScopeNotification', () => {
+ it('should return existing scope notification', async () => {
+ const id1 = uuid();
+ const notification: any = {
+ ...testNotification,
+ id: id1,
+ payload: {
+ title: 'Notification',
+ link: '/scaffolder/task/1234',
+ severity: 'normal',
+ scope: 'scaffolder-1234',
+ },
+ };
+ await insertNotification(notification);
+
+ const existing = await storage.getExistingScopeNotification({
+ user,
+ origin: 'plugin-test',
+ scope: 'scaffolder-1234',
+ });
+ expect(existing).not.toBeNull();
+ expect(existing?.id).toEqual(id1);
+ });
+ });
+
+ describe('restoreExistingNotification', () => {
+ it('should return restore existing scope notification', async () => {
+ const id1 = uuid();
+ const notification: any = {
+ ...testNotification,
+ id: id1,
+ read: new Date(),
+ done: new Date(),
+ payload: {
+ title: 'Notification',
+ link: '/scaffolder/task/1234',
+ severity: 'normal',
+ scope: 'scaffolder-1234',
+ },
+ };
+ await insertNotification(notification);
+
+ const existing = await storage.restoreExistingNotification({
+ id: id1,
+ notification: {
+ user: notification.user,
+ payload: {
+ title: 'New notification',
+ link: '/scaffolder/task/1234',
+ severity: 'normal',
+ },
+ } as any,
+ });
+ expect(existing).not.toBeNull();
+ expect(existing?.id).toEqual(id1);
+ expect(existing?.payload.title).toEqual('New notification');
+ expect(existing?.done).toBeNull();
+ expect(existing?.read).toBeNull();
+ });
+ });
+
+ describe('getNotification', () => {
+ it('should return notification by id', async () => {
+ const id1 = uuid();
+ await insertNotification({ id: id1, ...testNotification });
+
+ const notification = await storage.getNotification({ id: id1 });
+ expect(notification?.id).toEqual(id1);
+ });
+ });
+
+ describe('markRead', () => {
+ it('should mark notification read', async () => {
+ const id1 = uuid();
+ await insertNotification({ id: id1, ...testNotification });
+
+ await storage.markRead({ ids: [id1], user });
+ const notification = await storage.getNotification({ id: id1 });
+ expect(notification?.read).not.toBeNull();
+ });
+ });
+
+ describe('markUnread', () => {
+ it('should mark notification unread', async () => {
+ const id1 = uuid();
+ await insertNotification({
+ id: id1,
+ ...testNotification,
+ read: new Date(),
+ });
+
+ await storage.markUnread({ ids: [id1], user });
+ const notification = await storage.getNotification({ id: id1 });
+ expect(notification?.read).toBeNull();
+ });
+ });
+
+ describe('markDone', () => {
+ it('should mark notification done', async () => {
+ const id1 = uuid();
+ await insertNotification({ id: id1, ...testNotification });
+
+ await storage.markDone({ ids: [id1], user });
+ const notification = await storage.getNotification({ id: id1 });
+ expect(notification?.done).not.toBeNull();
+ });
+ });
+
+ describe('markUndone', () => {
+ it('should mark notification undone', async () => {
+ const id1 = uuid();
+ await insertNotification({
+ id: id1,
+ ...testNotification,
+ done: new Date(),
+ });
+
+ await storage.markUndone({ ids: [id1], user });
+ const notification = await storage.getNotification({ id: id1 });
+ expect(notification?.done).toBeNull();
+ });
+ });
+
+ describe('markSaved', () => {
+ it('should mark notification saved', async () => {
+ const id1 = uuid();
+ await insertNotification({ id: id1, ...testNotification });
+
+ await storage.markSaved({ ids: [id1], user });
+ const notification = await storage.getNotification({ id: id1 });
+ expect(notification?.saved).not.toBeNull();
+ });
+ });
+
+ describe('markUnsaved', () => {
+ it('should mark notification not saved', async () => {
+ const id1 = uuid();
+ await insertNotification({
+ id: id1,
+ ...testNotification,
+ saved: new Date(),
+ });
+
+ await storage.markUnsaved({ ids: [id1], user });
+ const notification = await storage.getNotification({ id: id1 });
+ expect(notification?.saved).toBeNull();
+ });
+ });
+ },
+);
diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts
new file mode 100644
index 0000000000..7e5060106b
--- /dev/null
+++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts
@@ -0,0 +1,240 @@
+/*
+ * 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 {
+ PluginDatabaseManager,
+ resolvePackagePath,
+} from '@backstage/backend-common';
+import {
+ NotificationGetOptions,
+ NotificationModifyOptions,
+ NotificationsStore,
+} from './NotificationsStore';
+import { Notification } from '@backstage/plugin-notifications-common';
+import { Knex } from 'knex';
+
+const migrationsDir = resolvePackagePath(
+ '@backstage/plugin-notifications-backend',
+ 'migrations',
+);
+
+/** @internal */
+export class DatabaseNotificationsStore implements NotificationsStore {
+ private constructor(private readonly db: Knex) {}
+
+ static async create({
+ database,
+ skipMigrations,
+ }: {
+ database: PluginDatabaseManager;
+ skipMigrations?: boolean;
+ }): Promise {
+ const client = await database.getClient();
+
+ if (!database.migrations?.skip && !skipMigrations) {
+ await client.migrate.latest({
+ directory: migrationsDir,
+ });
+ }
+
+ return new DatabaseNotificationsStore(client);
+ }
+
+ private mapToInteger = (val: string | number | undefined): number => {
+ 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, 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.whereNotNull('saved');
+ }
+
+ if (options.limit) {
+ query.limit(options.limit);
+ }
+
+ if (options.offset) {
+ query.offset(options.offset);
+ }
+
+ if (options.search) {
+ query.whereRaw(
+ `(LOWER(notification.title) LIKE LOWER(?) OR LOWER(notification.description) LIKE LOWER(?))`,
+ [`%${options.search}%`, `%${options.search}%`],
+ );
+ }
+
+ if (options.ids) {
+ query.whereIn('notification.id', options.ids);
+ }
+
+ return query;
+ };
+
+ async getNotifications(options: NotificationGetOptions) {
+ const notificationQuery = this.getNotificationsBaseQuery(options);
+ const notifications = await notificationQuery.select();
+ return this.mapToNotifications(notifications);
+ }
+
+ async saveNotification(notification: Notification) {
+ await this.db.insert(notification).into('notification');
+ }
+
+ async getStatus(options: NotificationGetOptions) {
+ const notificationQuery = this.getNotificationsBaseQuery({
+ ...options,
+ sort: null,
+ });
+ const readSubQuery = notificationQuery
+ .clone()
+ .count('id')
+ .whereNotNull('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((query as any)?.UNREAD),
+ read: this.mapToInteger((query as any)?.READ),
+ };
+ }
+
+ async getExistingScopeNotification(options: {
+ user: string;
+ scope: string;
+ origin: string;
+ }) {
+ const query = this.db('notification')
+ .where('user', options.user)
+ .where('scope', options.scope)
+ .where('origin', options.origin)
+ .select()
+ .limit(1);
+
+ const rows = await query;
+ if (!rows || rows.length === 0) {
+ return null;
+ }
+ return rows[0] as Notification;
+ }
+
+ async restoreExistingNotification(options: {
+ id: string;
+ notification: Notification;
+ }) {
+ const query = this.db('notification')
+ .where('id', options.id)
+ .where('user', options.notification.user);
+
+ await query.update({
+ title: options.notification.payload.title,
+ description: options.notification.payload.description,
+ link: options.notification.payload.link,
+ topic: options.notification.payload.topic,
+ updated: options.notification.created,
+ severity: options.notification.payload.severity,
+ read: null,
+ done: null,
+ });
+
+ return await this.getNotification(options);
+ }
+
+ async getNotification(options: { id: string }): Promise {
+ const rows = await this.db('notification')
+ .where('id', options.id)
+ .select()
+ .limit(1);
+ if (!rows || rows.length === 0) {
+ return null;
+ }
+ return this.mapToNotifications(rows)[0];
+ }
+
+ async markRead(options: NotificationModifyOptions): Promise {
+ const notificationQuery = this.getNotificationsBaseQuery(options);
+ await notificationQuery.update({ read: new Date() });
+ }
+
+ async markUnread(options: NotificationModifyOptions): Promise {
+ const notificationQuery = this.getNotificationsBaseQuery(options);
+ await notificationQuery.update({ read: null });
+ }
+
+ async markDone(options: NotificationModifyOptions): Promise {
+ const notificationQuery = this.getNotificationsBaseQuery(options);
+ await notificationQuery.update({ done: new Date(), read: new Date() });
+ }
+
+ async markUndone(options: NotificationModifyOptions): Promise {
+ const notificationQuery = this.getNotificationsBaseQuery(options);
+ await notificationQuery.update({ done: null, read: null });
+ }
+
+ async markSaved(options: NotificationModifyOptions): Promise {
+ const notificationQuery = this.getNotificationsBaseQuery(options);
+ await notificationQuery.update({ saved: new Date() });
+ }
+
+ async markUnsaved(options: NotificationModifyOptions): Promise {
+ const notificationQuery = this.getNotificationsBaseQuery(options);
+ await notificationQuery.update({ saved: null });
+ }
+}
diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts
new file mode 100644
index 0000000000..4e3a2fa547
--- /dev/null
+++ b/plugins/notifications-backend/src/database/NotificationsStore.ts
@@ -0,0 +1,72 @@
+/*
+ * 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,
+ NotificationStatus,
+ NotificationType,
+} from '@backstage/plugin-notifications-common';
+
+/** @internal */
+export type NotificationGetOptions = {
+ user: string;
+ ids?: string[];
+ type?: NotificationType;
+ offset?: number;
+ limit?: number;
+ search?: string;
+ sort?: 'created' | 'read' | 'updated' | null;
+ sortOrder?: 'asc' | 'desc';
+};
+
+/** @internal */
+export type NotificationModifyOptions = {
+ ids: string[];
+} & NotificationGetOptions;
+
+/** @internal */
+export interface NotificationsStore {
+ getNotifications(options: NotificationGetOptions): Promise;
+
+ saveNotification(notification: Notification): Promise;
+
+ getExistingScopeNotification(options: {
+ user: string;
+ scope: string;
+ origin: string;
+ }): Promise;
+
+ restoreExistingNotification(options: {
+ id: string;
+ notification: Notification;
+ }): Promise;
+
+ getNotification(options: { id: string }): Promise;
+
+ getStatus(options: NotificationGetOptions): Promise;
+
+ markRead(options: NotificationModifyOptions): Promise;
+
+ markUnread(options: NotificationModifyOptions): Promise;
+
+ markDone(options: NotificationModifyOptions): Promise;
+
+ markUndone(options: NotificationModifyOptions): Promise;
+
+ markSaved(options: NotificationModifyOptions): Promise;
+
+ markUnsaved(options: NotificationModifyOptions): Promise;
+}
diff --git a/plugins/notifications-backend/src/database/index.ts b/plugins/notifications-backend/src/database/index.ts
new file mode 100644
index 0000000000..6d2f549f38
--- /dev/null
+++ b/plugins/notifications-backend/src/database/index.ts
@@ -0,0 +1,17 @@
+/*
+ * 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.
+ */
+export * from './DatabaseNotificationsStore';
+export * from './NotificationsStore';
diff --git a/plugins/notifications-backend/src/index.ts b/plugins/notifications-backend/src/index.ts
new file mode 100644
index 0000000000..b47321aa39
--- /dev/null
+++ b/plugins/notifications-backend/src/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+export { notificationsPlugin as default } from './plugin';
diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts
new file mode 100644
index 0000000000..1d8806cf49
--- /dev/null
+++ b/plugins/notifications-backend/src/plugin.ts
@@ -0,0 +1,92 @@
+/*
+ * 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 {
+ coreServices,
+ createBackendPlugin,
+} from '@backstage/backend-plugin-api';
+import { createRouter } from './service/router';
+import { signalService } from '@backstage/plugin-signals-node';
+import {
+ NotificationProcessor,
+ notificationsProcessingExtensionPoint,
+ NotificationsProcessingExtensionPoint,
+} from '@backstage/plugin-notifications-node';
+
+class NotificationsProcessingExtensionPointImpl
+ implements NotificationsProcessingExtensionPoint
+{
+ #processors = new Array();
+
+ addProcessor(
+ ...processors: Array>
+ ): void {
+ this.#processors.push(...processors.flat());
+ }
+
+ get processors() {
+ return this.#processors;
+ }
+}
+
+/**
+ * Notifications backend plugin
+ *
+ * @public
+ */
+export const notificationsPlugin = createBackendPlugin({
+ pluginId: 'notifications',
+ register(env) {
+ const processingExtensions =
+ new NotificationsProcessingExtensionPointImpl();
+ env.registerExtensionPoint(
+ notificationsProcessingExtensionPoint,
+ processingExtensions,
+ );
+
+ env.registerInit({
+ deps: {
+ httpRouter: coreServices.httpRouter,
+ logger: coreServices.logger,
+ identity: coreServices.identity,
+ database: coreServices.database,
+ tokenManager: coreServices.tokenManager,
+ discovery: coreServices.discovery,
+ signals: signalService,
+ },
+ async init({
+ httpRouter,
+ logger,
+ identity,
+ database,
+ tokenManager,
+ discovery,
+ signals,
+ }) {
+ httpRouter.use(
+ await createRouter({
+ logger,
+ identity,
+ database,
+ tokenManager,
+ discovery,
+ signalService: signals,
+ processors: processingExtensions.processors,
+ }),
+ );
+ },
+ });
+ },
+});
diff --git a/plugins/notifications-backend/src/run.ts b/plugins/notifications-backend/src/run.ts
new file mode 100644
index 0000000000..d299ed23e9
--- /dev/null
+++ b/plugins/notifications-backend/src/run.ts
@@ -0,0 +1,32 @@
+/*
+ * 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 { getRootLogger } from '@backstage/backend-common';
+import yn from 'yn';
+import { startStandaloneServer } from './service/standaloneServer';
+
+const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
+const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
+const logger = getRootLogger();
+
+startStandaloneServer({ port, enableCors, logger }).catch(err => {
+ logger.error(err);
+ process.exit(1);
+});
+
+process.on('SIGINT', () => {
+ logger.info('CTRL+C pressed; exiting.');
+ process.exit(0);
+});
diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts
new file mode 100644
index 0000000000..3152b1b07f
--- /dev/null
+++ b/plugins/notifications-backend/src/service/router.test.ts
@@ -0,0 +1,97 @@
+/*
+ * 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 {
+ 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 { ConfigReader } from '@backstage/config';
+import { SignalService } from '@backstage/plugin-signals-node';
+
+function createDatabase(): PluginDatabaseManager {
+ return DatabaseManager.fromConfig(
+ new ConfigReader({
+ backend: {
+ database: {
+ client: 'better-sqlite3',
+ connection: ':memory:',
+ },
+ },
+ }),
+ ).forPlugin('notifications');
+}
+
+describe('createRouter', () => {
+ let app: express.Express;
+
+ const identityMock: IdentityApi = {
+ async getIdentity() {
+ return {
+ identity: {
+ type: 'user',
+ ownershipEntityRefs: [],
+ userEntityRef: 'user:default/guest',
+ },
+ token: 'no-token',
+ };
+ },
+ };
+ const mockedTokenManager: jest.Mocked = {
+ getToken: jest.fn(),
+ authenticate: jest.fn(),
+ };
+
+ const discovery: jest.Mocked = {
+ getBaseUrl: jest.fn(),
+ getExternalBaseUrl: jest.fn(),
+ };
+
+ const signalService: jest.Mocked = {
+ publish: jest.fn(),
+ };
+
+ beforeAll(async () => {
+ const router = await createRouter({
+ logger: getVoidLogger(),
+ identity: identityMock,
+ database: createDatabase(),
+ tokenManager: mockedTokenManager,
+ discovery,
+ signalService,
+ });
+ app = express().use(router);
+ });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('GET /health', () => {
+ it('returns ok', async () => {
+ const response = await request(app).get('/health');
+
+ expect(response.status).toEqual(200);
+ expect(response.body).toEqual({ status: 'ok' });
+ });
+ });
+});
diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts
new file mode 100644
index 0000000000..b293ea4a1f
--- /dev/null
+++ b/plugins/notifications-backend/src/service/router.ts
@@ -0,0 +1,365 @@
+/*
+ * 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 {
+ errorHandler,
+ PluginDatabaseManager,
+ TokenManager,
+} from '@backstage/backend-common';
+import express, { Request } from 'express';
+import Router from 'express-promise-router';
+import {
+ getBearerTokenFromAuthorizationHeader,
+ IdentityApi,
+} from '@backstage/plugin-auth-node';
+import {
+ DatabaseNotificationsStore,
+ NotificationGetOptions,
+} 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 '@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 {
+ Notification,
+ NotificationType,
+} from '@backstage/plugin-notifications-common';
+
+/** @internal */
+export interface RouterOptions {
+ logger: LoggerService;
+ identity: IdentityApi;
+ database: PluginDatabaseManager;
+ tokenManager: TokenManager;
+ discovery: DiscoveryService;
+ signalService?: SignalService;
+ catalog?: CatalogApi;
+ processors?: NotificationProcessor[];
+}
+
+/** @internal */
+export async function createRouter(
+ options: RouterOptions,
+): Promise {
+ const {
+ logger,
+ database,
+ identity,
+ discovery,
+ catalog,
+ tokenManager,
+ processors,
+ signalService,
+ } = options;
+
+ const catalogClient =
+ catalog ?? new CatalogClient({ discoveryApi: discovery });
+ const store = await DatabaseNotificationsStore.create({ database });
+
+ const getUser = async (req: Request) => {
+ const user = await identity.getIdentity({ request: req });
+ if (!user) {
+ throw new AuthenticationError();
+ }
+ return user.identity.userEntityRef;
+ };
+
+ const authenticateService = async (req: Request) => {
+ const token = getBearerTokenFromAuthorizationHeader(
+ req.header('authorization'),
+ );
+ if (!token) {
+ throw new AuthenticationError();
+ }
+ await tokenManager.authenticate(token);
+ };
+
+ const getUsersForEntityRef = async (
+ entityRef: string | string[] | null,
+ ): Promise => {
+ const { token } = await tokenManager.getToken();
+
+ // TODO: Support for broadcast
+ if (entityRef === null) {
+ return [];
+ }
+
+ const refs = Array.isArray(entityRef) ? entityRef : [entityRef];
+ const entities = await catalogClient.getEntitiesByRefs(
+ {
+ entityRefs: refs,
+ fields: ['kind', 'metadata.name', 'metadata.namespace'],
+ },
+ { token },
+ );
+ const mapEntity = async (entity: Entity | undefined): Promise => {
+ if (!entity) {
+ return [];
+ }
+
+ if (isUserEntity(entity)) {
+ return [stringifyEntityRef(entity)];
+ } else if (isGroupEntity(entity) && entity.relations) {
+ const users = entity.relations
+ .filter(
+ relation =>
+ relation.type === RELATION_HAS_MEMBER && relation.targetRef,
+ )
+ .map(r => r.targetRef);
+ const childGroups = await catalogClient.getEntitiesByRefs(
+ {
+ entityRefs: entity.spec.children,
+ fields: ['kind', 'metadata.name', 'metadata.namespace'],
+ },
+ { token },
+ );
+ const childGroupUsers = await Promise.all(
+ childGroups.items.map(mapEntity),
+ );
+ return [...users, ...childGroupUsers.flat(2)];
+ } 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 decorateNotification = async (notification: Notification) => {
+ let ret: Notification = notification;
+ for (const processor of processors ?? []) {
+ ret = processor.decorate ? await processor.decorate(ret) : ret;
+ }
+ return ret;
+ };
+
+ const processorSendNotification = async (notification: Notification) => {
+ for (const processor of processors ?? []) {
+ if (processor.send) {
+ processor.send(notification);
+ }
+ }
+ };
+
+ // TODO: Move to use OpenAPI router instead
+ const router = Router();
+ router.use(express.json());
+
+ router.get('/health', (_, response) => {
+ logger.info('PONG!');
+ response.json({ status: 'ok' });
+ });
+
+ router.get('/', async (req, res) => {
+ const user = await getUser(req);
+ const opts: NotificationGetOptions = {
+ user: user,
+ };
+ if (req.query.type) {
+ opts.type = req.query.type.toString() as NotificationType;
+ }
+ if (req.query.offset) {
+ opts.offset = Number.parseInt(req.query.offset.toString(), 10);
+ }
+ if (req.query.limit) {
+ opts.limit = Number.parseInt(req.query.limit.toString(), 10);
+ }
+ if (req.query.search) {
+ opts.search = req.query.search.toString();
+ }
+
+ const notifications = await store.getNotifications(opts);
+ res.send(notifications);
+ });
+
+ router.get('/status', async (req, res) => {
+ const user = await getUser(req);
+ const status = await store.getStatus({ user, type: 'undone' });
+ res.send(status);
+ });
+
+ router.post('/update', async (req, res) => {
+ const user = await getUser(req);
+ const { ids, done, read, saved } = req.body;
+ if (!ids || !Array.isArray(ids)) {
+ throw new InputError();
+ }
+
+ if (done === true) {
+ await store.markDone({ user, ids });
+ if (signalService) {
+ await signalService.publish({
+ recipients: [user],
+ message: { action: 'done', notification_ids: ids },
+ channel: 'notifications',
+ });
+ }
+ } else if (done === false) {
+ await store.markUndone({ user, ids });
+ if (signalService) {
+ await signalService.publish({
+ recipients: [user],
+ message: { action: 'undone', notification_ids: ids },
+ channel: 'notifications',
+ });
+ }
+ }
+
+ if (read === true) {
+ await store.markRead({ user, ids });
+
+ if (signalService) {
+ await signalService.publish({
+ recipients: [user],
+ message: { action: 'mark_read', notification_ids: ids },
+ channel: 'notifications',
+ });
+ }
+ } else if (read === false) {
+ await store.markUnread({ user: user, ids });
+
+ if (signalService) {
+ await signalService.publish({
+ recipients: [user],
+ message: { action: 'mark_unread', notification_ids: ids },
+ channel: 'notifications',
+ });
+ }
+ }
+
+ if (saved === true) {
+ await store.markSaved({ user: user, ids });
+ } else if (saved === false) {
+ await store.markUnsaved({ user: user, ids });
+ }
+
+ 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('/', async (req, res) => {
+ const { recipients, origin, payload } = req.body;
+ const notifications = [];
+ let users = [];
+
+ try {
+ await authenticateService(req);
+ } catch (e) {
+ throw new AuthenticationError();
+ }
+
+ const { title, link, description, scope } = payload;
+
+ if (!recipients || !title || !origin || !link) {
+ logger.error(`Invalid notification request received`);
+ throw new InputError();
+ }
+
+ let entityRef = null;
+ // TODO: Support for broadcast notifications
+ if (recipients.entityRef && recipients.type === 'entity') {
+ entityRef = recipients.entityRef;
+ }
+
+ try {
+ users = await getUsersForEntityRef(entityRef);
+ } catch (e) {
+ throw new InputError();
+ }
+
+ const baseNotification: Omit = {
+ payload: {
+ ...payload,
+ severity: payload.severity ?? 'normal',
+ },
+ origin,
+ created: new Date(),
+ };
+
+ const uniqueUsers = [...new Set(users)];
+ for (const user of uniqueUsers) {
+ const userNotification = {
+ ...baseNotification,
+ id: uuid(),
+ user,
+ };
+ const notification = await decorateNotification(userNotification);
+
+ let existingNotification;
+ if (scope) {
+ existingNotification = await store.getExistingScopeNotification({
+ user,
+ scope,
+ origin,
+ });
+ }
+
+ let ret = notification;
+ if (existingNotification) {
+ const restored = await store.restoreExistingNotification({
+ id: existingNotification.id,
+ notification,
+ });
+ ret = restored ?? notification;
+ } else {
+ await store.saveNotification(notification);
+ }
+
+ processorSendNotification(ret);
+ notifications.push(ret);
+ }
+
+ if (signalService) {
+ await signalService.publish({
+ recipients: entityRef === null ? null : uniqueUsers,
+ message: {
+ action: 'new_notification',
+ notification: { title, description, link },
+ },
+ channel: 'notifications',
+ });
+ }
+
+ res.json(notifications);
+ });
+
+ router.use(errorHandler());
+ return router;
+}
diff --git a/plugins/notifications-backend/src/service/standaloneServer.ts b/plugins/notifications-backend/src/service/standaloneServer.ts
new file mode 100644
index 0000000000..e2841543b1
--- /dev/null
+++ b/plugins/notifications-backend/src/service/standaloneServer.ts
@@ -0,0 +1,136 @@
+/*
+ * 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 {
+ createServiceBuilder,
+ HostDiscovery,
+ loadBackendConfig,
+ PluginDatabaseManager,
+ ServerTokenManager,
+} from '@backstage/backend-common';
+import { Server } from 'http';
+import { Logger } from 'winston';
+import { createRouter } from './router';
+import Knex from 'knex';
+import { IdentityApi } from '@backstage/plugin-auth-node';
+import { Request } from 'express';
+import {
+ CatalogApi,
+ CatalogRequestOptions,
+ GetEntitiesByRefsRequest,
+} from '@backstage/catalog-client';
+import { DefaultSignalService } from '@backstage/plugin-signals-node';
+import {
+ EventBroker,
+ EventParams,
+ EventSubscriber,
+} from '@backstage/plugin-events-node';
+
+export interface ServerOptions {
+ port: number;
+ enableCors: boolean;
+ logger: Logger;
+}
+
+export async function startStandaloneServer(
+ options: ServerOptions,
+): Promise {
+ const logger = options.logger.child({ service: 'notifications-backend' });
+ logger.debug('Starting application server...');
+
+ 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 catalogApi = {
+ async getEntitiesByRefs(
+ _request: GetEntitiesByRefsRequest,
+ __options?: CatalogRequestOptions,
+ ) {
+ return {
+ items: [
+ {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'User',
+ metadata: { name: 'user', namespace: 'default' },
+ spec: {},
+ },
+ ],
+ };
+ },
+ } as Partial as CatalogApi;
+
+ const identityMock: IdentityApi = {
+ async getIdentity({ request }: { request: Request }) {
+ const token = request.headers.authorization?.split(' ')[1];
+ return {
+ identity: {
+ type: 'user',
+ ownershipEntityRefs: [],
+ userEntityRef: 'user:default/guest',
+ },
+ token: token || 'no-token',
+ };
+ },
+ };
+
+ const mockSubscribers: EventSubscriber[] = [];
+ const eventBroker: EventBroker = {
+ async publish(params: EventParams): Promise {
+ mockSubscribers.forEach(sub => sub.onEvent(params));
+ },
+ subscribe(...subscribers: EventSubscriber[]) {
+ subscribers.flat().forEach(subscriber => {
+ mockSubscribers.push(subscriber);
+ });
+ },
+ };
+
+ const signalService = DefaultSignalService.create({ eventBroker });
+
+ const router = await createRouter({
+ logger,
+ identity: identityMock,
+ database: dbMock,
+ catalog: catalogApi,
+ discovery,
+ tokenManager,
+ signalService,
+ });
+
+ let service = createServiceBuilder(module)
+ .setPort(options.port)
+ .addRouter('/notifications', router);
+ if (options.enableCors) {
+ service = service.enableCors({ origin: 'http://localhost:3000' });
+ }
+
+ return await service.start().catch(err => {
+ logger.error(err);
+ process.exit(1);
+ });
+}
+
+module.hot?.accept();
diff --git a/plugins/notifications-backend/src/setupTests.ts b/plugins/notifications-backend/src/setupTests.ts
new file mode 100644
index 0000000000..4b9026cde5
--- /dev/null
+++ b/plugins/notifications-backend/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+export {};
diff --git a/plugins/notifications-common/.eslintrc.js b/plugins/notifications-common/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/notifications-common/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/notifications-common/README.md b/plugins/notifications-common/README.md
new file mode 100644
index 0000000000..673e30288f
--- /dev/null
+++ b/plugins/notifications-common/README.md
@@ -0,0 +1,5 @@
+# @backstage/plugin-notifications-common
+
+Welcome to the common package for the notifications plugin!
+
+_This plugin was created through the Backstage CLI_
diff --git a/plugins/notifications-common/api-report.md b/plugins/notifications-common/api-report.md
new file mode 100644
index 0000000000..d2dc1afb9d
--- /dev/null
+++ b/plugins/notifications-common/api-report.md
@@ -0,0 +1,42 @@
+## API Report File for "@backstage/plugin-notifications-common"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+// @public (undocumented)
+type Notification_2 = {
+ id: string;
+ user: string;
+ created: Date;
+ saved?: Date;
+ read?: Date;
+ done?: Date;
+ updated?: Date;
+ origin: string;
+ payload: NotificationPayload;
+};
+export { Notification_2 as Notification };
+
+// @public (undocumented)
+export type NotificationPayload = {
+ title: string;
+ description?: string;
+ link: string;
+ severity: NotificationSeverity;
+ topic?: string;
+ scope?: string;
+ icon?: string;
+};
+
+// @public (undocumented)
+export type NotificationSeverity = 'critical' | 'high' | 'normal' | 'low';
+
+// @public (undocumented)
+export type NotificationStatus = {
+ unread: number;
+ read: number;
+};
+
+// @public (undocumented)
+export type NotificationType = 'undone' | 'done' | 'saved';
+```
diff --git a/plugins/notifications-common/catalog-info.yaml b/plugins/notifications-common/catalog-info.yaml
new file mode 100644
index 0000000000..5a888d2372
--- /dev/null
+++ b/plugins/notifications-common/catalog-info.yaml
@@ -0,0 +1,10 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: backstage-plugin-notifications-common
+ title: '@backstage/plugin-notifications-common'
+ description: Common functionalities for the notifications plugin
+spec:
+ lifecycle: experimental
+ type: backstage-common-library
+ owner: maintainers
diff --git a/plugins/notifications-common/package.json b/plugins/notifications-common/package.json
new file mode 100644
index 0000000000..cda38bd1cb
--- /dev/null
+++ b/plugins/notifications-common/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@backstage/plugin-notifications-common",
+ "description": "Common functionalities for the notifications plugin",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "module": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "common-library"
+ },
+ "sideEffects": false,
+ "scripts": {
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^"
+ },
+ "files": [
+ "dist"
+ ],
+ "dependencies": {
+ "@material-ui/icons": "^4.9.1"
+ }
+}
diff --git a/plugins/notifications-common/src/index.ts b/plugins/notifications-common/src/index.ts
new file mode 100644
index 0000000000..8d9f26f07a
--- /dev/null
+++ b/plugins/notifications-common/src/index.ts
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+/**
+ * Common functionalities for the notifications plugin.
+ *
+ * @packageDocumentation
+ */
+
+export * from './types';
diff --git a/plugins/notifications-common/src/setupTests.ts b/plugins/notifications-common/src/setupTests.ts
new file mode 100644
index 0000000000..4b9026cde5
--- /dev/null
+++ b/plugins/notifications-common/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+export {};
diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts
new file mode 100644
index 0000000000..1e7469244f
--- /dev/null
+++ b/plugins/notifications-common/src/types.ts
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+
+/** @public */
+export type NotificationType = 'undone' | 'done' | 'saved';
+
+/** @public */
+export type NotificationSeverity = 'critical' | 'high' | 'normal' | 'low';
+
+/** @public */
+export type NotificationPayload = {
+ title: string;
+ description?: string;
+ link: string;
+ // TODO: Add support for additional links
+ // additionalLinks?: string[];
+ severity: NotificationSeverity;
+ topic?: string;
+ scope?: string;
+ icon?: string;
+};
+
+/** @public */
+export type Notification = {
+ id: string;
+ user: string;
+ created: Date;
+ saved?: Date;
+ read?: Date;
+ done?: Date;
+ updated?: Date;
+ origin: string;
+ payload: NotificationPayload;
+};
+
+/** @public */
+export type NotificationStatus = {
+ unread: number;
+ read: number;
+};
diff --git a/plugins/notifications-node/.eslintrc.js b/plugins/notifications-node/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/notifications-node/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/notifications-node/README.md b/plugins/notifications-node/README.md
new file mode 100644
index 0000000000..13475ab209
--- /dev/null
+++ b/plugins/notifications-node/README.md
@@ -0,0 +1,51 @@
+# @backstage/plugin-notifications-node
+
+Welcome to the Node.js library package for the notifications plugin!
+
+## Getting Started
+
+To be able to send notifications from other backend plugins, the `NotificationService` must be initialized for the
+environment. Add notification service to your `plugin.ts` as a dependency for init
+
+```ts
+import { notificationService } from '@backstage/plugin-notifications-node';
+
+export const myPlugin = createBackendPlugin({
+ pluginId: 'myPlugin',
+ register(env) {
+ env.registerInit({
+ deps: {
+ config: coreServices.rootConfig,
+ logger: coreServices.logger,
+ httpRouter: coreServices.httpRouter,
+ notificationService: notificationService,
+ },
+ async init({ config, logger, httpRouter, notificationService }) {
+ httpRouter.use(
+ await createRouter({
+ config,
+ logger,
+ permissions,
+ notificationService,
+ }),
+ );
+ },
+ });
+ },
+});
+```
+
+You also need to set up the `@backstage/plugin-notifications-backend` and `@backstage/plugin-notifications`
+to be able to show notifications in the UI.
+
+## Sending notifications
+
+To send notifications from backend plugin, use the `NotificationService::send` functionality. This function will
+save the notification and optionally signal the frontend to show the latest status for users.
+
+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.
+
+If the notification has `scope` set and user already has notification with that scope, the existing notification
+will be updated with the new notification values and moved to inbox as unread.
diff --git a/plugins/notifications-node/api-report.md b/plugins/notifications-node/api-report.md
new file mode 100644
index 0000000000..75a0fb68fd
--- /dev/null
+++ b/plugins/notifications-node/api-report.md
@@ -0,0 +1,69 @@
+## API Report File for "@backstage/plugin-notifications-node"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import { DiscoveryService } 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 { TokenManager } from '@backstage/backend-common';
+
+// @public (undocumented)
+export class DefaultNotificationService implements NotificationService {
+ // (undocumented)
+ static create({
+ tokenManager,
+ discovery,
+ pluginId,
+ }: NotificationServiceOptions): DefaultNotificationService;
+ // (undocumented)
+ send(notification: NotificationSendOptions): Promise;
+}
+
+// @public (undocumented)
+export interface NotificationProcessor {
+ decorate?(notification: Notification_2): Promise;
+ send?(notification: Notification_2): Promise;
+}
+
+// @public (undocumented)
+export type NotificationRecipients = {
+ type: 'entity';
+ entityRef: string | string[];
+};
+
+// @public (undocumented)
+export type NotificationSendOptions = {
+ recipients: NotificationRecipients;
+ payload: NotificationPayload;
+};
+
+// @public (undocumented)
+export interface NotificationService {
+ // (undocumented)
+ send(options: NotificationSendOptions): Promise;
+}
+
+// @public (undocumented)
+export const notificationService: ServiceRef;
+
+// @public (undocumented)
+export type NotificationServiceOptions = {
+ discovery: DiscoveryService;
+ tokenManager: TokenManager;
+ pluginId: string;
+};
+
+// @public (undocumented)
+export interface NotificationsProcessingExtensionPoint {
+ // (undocumented)
+ addProcessor(
+ ...processors: Array>
+ ): void;
+}
+
+// @public (undocumented)
+export const notificationsProcessingExtensionPoint: ExtensionPoint;
+```
diff --git a/plugins/notifications-node/catalog-info.yaml b/plugins/notifications-node/catalog-info.yaml
new file mode 100644
index 0000000000..cf25f049c9
--- /dev/null
+++ b/plugins/notifications-node/catalog-info.yaml
@@ -0,0 +1,10 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: backstage-plugin-notifications-node
+ title: '@backstage/plugin-notifications-node'
+ description: Node.js library for the notifications plugin
+spec:
+ lifecycle: experimental
+ type: backstage-node-library
+ owner: maintainers
diff --git a/plugins/notifications-node/package.json b/plugins/notifications-node/package.json
new file mode 100644
index 0000000000..b7393c1123
--- /dev/null
+++ b/plugins/notifications-node/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@backstage/plugin-notifications-node",
+ "description": "Node.js library for the notifications plugin",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "node-library"
+ },
+ "scripts": {
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "@backstage/test-utils": "workspace:^",
+ "msw": "^1.0.0"
+ },
+ "files": [
+ "dist"
+ ],
+ "dependencies": {
+ "@backstage/backend-common": "workspace:^",
+ "@backstage/backend-plugin-api": "workspace:^",
+ "@backstage/catalog-client": "workspace:^",
+ "@backstage/catalog-model": "workspace:^",
+ "@backstage/plugin-notifications-common": "workspace:^",
+ "@backstage/plugin-signals-node": "workspace:^",
+ "knex": "^3.0.0",
+ "uuid": "^8.0.0"
+ }
+}
diff --git a/plugins/notifications-node/src/extensions.ts b/plugins/notifications-node/src/extensions.ts
new file mode 100644
index 0000000000..f8ab26462d
--- /dev/null
+++ b/plugins/notifications-node/src/extensions.ts
@@ -0,0 +1,54 @@
+/*
+ * 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 { createExtensionPoint } from '@backstage/backend-plugin-api';
+import { Notification } from '@backstage/plugin-notifications-common';
+
+/**
+ * @public
+ */
+export interface 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;
+
+ /**
+ * Send notification using this processor.
+ *
+ * @param notification - The notification to send
+ */
+ send?(notification: Notification): Promise;
+}
+
+/**
+ * @public
+ */
+export interface NotificationsProcessingExtensionPoint {
+ addProcessor(
+ ...processors: Array>
+ ): void;
+}
+
+/**
+ * @public
+ */
+export const notificationsProcessingExtensionPoint =
+ createExtensionPoint({
+ id: 'notifications.processing',
+ });
diff --git a/plugins/notifications-node/src/index.ts b/plugins/notifications-node/src/index.ts
new file mode 100644
index 0000000000..05ca83957b
--- /dev/null
+++ b/plugins/notifications-node/src/index.ts
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+/**
+ * Node.js library for the notifications plugin.
+ *
+ * @packageDocumentation
+ */
+
+export * from './service';
+export * from './lib';
+export * from './extensions';
diff --git a/plugins/notifications-node/src/lib.ts b/plugins/notifications-node/src/lib.ts
new file mode 100644
index 0000000000..c79d871ff8
--- /dev/null
+++ b/plugins/notifications-node/src/lib.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 {
+ coreServices,
+ createServiceFactory,
+ createServiceRef,
+} from '@backstage/backend-plugin-api';
+import { DefaultNotificationService } from './service';
+import { NotificationService } from './service/NotificationService';
+
+/** @public */
+export const notificationService = createServiceRef({
+ id: 'notifications.service',
+ scope: 'plugin',
+ defaultFactory: async service =>
+ createServiceFactory({
+ service,
+ deps: {
+ discovery: coreServices.discovery,
+ tokenManager: coreServices.tokenManager,
+ pluginMetadata: coreServices.pluginMetadata,
+ },
+ factory({ discovery, tokenManager, pluginMetadata }) {
+ return DefaultNotificationService.create({
+ discovery,
+ tokenManager,
+ pluginId: pluginMetadata.getId(),
+ });
+ },
+ }),
+});
diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.test.ts b/plugins/notifications-node/src/service/DefaultNotificationService.test.ts
new file mode 100644
index 0000000000..a9b204ca7f
--- /dev/null
+++ b/plugins/notifications-node/src/service/DefaultNotificationService.test.ts
@@ -0,0 +1,90 @@
+/*
+ * 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 { setupRequestMockHandlers } from '@backstage/test-utils';
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import { NotificationPayload } from '@backstage/plugin-notifications-common';
+import {
+ DefaultNotificationService,
+ NotificationSendOptions,
+} from './DefaultNotificationService';
+
+const server = setupServer();
+
+const testNotification: NotificationPayload = {
+ title: 'Notification 1',
+ link: '/catalog',
+ severity: 'normal',
+};
+
+describe('DefaultNotificationService', () => {
+ setupRequestMockHandlers(server);
+ const mockBaseUrl = 'http://backstage/api/notifications';
+ const discoveryApi = {
+ getBaseUrl: async () => mockBaseUrl,
+ getExternalBaseUrl: async () => mockBaseUrl,
+ };
+ const tokenManager = {
+ getToken: async () => ({ token: '1234' }),
+ authenticate: jest.fn(),
+ };
+
+ let service: DefaultNotificationService;
+ beforeEach(() => {
+ service = DefaultNotificationService.create({
+ discovery: discoveryApi,
+ tokenManager,
+ pluginId: 'test',
+ });
+ });
+
+ describe('getNotifications', () => {
+ it('should create notification', async () => {
+ const body: NotificationSendOptions = {
+ recipients: { type: 'entity', entityRef: ['user:default/john.doe'] },
+ payload: testNotification,
+ };
+
+ server.use(
+ rest.post(`${mockBaseUrl}/`, async (req, res, ctx) => {
+ const json = await req.json();
+ expect(json).toEqual({ ...body, origin: 'plugin-test' });
+ expect(req.headers.get('Authorization')).toEqual('Bearer 1234');
+ return res(ctx.status(200));
+ }),
+ );
+ await expect(service.send(body)).resolves.not.toThrow();
+ });
+
+ it('should throw error if failing', async () => {
+ const body: NotificationSendOptions = {
+ recipients: { type: 'entity', entityRef: ['user:default/john.doe'] },
+ payload: testNotification,
+ };
+
+ server.use(
+ rest.post(`${mockBaseUrl}/`, async (req, res, ctx) => {
+ const json = await req.json();
+ expect(json).toEqual({ ...body, origin: 'plugin-test' });
+ expect(req.headers.get('Authorization')).toEqual('Bearer 1234');
+ return res(ctx.status(400));
+ }),
+ );
+ await expect(service.send(body)).rejects.toThrow();
+ });
+ });
+});
diff --git a/plugins/notifications-node/src/service/DefaultNotificationService.ts b/plugins/notifications-node/src/service/DefaultNotificationService.ts
new file mode 100644
index 0000000000..7e5cf58f53
--- /dev/null
+++ b/plugins/notifications-node/src/service/DefaultNotificationService.ts
@@ -0,0 +1,85 @@
+/*
+ * 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 { TokenManager } from '@backstage/backend-common';
+import { NotificationService } from './NotificationService';
+import { DiscoveryService } from '@backstage/backend-plugin-api';
+import { NotificationPayload } from '@backstage/plugin-notifications-common';
+
+/** @public */
+export type NotificationServiceOptions = {
+ discovery: DiscoveryService;
+ tokenManager: TokenManager;
+ pluginId: string;
+};
+
+/** @public */
+export type NotificationRecipients = {
+ type: 'entity';
+ entityRef: string | string[];
+};
+
+// TODO: Support for broadcast messages
+// | { type: 'broadcast' };
+
+/** @public */
+export type NotificationSendOptions = {
+ recipients: NotificationRecipients;
+ payload: NotificationPayload;
+};
+
+/** @public */
+export class DefaultNotificationService implements NotificationService {
+ private constructor(
+ private readonly discovery: DiscoveryService,
+ private readonly tokenManager: TokenManager,
+ private readonly pluginId: string,
+ ) {}
+
+ static create({
+ tokenManager,
+ discovery,
+ pluginId,
+ }: NotificationServiceOptions): DefaultNotificationService {
+ return new DefaultNotificationService(discovery, tokenManager, pluginId);
+ }
+
+ async send(notification: NotificationSendOptions): Promise {
+ try {
+ const baseUrl = await this.discovery.getBaseUrl('notifications');
+ const { token } = await this.tokenManager.getToken();
+ const response = await fetch(`${baseUrl}/`, {
+ method: 'POST',
+ body: JSON.stringify({
+ ...notification,
+ // TODO: Should retrieve this in the backend from service auth instead
+ origin: `plugin-${this.pluginId}`,
+ }),
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Request failed with status ${response.status}`);
+ }
+ } catch (error) {
+ // TODO: Should not throw in optimal case, see BEP
+ throw new Error(`Failed to send notifications: ${error}`);
+ }
+ }
+}
diff --git a/plugins/notifications-node/src/service/NotificationService.ts b/plugins/notifications-node/src/service/NotificationService.ts
new file mode 100644
index 0000000000..08b63599cc
--- /dev/null
+++ b/plugins/notifications-node/src/service/NotificationService.ts
@@ -0,0 +1,22 @@
+/*
+ * 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 { NotificationSendOptions } from './DefaultNotificationService';
+
+/** @public */
+export interface NotificationService {
+ send(options: NotificationSendOptions): Promise;
+}
diff --git a/plugins/notifications-node/src/service/index.ts b/plugins/notifications-node/src/service/index.ts
new file mode 100644
index 0000000000..37c3bff908
--- /dev/null
+++ b/plugins/notifications-node/src/service/index.ts
@@ -0,0 +1,17 @@
+/*
+ * 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.
+ */
+export * from './DefaultNotificationService';
+export type { NotificationService } from './NotificationService';
diff --git a/plugins/notifications-node/src/setupTests.ts b/plugins/notifications-node/src/setupTests.ts
new file mode 100644
index 0000000000..4b9026cde5
--- /dev/null
+++ b/plugins/notifications-node/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+export {};
diff --git a/plugins/notifications/.eslintrc.js b/plugins/notifications/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/notifications/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/notifications/README.md b/plugins/notifications/README.md
new file mode 100644
index 0000000000..63212d2c16
--- /dev/null
+++ b/plugins/notifications/README.md
@@ -0,0 +1,41 @@
+# notifications
+
+Welcome to the notifications plugin!
+
+_This plugin was created through the Backstage CLI_
+
+## Getting started
+
+First, install the `@backstage/plugin-notifications-backend` and `@backstage/plugin-notifications-node` packages.
+See the documentation for installation instructions.
+
+To add the notifications main menu, add the following to your `packages/app/src/components/Root/Root.tsx`:
+
+```tsx
+import { NotificationsSidebarItem } from '@backstage/plugin-notifications';
+
+
+
+
+ // ...
+
+
+
+ ;
+```
+
+Also add the route to notifications to `packages/app/src/App.tsx`:
+
+```tsx
+import { NotificationsPage } from '@backstage/plugin-notifications';
+
+
+ // ...
+ } />
+ ;
+```
+
+## Real-time notifications
+
+To be able to get real-time notifications to the UI without need for the user to refresh the page, you also need to
+add `@backstage/plugin-signals` package to your installation.
diff --git a/plugins/notifications/api-report.md b/plugins/notifications/api-report.md
new file mode 100644
index 0000000000..e2071dad14
--- /dev/null
+++ b/plugins/notifications/api-report.md
@@ -0,0 +1,135 @@
+## API Report File for "@backstage/plugin-notifications"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+///
+
+import { ApiRef } from '@backstage/core-plugin-api';
+import { BackstagePlugin } from '@backstage/core-plugin-api';
+import { DiscoveryApi } from '@backstage/core-plugin-api';
+import { FetchApi } from '@backstage/core-plugin-api';
+import { JSX as JSX_2 } from 'react';
+import { Notification as Notification_2 } from '@backstage/plugin-notifications-common';
+import { NotificationStatus } from '@backstage/plugin-notifications-common';
+import { NotificationType } from '@backstage/plugin-notifications-common';
+import { default as React_2 } from 'react';
+import { RouteRef } from '@backstage/core-plugin-api';
+
+// @public (undocumented)
+export type GetNotificationsOptions = {
+ type?: NotificationType;
+ offset?: number;
+ limit?: number;
+ search?: string;
+};
+
+// @public (undocumented)
+export interface NotificationsApi {
+ // (undocumented)
+ getNotifications(
+ options?: GetNotificationsOptions,
+ ): Promise;
+ // (undocumented)
+ getStatus(): Promise;
+ // (undocumented)
+ updateNotifications(
+ options: UpdateNotificationsOptions,
+ ): Promise;
+}
+
+// @public (undocumented)
+export const notificationsApiRef: ApiRef;
+
+// @public (undocumented)
+export class NotificationsClient implements NotificationsApi {
+ constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi });
+ // (undocumented)
+ getNotifications(
+ options?: GetNotificationsOptions,
+ ): Promise;
+ // (undocumented)
+ getStatus(): Promise;
+ // (undocumented)
+ updateNotifications(
+ options: UpdateNotificationsOptions,
+ ): Promise;
+}
+
+// @public (undocumented)
+export const NotificationsPage: () => JSX_2.Element;
+
+// @public (undocumented)
+export const notificationsPlugin: BackstagePlugin<
+ {
+ root: RouteRef;
+ },
+ {}
+>;
+
+// @public (undocumented)
+export const NotificationsSidebarItem: (props?: {
+ webNotificationsEnabled?: boolean;
+ titleCounterEnabled?: boolean;
+}) => React_2.JSX.Element;
+
+// @public (undocumented)
+export const NotificationsTable: (props: {
+ onUpdate: () => void;
+ type: NotificationType;
+ notifications?: Notification_2[];
+}) => React_2.JSX.Element;
+
+// @public (undocumented)
+export type UpdateNotificationsOptions = {
+ ids: string[];
+ done?: boolean;
+ read?: boolean;
+ saved?: boolean;
+};
+
+// @public (undocumented)
+export function useNotificationsApi(
+ f: (api: NotificationsApi) => Promise,
+ deps?: any[],
+):
+ | {
+ retry: () => void;
+ loading: boolean;
+ error?: undefined;
+ value?: undefined;
+ }
+ | {
+ retry: () => void;
+ loading: false;
+ error: Error;
+ value?: undefined;
+ }
+ | {
+ retry: () => void;
+ loading: true;
+ error?: Error | undefined;
+ value?: T | undefined;
+ }
+ | {
+ retry: () => void;
+ loading: false;
+ error?: undefined;
+ value: T;
+ };
+
+// @public (undocumented)
+export function useTitleCounter(): {
+ setNotificationCount: (newCount: number) => void;
+};
+
+// @public (undocumented)
+export function useWebNotifications(): {
+ sendWebNotification: (options: {
+ title: string;
+ description: string;
+ }) => Notification | null;
+};
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/notifications/catalog-info.yaml b/plugins/notifications/catalog-info.yaml
new file mode 100644
index 0000000000..33d39798e7
--- /dev/null
+++ b/plugins/notifications/catalog-info.yaml
@@ -0,0 +1,9 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: backstage-plugin-notifications
+ title: '@backstage/plugin-notifications'
+spec:
+ lifecycle: experimental
+ type: backstage-frontend-plugin
+ owner: maintainers
diff --git a/plugins/notifications/dev/index.tsx b/plugins/notifications/dev/index.tsx
new file mode 100644
index 0000000000..f4ea31f4ab
--- /dev/null
+++ b/plugins/notifications/dev/index.tsx
@@ -0,0 +1,27 @@
+/*
+ * 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 { createDevApp } from '@backstage/dev-utils';
+import { notificationsPlugin } from '../src/plugin';
+
+createDevApp()
+ .registerPlugin(notificationsPlugin)
+ .addPage({
+ element:
,
+ title: 'Root Page',
+ path: '/notifications',
+ })
+ .render();
diff --git a/plugins/notifications/package.json b/plugins/notifications/package.json
new file mode 100644
index 0000000000..b6177bda8a
--- /dev/null
+++ b/plugins/notifications/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "@backstage/plugin-notifications",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "frontend-plugin"
+ },
+ "sideEffects": false,
+ "scripts": {
+ "start": "backstage-cli package start",
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/core-components": "workspace:^",
+ "@backstage/core-plugin-api": "workspace:^",
+ "@backstage/errors": "workspace:^",
+ "@backstage/plugin-notifications-common": "workspace:^",
+ "@backstage/plugin-signals-react": "workspace:^",
+ "@backstage/theme": "workspace:^",
+ "@backstage/types": "workspace:^",
+ "@material-ui/core": "^4.9.13",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "^4.0.0-alpha.61",
+ "@types/react": "^16.13.1 || ^17.0.0",
+ "react-relative-time": "^0.0.9",
+ "react-use": "^17.2.4"
+ },
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0",
+ "react-router-dom": "6.0.0-beta.0 || ^6.3.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "@backstage/core-app-api": "workspace:^",
+ "@backstage/dev-utils": "workspace:^",
+ "@backstage/test-utils": "workspace:^",
+ "@testing-library/jest-dom": "^6.0.0",
+ "@testing-library/react": "^14.0.0",
+ "@testing-library/user-event": "^14.0.0",
+ "msw": "^1.0.0"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts
new file mode 100644
index 0000000000..775dc6b244
--- /dev/null
+++ b/plugins/notifications/src/api/NotificationsApi.ts
@@ -0,0 +1,53 @@
+/*
+ * 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 { createApiRef } from '@backstage/core-plugin-api';
+import {
+ Notification,
+ NotificationStatus,
+ NotificationType,
+} from '@backstage/plugin-notifications-common';
+
+/** @public */
+export const notificationsApiRef = createApiRef({
+ id: 'plugin.notifications.service',
+});
+
+/** @public */
+export type GetNotificationsOptions = {
+ type?: NotificationType;
+ offset?: number;
+ limit?: number;
+ search?: string;
+};
+
+/** @public */
+export type UpdateNotificationsOptions = {
+ ids: string[];
+ done?: boolean;
+ read?: boolean;
+ saved?: boolean;
+};
+
+/** @public */
+export interface NotificationsApi {
+ getNotifications(options?: GetNotificationsOptions): Promise;
+
+ getStatus(): Promise;
+
+ updateNotifications(
+ options: UpdateNotificationsOptions,
+ ): Promise;
+}
diff --git a/plugins/notifications/src/api/NotificationsClient.test.ts b/plugins/notifications/src/api/NotificationsClient.test.ts
new file mode 100644
index 0000000000..a7a2d1e5a7
--- /dev/null
+++ b/plugins/notifications/src/api/NotificationsClient.test.ts
@@ -0,0 +1,104 @@
+/*
+ * 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 { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import { NotificationsClient } from './NotificationsClient';
+import { Notification } from '@backstage/plugin-notifications-common';
+
+const server = setupServer();
+
+const testNotification: Partial = {
+ user: 'user:default/john.doe',
+ origin: 'plugin-test',
+ payload: {
+ title: 'Notification 1',
+ link: '/catalog',
+ severity: 'normal',
+ },
+};
+
+describe('NotificationsClient', () => {
+ setupRequestMockHandlers(server);
+ const mockBaseUrl = 'http://backstage/api/notifications';
+ const discoveryApi = { getBaseUrl: async () => mockBaseUrl };
+ const fetchApi = new MockFetchApi();
+
+ let client: NotificationsClient;
+ beforeEach(() => {
+ client = new NotificationsClient({ discoveryApi, fetchApi });
+ });
+
+ describe('getNotifications', () => {
+ const expectedResp = [testNotification];
+
+ it('should fetch notifications from correct endpoint', async () => {
+ server.use(
+ rest.get(`${mockBaseUrl}/`, (_, res, ctx) =>
+ res(ctx.json(expectedResp)),
+ ),
+ );
+ const response = await client.getNotifications();
+ expect(response).toEqual(expectedResp);
+ });
+
+ it('should fetch notifications with options', async () => {
+ server.use(
+ rest.get(`${mockBaseUrl}/`, (req, res, ctx) => {
+ expect(req.url.search).toBe(
+ '?type=undone&limit=10&offset=0&search=find+me',
+ );
+ return res(ctx.json(expectedResp));
+ }),
+ );
+ const response = await client.getNotifications({
+ type: 'undone',
+ limit: 10,
+ offset: 0,
+ search: 'find me',
+ });
+ expect(response).toEqual(expectedResp);
+ });
+
+ it('should fetch status from correct endpoint', async () => {
+ server.use(
+ rest.get(`${mockBaseUrl}/status`, (_, res, ctx) =>
+ res(ctx.json({ read: 1, unread: 1 })),
+ ),
+ );
+ const response = await client.getStatus();
+ expect(response).toEqual({ read: 1, unread: 1 });
+ });
+
+ it('should update notifications', async () => {
+ server.use(
+ rest.post(`${mockBaseUrl}/update`, async (req, res, ctx) => {
+ expect(await req.json()).toEqual({
+ ids: ['acdaa8ca-262b-43c1-b74b-de06e5f3b3c7'],
+ done: true,
+ });
+ return res(ctx.json(expectedResp));
+ }),
+ );
+ const response = await client.updateNotifications({
+ ids: ['acdaa8ca-262b-43c1-b74b-de06e5f3b3c7'],
+ done: true,
+ });
+ expect(response).toEqual(expectedResp);
+ });
+ });
+});
diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts
new file mode 100644
index 0000000000..fe7a90d3e8
--- /dev/null
+++ b/plugins/notifications/src/api/NotificationsClient.ts
@@ -0,0 +1,89 @@
+/*
+ * 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 {
+ GetNotificationsOptions,
+ NotificationsApi,
+ UpdateNotificationsOptions,
+} from './NotificationsApi';
+import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
+import { ResponseError } from '@backstage/errors';
+import {
+ Notification,
+ NotificationStatus,
+} from '@backstage/plugin-notifications-common';
+
+/** @public */
+export class NotificationsClient implements NotificationsApi {
+ private readonly discoveryApi: DiscoveryApi;
+ private readonly fetchApi: FetchApi;
+
+ public constructor(options: {
+ discoveryApi: DiscoveryApi;
+ fetchApi: FetchApi;
+ }) {
+ this.discoveryApi = options.discoveryApi;
+ this.fetchApi = options.fetchApi;
+ }
+
+ async getNotifications(
+ options?: GetNotificationsOptions,
+ ): Promise {
+ const queryString = new URLSearchParams();
+ if (options?.type) {
+ queryString.append('type', options.type);
+ }
+ if (options?.limit !== undefined) {
+ queryString.append('limit', options.limit.toString(10));
+ }
+ if (options?.offset !== undefined) {
+ queryString.append('offset', options.offset.toString(10));
+ }
+ if (options?.search) {
+ queryString.append('search', options.search);
+ }
+
+ const urlSegment = `?${queryString}`;
+
+ return await this.request(urlSegment);
+ }
+
+ async getStatus(): Promise {
+ return await this.request('status');
+ }
+
+ async updateNotifications(
+ options: UpdateNotificationsOptions,
+ ): Promise {
+ return await this.request('update', {
+ method: 'POST',
+ body: JSON.stringify(options),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ private async request(path: string, init?: any): Promise {
+ const baseUrl = `${await this.discoveryApi.getBaseUrl('notifications')}/`;
+ const url = new URL(path, baseUrl);
+
+ const response = await this.fetchApi.fetch(url.toString(), init);
+
+ if (!response.ok) {
+ throw await ResponseError.fromResponse(response);
+ }
+
+ return response.json() as Promise;
+ }
+}
diff --git a/plugins/notifications/src/api/index.ts b/plugins/notifications/src/api/index.ts
new file mode 100644
index 0000000000..bf39e313b8
--- /dev/null
+++ b/plugins/notifications/src/api/index.ts
@@ -0,0 +1,17 @@
+/*
+ * 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.
+ */
+export * from './NotificationsApi';
+export * from './NotificationsClient';
diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx
new file mode 100644
index 0000000000..85e0937ca3
--- /dev/null
+++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx
@@ -0,0 +1,112 @@
+/*
+ * 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, { useEffect, useState } from 'react';
+import {
+ Content,
+ ErrorPanel,
+ PageWithHeader,
+} from '@backstage/core-components';
+import { NotificationsTable } from '../NotificationsTable';
+import { useNotificationsApi } from '../../hooks';
+import { Button, Grid, makeStyles } from '@material-ui/core';
+import Bookmark from '@material-ui/icons/Bookmark';
+import Check from '@material-ui/icons/Check';
+import Inbox from '@material-ui/icons/Inbox';
+import { NotificationType } from '@backstage/plugin-notifications-common';
+import { useSignal } from '@backstage/plugin-signals-react';
+
+const useStyles = makeStyles(_theme => ({
+ filterButton: {
+ width: '100%',
+ justifyContent: 'start',
+ },
+}));
+
+export const NotificationsPage = () => {
+ const [type, setType] = useState('undone');
+ const [refresh, setRefresh] = React.useState(false);
+
+ const { error, value, retry } = useNotificationsApi(
+ api => api.getNotifications({ type }),
+ [type],
+ );
+
+ useEffect(() => {
+ if (refresh) {
+ retry();
+ setRefresh(false);
+ }
+ }, [refresh, setRefresh, retry]);
+
+ const { lastSignal } = useSignal('notifications');
+ useEffect(() => {
+ if (lastSignal && lastSignal.action) {
+ setRefresh(true);
+ }
+ }, [lastSignal]);
+
+ const onUpdate = () => {
+ setRefresh(true);
+ };
+
+ const styles = useStyles();
+ if (error) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ }
+ variant={type === 'undone' ? 'contained' : 'text'}
+ onClick={() => setType('undone')}
+ >
+ Inbox
+
+ }
+ variant={type === 'done' ? 'contained' : 'text'}
+ onClick={() => setType('done')}
+ >
+ Done
+
+ }
+ variant={type === 'saved' ? 'contained' : 'text'}
+ onClick={() => setType('saved')}
+ >
+ Saved
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/plugins/notifications/src/components/NotificationsPage/index.ts b/plugins/notifications/src/components/NotificationsPage/index.ts
new file mode 100644
index 0000000000..ace54c34d0
--- /dev/null
+++ b/plugins/notifications/src/components/NotificationsPage/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+export * from './NotificationsPage';
diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx
new file mode 100644
index 0000000000..90fba009a3
--- /dev/null
+++ b/plugins/notifications/src/components/NotificationsSideBarItem/NotificationsSideBarItem.tsx
@@ -0,0 +1,107 @@
+/*
+ * 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, { useEffect } from 'react';
+import { useNotificationsApi } from '../../hooks';
+import { SidebarItem } from '@backstage/core-components';
+import NotificationsIcon from '@material-ui/icons/Notifications';
+import { useRouteRef } from '@backstage/core-plugin-api';
+import { rootRouteRef } from '../../routes';
+import { useSignal } from '@backstage/plugin-signals-react';
+import { useWebNotifications } from '../../hooks/useWebNotifications';
+import { useTitleCounter } from '../../hooks/useTitleCounter';
+import { JsonObject } from '@backstage/types';
+
+/** @public */
+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 [unreadCount, setUnreadCount] = React.useState(0);
+ const notificationsRoute = useRouteRef(rootRouteRef);
+ // TODO: Add signal type support to `useSignal` to make it a bit easier to use
+ // TODO: Do we want to add long polling in case signals are not available
+ const { lastSignal } = useSignal('notifications');
+ const { sendWebNotification } = useWebNotifications();
+ const [refresh, setRefresh] = React.useState(false);
+ const { setNotificationCount } = useTitleCounter();
+
+ useEffect(() => {
+ if (refresh) {
+ retry();
+ setRefresh(false);
+ }
+ }, [refresh, retry]);
+
+ useEffect(() => {
+ const handleWebNotification = (signal: JsonObject) => {
+ if (!webNotificationsEnabled || !('notification' in signal)) {
+ return;
+ }
+
+ const notificationData = signal.notification as JsonObject;
+
+ if (
+ !notificationData ||
+ !('title' in notificationData) ||
+ !('description' in notificationData) ||
+ !('title' in notificationData)
+ ) {
+ return;
+ }
+ const notification = sendWebNotification({
+ title: notificationData.title as string,
+ description: notificationData.description as string,
+ });
+ if (notification) {
+ notification.onclick = event => {
+ event.preventDefault();
+ notification.close();
+ window.open(notificationData.link as string, '_blank');
+ };
+ }
+ };
+
+ if (lastSignal && lastSignal.action) {
+ handleWebNotification(lastSignal);
+ setRefresh(true);
+ }
+ }, [lastSignal, sendWebNotification, webNotificationsEnabled]);
+
+ useEffect(() => {
+ if (!loading && !error && value) {
+ setUnreadCount(value.unread);
+ if (titleCounterEnabled) {
+ setNotificationCount(value.unread);
+ }
+ }
+ }, [loading, error, value, titleCounterEnabled, setNotificationCount]);
+
+ // TODO: Figure out if the count can be added to hasNotifications
+ return (
+
+ );
+};
diff --git a/plugins/notifications/src/components/NotificationsSideBarItem/index.ts b/plugins/notifications/src/components/NotificationsSideBarItem/index.ts
new file mode 100644
index 0000000000..22d6b5d881
--- /dev/null
+++ b/plugins/notifications/src/components/NotificationsSideBarItem/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+export * from './NotificationsSideBarItem';
diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx
new file mode 100644
index 0000000000..8328da1c6d
--- /dev/null
+++ b/plugins/notifications/src/components/NotificationsTable/NotificationsTable.tsx
@@ -0,0 +1,308 @@
+/*
+ * 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, { useEffect, useState } from 'react';
+import {
+ Box,
+ Button,
+ IconButton,
+ makeStyles,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ Tooltip,
+ Typography,
+} from '@material-ui/core';
+import {
+ Notification,
+ NotificationType,
+} from '@backstage/plugin-notifications-common';
+import { useNavigate } from 'react-router-dom';
+import Checkbox from '@material-ui/core/Checkbox';
+import Check from '@material-ui/icons/Check';
+import Bookmark from '@material-ui/icons/Bookmark';
+import { notificationsApiRef } from '../../api';
+import { useApi } from '@backstage/core-plugin-api';
+import Inbox from '@material-ui/icons/Inbox';
+import CloseIcon from '@material-ui/icons/Close';
+// @ts-ignore
+import RelativeTime from 'react-relative-time';
+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',
+ '&.unread': {
+ border: '1px solid rgba(255, 255, 255, .3)',
+ },
+ '& .hideOnHover': {
+ display: 'initial',
+ },
+ '& .showOnHover': {
+ display: 'none',
+ },
+ '&:hover': {
+ '& .hideOnHover': {
+ display: 'none',
+ },
+ '& .showOnHover': {
+ display: 'initial',
+ },
+ },
+ },
+ actionButton: {
+ padding: '9px',
+ },
+ checkBox: {
+ padding: '0 10px 10px 0',
+ },
+}));
+
+/** @public */
+export const NotificationsTable = (props: {
+ onUpdate: () => void;
+ type: NotificationType;
+ notifications?: Notification[];
+}) => {
+ const { notifications, type } = props;
+ const navigate = useNavigate();
+ const styles = useStyles();
+ const [selected, setSelected] = useState([]);
+ const notificationsApi = useApi(notificationsApiRef);
+
+ const onCheckBoxClick = (id: string) => {
+ const index = selected.indexOf(id);
+ if (index !== -1) {
+ setSelected(selected.filter(s => s !== id));
+ } else {
+ setSelected([...selected, id]);
+ }
+ };
+
+ useEffect(() => {
+ setSelected([]);
+ }, [type]);
+
+ const isChecked = (id: string) => {
+ return selected.indexOf(id) !== -1;
+ };
+
+ const isAllSelected = () => {
+ return (
+ selected.length === notifications?.length && notifications.length > 0
+ );
+ };
+
+ return (
+
+
+
+
+ {type !== 'saved' && !notifications?.length && 'No notifications'}
+ {type !== 'saved' && !!notifications?.length && (
+ {
+ if (isAllSelected()) {
+ setSelected([]);
+ } else {
+ setSelected(
+ notifications ? notifications.map(n => n.id) : [],
+ );
+ }
+ }}
+ />
+ )}
+ {type === 'saved' &&
+ `${notifications?.length ?? 0} saved notifications`}
+ {selected.length === 0 &&
+ !!notifications?.length &&
+ type !== 'saved' &&
+ 'Select all'}
+ {selected.length > 0 && `${selected.length} selected`}
+ {type === 'done' && selected.length > 0 && (
+ }
+ onClick={() => {
+ notificationsApi
+ .updateNotifications({ ids: selected, done: false })
+ .then(() => props.onUpdate());
+ setSelected([]);
+ }}
+ >
+ Move to inbox
+
+ )}
+
+ {type === 'undone' && selected.length > 0 && (
+ }
+ onClick={() => {
+ notificationsApi
+ .updateNotifications({ ids: selected, done: true })
+ .then(() => props.onUpdate());
+ setSelected([]);
+ }}
+ >
+ Mark as done
+
+ )}
+
+
+
+
+ {props.notifications?.map(notification => {
+ return (
+
+
+ onCheckBoxClick(notification.id)}
+ />
+
+
+ notificationsApi
+ .updateNotifications({ ids: [notification.id], read: true })
+ .then(() => navigate(notification.payload.link))
+ }
+ style={{ paddingLeft: 0 }}
+ >
+
+ {notification.payload.title}
+
+
+ {notification.payload.description}
+
+
+
+
+
+
+
+
+
+ notificationsApi
+ .updateNotifications({
+ ids: [notification.id],
+ read: true,
+ })
+ .then(() => navigate(notification.payload.link))
+ }
+ >
+
+
+
+
+ {
+ if (notification.read) {
+ notificationsApi
+ .updateNotifications({
+ ids: [notification.id],
+ done: false,
+ })
+ .then(() => {
+ props.onUpdate();
+ });
+ } else {
+ notificationsApi
+ .updateNotifications({
+ ids: [notification.id],
+ done: true,
+ })
+ .then(() => {
+ props.onUpdate();
+ });
+ }
+ }}
+ >
+ {notification.read ? (
+
+ ) : (
+
+ )}
+
+
+
+ {
+ if (notification.saved) {
+ notificationsApi
+ .updateNotifications({
+ ids: [notification.id],
+ saved: false,
+ })
+ .then(() => {
+ props.onUpdate();
+ });
+ } else {
+ notificationsApi
+ .updateNotifications({
+ ids: [notification.id],
+ saved: true,
+ })
+ .then(() => {
+ props.onUpdate();
+ });
+ }
+ }}
+ >
+ {notification.saved ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/plugins/notifications/src/components/NotificationsTable/index.ts b/plugins/notifications/src/components/NotificationsTable/index.ts
new file mode 100644
index 0000000000..a1fb7a25c6
--- /dev/null
+++ b/plugins/notifications/src/components/NotificationsTable/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+export * from './NotificationsTable';
diff --git a/plugins/notifications/src/components/index.ts b/plugins/notifications/src/components/index.ts
new file mode 100644
index 0000000000..3bca70a215
--- /dev/null
+++ b/plugins/notifications/src/components/index.ts
@@ -0,0 +1,17 @@
+/*
+ * 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.
+ */
+export * from './NotificationsSideBarItem';
+export * from './NotificationsTable';
diff --git a/plugins/notifications/src/hooks/index.ts b/plugins/notifications/src/hooks/index.ts
new file mode 100644
index 0000000000..516901aa04
--- /dev/null
+++ b/plugins/notifications/src/hooks/index.ts
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
+export * from './useNotificationsApi';
+export * from './useWebNotifications';
+export * from './useTitleCounter';
diff --git a/plugins/notifications/src/hooks/useNotificationsApi.ts b/plugins/notifications/src/hooks/useNotificationsApi.ts
new file mode 100644
index 0000000000..e1afb54c8d
--- /dev/null
+++ b/plugins/notifications/src/hooks/useNotificationsApi.ts
@@ -0,0 +1,31 @@
+/*
+ * 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 { NotificationsApi, notificationsApiRef } from '../api';
+import { useApi } from '@backstage/core-plugin-api';
+import useAsyncRetry from 'react-use/lib/useAsyncRetry';
+
+/** @public */
+export function useNotificationsApi(
+ f: (api: NotificationsApi) => Promise,
+ deps: any[] = [],
+) {
+ const notificationsApi = useApi(notificationsApiRef);
+
+ return useAsyncRetry(async () => {
+ return await f(notificationsApi);
+ }, deps);
+}
diff --git a/plugins/notifications/src/hooks/useTitleCounter.ts b/plugins/notifications/src/hooks/useTitleCounter.ts
new file mode 100644
index 0000000000..d4ba22b946
--- /dev/null
+++ b/plugins/notifications/src/hooks/useTitleCounter.ts
@@ -0,0 +1,60 @@
+/*
+ * 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 { useCallback, useEffect, useState } from 'react';
+
+/** @public */
+export function useTitleCounter() {
+ const [title, setTitle] = useState(document.title);
+ const [count, setCount] = useState(0);
+
+ const getPrefix = (value: number) => {
+ return value === 0 ? '' : `(${value}) `;
+ };
+
+ const cleanTitle = (currentTitle: string) => {
+ return currentTitle.replace(/^\(\d+\)\s/, '');
+ };
+
+ useEffect(() => {
+ document.title = title;
+ }, [title]);
+
+ useEffect(() => {
+ const baseTitle = cleanTitle(title);
+ setTitle(`${getPrefix(count)}${baseTitle}`);
+ return () => {
+ document.title = cleanTitle(title);
+ };
+ }, [title, count]);
+
+ const titleElement = document.querySelector('title');
+ if (titleElement) {
+ new MutationObserver(() => {
+ setTitle(document.title);
+ }).observe(titleElement, {
+ subtree: true,
+ characterData: true,
+ childList: true,
+ });
+ }
+
+ const setNotificationCount = useCallback(
+ (newCount: number) => setCount(newCount),
+ [],
+ );
+
+ return { setNotificationCount };
+}
diff --git a/plugins/notifications/src/hooks/useWebNotifications.ts b/plugins/notifications/src/hooks/useWebNotifications.ts
new file mode 100644
index 0000000000..7e34172a97
--- /dev/null
+++ b/plugins/notifications/src/hooks/useWebNotifications.ts
@@ -0,0 +1,54 @@
+/*
+ * 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 { useCallback, useEffect, useState } from 'react';
+
+/** @public */
+export function useWebNotifications() {
+ const [webNotificationPermission, setWebNotificationPermission] =
+ useState('default');
+ const [webNotifications, setWebNotifications] = useState([]);
+
+ useEffect(() => {
+ if ('Notification' in window && webNotificationPermission === 'default') {
+ window.Notification.requestPermission().then(permission => {
+ setWebNotificationPermission(permission);
+ });
+ }
+ }, [webNotificationPermission]);
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') {
+ webNotifications.forEach(n => n.close());
+ setWebNotifications([]);
+ }
+ });
+
+ const sendWebNotification = useCallback(
+ (options: { title: string; description: string }) => {
+ if (webNotificationPermission !== 'granted') {
+ return null;
+ }
+
+ const notification = new Notification(options.title, {
+ body: options.description,
+ });
+ return notification;
+ },
+ [webNotificationPermission],
+ );
+
+ return { sendWebNotification };
+}
diff --git a/plugins/notifications/src/index.ts b/plugins/notifications/src/index.ts
new file mode 100644
index 0000000000..bb5d76a0cf
--- /dev/null
+++ b/plugins/notifications/src/index.ts
@@ -0,0 +1,19 @@
+/*
+ * 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.
+ */
+export { notificationsPlugin, NotificationsPage } from './plugin';
+export * from './api';
+export * from './hooks';
+export * from './components';
diff --git a/plugins/notifications/src/plugin.test.ts b/plugins/notifications/src/plugin.test.ts
new file mode 100644
index 0000000000..3bf749aba9
--- /dev/null
+++ b/plugins/notifications/src/plugin.test.ts
@@ -0,0 +1,22 @@
+/*
+ * 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 { notificationsPlugin } from './plugin';
+
+describe('notifications', () => {
+ it('should export plugin', () => {
+ expect(notificationsPlugin).toBeDefined();
+ });
+});
diff --git a/plugins/notifications/src/plugin.ts b/plugins/notifications/src/plugin.ts
new file mode 100644
index 0000000000..472e519183
--- /dev/null
+++ b/plugins/notifications/src/plugin.ts
@@ -0,0 +1,52 @@
+/*
+ * 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 {
+ createApiFactory,
+ createPlugin,
+ createRoutableExtension,
+ discoveryApiRef,
+ fetchApiRef,
+} from '@backstage/core-plugin-api';
+
+import { rootRouteRef } from './routes';
+import { notificationsApiRef } from './api/NotificationsApi';
+import { NotificationsClient } from './api';
+
+/** @public */
+export const notificationsPlugin = createPlugin({
+ id: 'notifications',
+ routes: {
+ root: rootRouteRef,
+ },
+ apis: [
+ createApiFactory({
+ api: notificationsApiRef,
+ deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef },
+ factory: ({ discoveryApi, fetchApi }) =>
+ new NotificationsClient({ discoveryApi, fetchApi }),
+ }),
+ ],
+});
+
+/** @public */
+export const NotificationsPage = notificationsPlugin.provide(
+ createRoutableExtension({
+ name: 'NotificationsPage',
+ component: () =>
+ import('./components/NotificationsPage').then(m => m.NotificationsPage),
+ mountPoint: rootRouteRef,
+ }),
+);
diff --git a/plugins/notifications/src/routes.ts b/plugins/notifications/src/routes.ts
new file mode 100644
index 0000000000..f307038701
--- /dev/null
+++ b/plugins/notifications/src/routes.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 { createRouteRef } from '@backstage/core-plugin-api';
+
+export const rootRouteRef = createRouteRef({
+ id: 'notifications',
+});
diff --git a/plugins/notifications/src/setupTests.ts b/plugins/notifications/src/setupTests.ts
new file mode 100644
index 0000000000..865308e634
--- /dev/null
+++ b/plugins/notifications/src/setupTests.ts
@@ -0,0 +1,16 @@
+/*
+ * 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 '@testing-library/jest-dom';
diff --git a/yarn.lock b/yarn.lock
index bda35a3dc9..a640721347 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7783,6 +7783,95 @@ __metadata:
languageName: unknown
linkType: soft
+"@backstage/plugin-notifications-backend@workspace:^, @backstage/plugin-notifications-backend@workspace:plugins/notifications-backend":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-notifications-backend@workspace:plugins/notifications-backend"
+ 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:^"
+ "@backstage/config": "workspace:^"
+ "@backstage/errors": "workspace:^"
+ "@backstage/plugin-auth-node": "workspace:^"
+ "@backstage/plugin-events-node": "workspace:^"
+ "@backstage/plugin-notifications-common": "workspace:^"
+ "@backstage/plugin-notifications-node": "workspace:^"
+ "@backstage/plugin-signals-node": "workspace:^"
+ "@types/express": ^4.17.6
+ "@types/supertest": ^2.0.8
+ express: ^4.17.1
+ express-promise-router: ^4.1.0
+ knex: ^3.0.0
+ 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
+ linkType: soft
+
+"@backstage/plugin-notifications-common@workspace:^, @backstage/plugin-notifications-common@workspace:plugins/notifications-common":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-notifications-common@workspace:plugins/notifications-common"
+ dependencies:
+ "@backstage/cli": "workspace:^"
+ "@material-ui/icons": ^4.9.1
+ languageName: unknown
+ linkType: soft
+
+"@backstage/plugin-notifications-node@workspace:^, @backstage/plugin-notifications-node@workspace:plugins/notifications-node":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-notifications-node@workspace:plugins/notifications-node"
+ dependencies:
+ "@backstage/backend-common": "workspace:^"
+ "@backstage/backend-plugin-api": "workspace:^"
+ "@backstage/catalog-client": "workspace:^"
+ "@backstage/catalog-model": "workspace:^"
+ "@backstage/cli": "workspace:^"
+ "@backstage/plugin-notifications-common": "workspace:^"
+ "@backstage/plugin-signals-node": "workspace:^"
+ "@backstage/test-utils": "workspace:^"
+ knex: ^3.0.0
+ msw: ^1.0.0
+ uuid: ^8.0.0
+ languageName: unknown
+ linkType: soft
+
+"@backstage/plugin-notifications@workspace:^, @backstage/plugin-notifications@workspace:plugins/notifications":
+ version: 0.0.0-use.local
+ resolution: "@backstage/plugin-notifications@workspace:plugins/notifications"
+ dependencies:
+ "@backstage/cli": "workspace:^"
+ "@backstage/core-app-api": "workspace:^"
+ "@backstage/core-components": "workspace:^"
+ "@backstage/core-plugin-api": "workspace:^"
+ "@backstage/dev-utils": "workspace:^"
+ "@backstage/errors": "workspace:^"
+ "@backstage/plugin-notifications-common": "workspace:^"
+ "@backstage/plugin-signals-react": "workspace:^"
+ "@backstage/test-utils": "workspace:^"
+ "@backstage/theme": "workspace:^"
+ "@backstage/types": "workspace:^"
+ "@material-ui/core": ^4.9.13
+ "@material-ui/icons": ^4.9.1
+ "@material-ui/lab": ^4.0.0-alpha.61
+ "@testing-library/jest-dom": ^6.0.0
+ "@testing-library/react": ^14.0.0
+ "@testing-library/user-event": ^14.0.0
+ "@types/react": ^16.13.1 || ^17.0.0
+ msw: ^1.0.0
+ react-relative-time: ^0.0.9
+ react-use: ^17.2.4
+ peerDependencies:
+ react: ^16.13.1 || ^17.0.0
+ react-router-dom: 6.0.0-beta.0 || ^6.3.0
+ languageName: unknown
+ linkType: soft
+
"@backstage/plugin-octopus-deploy@workspace:^, @backstage/plugin-octopus-deploy@workspace:plugins/octopus-deploy":
version: 0.0.0-use.local
resolution: "@backstage/plugin-octopus-deploy@workspace:plugins/octopus-deploy"
@@ -25247,9 +25336,9 @@ __metadata:
linkType: hard
"dom-accessibility-api@npm:^0.5.9":
- version: 0.5.13
- resolution: "dom-accessibility-api@npm:0.5.13"
- checksum: a5a5f14c01e466d424750aaac9225f1dc43cf16d101a1c40e01a554abce63c48084707002c39b805f2ce212273c179dd6d2258175997cd06d5f79851bf52dd40
+ version: 0.5.16
+ resolution: "dom-accessibility-api@npm:0.5.16"
+ checksum: 005eb283caef57fc1adec4d5df4dd49189b628f2f575af45decb210e04d634459e3f1ee64f18b41e2dcf200c844bc1d9279d80807e686a30d69a4756151ad248
languageName: node
linkType: hard
@@ -27062,6 +27151,7 @@ __metadata:
"@backstage/plugin-newrelic": "workspace:^"
"@backstage/plugin-newrelic-dashboard": "workspace:^"
"@backstage/plugin-nomad": "workspace:^"
+ "@backstage/plugin-notifications": "workspace:^"
"@backstage/plugin-octopus-deploy": "workspace:^"
"@backstage/plugin-org": "workspace:^"
"@backstage/plugin-pagerduty": "workspace:^"
@@ -27147,6 +27237,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:^"
@@ -27159,6 +27250,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:^"
@@ -39087,6 +39179,15 @@ __metadata:
languageName: node
linkType: hard
+"react-relative-time@npm:^0.0.9":
+ version: 0.0.9
+ resolution: "react-relative-time@npm:0.0.9"
+ peerDependencies:
+ react: ">=0.13.0"
+ checksum: 9c25887125df0eccfd4fee1edc4c99b19bef262ed5c66ac93269b63f0e1654a7c2758489d0d4b534847decc542dac23555896a7e2a0492af4c0ffbf48f1c62fe
+ languageName: node
+ linkType: hard
+
"react-remove-scroll-bar@npm:^2.3.3":
version: 2.3.4
resolution: "react-remove-scroll-bar@npm:2.3.4"