feat: add user specific notification settings
The settings can be customized for each origin and each processor individually. The default Web indicates notifications shown in the Backstage UI. By default, if there are no settings saved in the database, all notifications are enabled for all processors. The origins will populate by time for each user as they receive the first notification from that origin. Processors are shown as their own columns. Later, if it makes sense, allow users to also disable/enable notifications based on notification topic. Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-notifications-backend': patch
|
||||
'@backstage/plugin-notifications-common': patch
|
||||
'@backstage/plugin-notifications': patch
|
||||
---
|
||||
|
||||
Add support for user specific notification settings
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Add example template for notification sending
|
||||
@@ -350,6 +350,31 @@ export const myPlugin = createBackendPlugin({
|
||||
});
|
||||
```
|
||||
|
||||
### User-specific notification settings
|
||||
|
||||
The notifications plugin provides a way for users to manage their notification settings. To enable this, you must
|
||||
add the `UserNotificationSettingsCard` to your frontend.
|
||||
|
||||
```tsx
|
||||
// App.tsx example
|
||||
<Route path="/settings" element={<UserSettingsPage />}>
|
||||
<SettingsLayout.Route path="/advanced" title="Advanced">
|
||||
<AdvancedSettings />
|
||||
</SettingsLayout.Route>
|
||||
<SettingsLayout.Route path="/notifications" title="Notifications">
|
||||
<UserNotificationSettingsCard
|
||||
originNames={{ 'plugin:scaffolder': 'Scaffolder' }}
|
||||
/>
|
||||
</SettingsLayout.Route>
|
||||
</Route>
|
||||
```
|
||||
|
||||

|
||||
|
||||
You can customize the origin names shown in the UI by passing an object where the keys are the origins and the values are the names you want to show in the UI.
|
||||
|
||||
Each notification processor will receive its own column in the settings page, where the user can enable or disable notifications from that processor.
|
||||
|
||||
### External Services
|
||||
|
||||
When the emitter of a notification is a Backstage backend plugin, it is mandatory to use the integration via `@backstage/plugin-notifications-node` as described above.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -81,7 +81,10 @@ import { TwoColumnLayout } from './components/scaffolder/customScaffolderLayouts
|
||||
import { customDevToolsPage } from './components/devtools/CustomDevToolsPage';
|
||||
import { DevToolsPage } from '@backstage/plugin-devtools';
|
||||
import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities';
|
||||
import { NotificationsPage } from '@backstage/plugin-notifications';
|
||||
import {
|
||||
NotificationsPage,
|
||||
UserNotificationSettingsCard,
|
||||
} from '@backstage/plugin-notifications';
|
||||
|
||||
const app = createApp({
|
||||
apis,
|
||||
@@ -206,6 +209,11 @@ const routes = (
|
||||
<SettingsLayout.Route path="/advanced" title="Advanced">
|
||||
<AdvancedSettings />
|
||||
</SettingsLayout.Route>
|
||||
<SettingsLayout.Route path="/notifications" title="Notifications">
|
||||
<UserNotificationSettingsCard
|
||||
originNames={{ 'plugin:scaffolder': 'Scaffolder' }}
|
||||
/>
|
||||
</SettingsLayout.Route>
|
||||
</Route>
|
||||
<Route path="/devtools" element={<DevToolsPage />}>
|
||||
{customDevToolsPage}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@backstage/plugin-proxy-backend": "workspace:^",
|
||||
"@backstage/plugin-scaffolder-backend": "workspace:^",
|
||||
"@backstage/plugin-scaffolder-backend-module-github": "workspace:^",
|
||||
"@backstage/plugin-scaffolder-backend-module-notifications": "workspace:^",
|
||||
"@backstage/plugin-search-backend": "workspace:^",
|
||||
"@backstage/plugin-search-backend-module-catalog": "workspace:^",
|
||||
"@backstage/plugin-search-backend-module-explore": "workspace:^",
|
||||
|
||||
@@ -49,6 +49,9 @@ backend.add(import('@backstage/plugin-permission-backend'));
|
||||
backend.add(import('@backstage/plugin-proxy-backend'));
|
||||
backend.add(import('@backstage/plugin-scaffolder-backend'));
|
||||
backend.add(import('@backstage/plugin-scaffolder-backend-module-github'));
|
||||
backend.add(
|
||||
import('@backstage/plugin-scaffolder-backend-module-notifications'),
|
||||
);
|
||||
backend.add(
|
||||
import('@backstage/plugin-catalog-backend-module-backstage-openapi'),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.createTable('user_settings', table => {
|
||||
table.string('user').notNullable();
|
||||
table.string('channel').notNullable();
|
||||
table.string('origin').notNullable();
|
||||
table.boolean('enabled').defaultTo(true).notNullable();
|
||||
table.index(['user'], 'user_settings_user_idx');
|
||||
table.unique(['user', 'channel', 'origin'], {
|
||||
indexName: 'user_settings_unique_idx',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.dropTable('user_settings');
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import { DatabaseNotificationsStore } from './DatabaseNotificationsStore';
|
||||
import { Knex } from 'knex';
|
||||
import {
|
||||
Notification,
|
||||
NotificationSettings,
|
||||
NotificationSeverity,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
|
||||
@@ -151,6 +152,23 @@ const otherUserNotification: Notification = {
|
||||
severity: 'normal',
|
||||
},
|
||||
};
|
||||
const notificationSettings: NotificationSettings = {
|
||||
channels: [
|
||||
{
|
||||
id: 'Web',
|
||||
origins: [
|
||||
{
|
||||
id: 'plugin-test',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'plugin-test2',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'DatabaseNotificationsStore (%s)',
|
||||
@@ -712,5 +730,18 @@ describe.each(databases.eachSupportedId())(
|
||||
expect(notification?.saved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings', () => {
|
||||
it('should save and load notification settings', async () => {
|
||||
await storage.saveNotificationSettings({
|
||||
user: 'user:default/test',
|
||||
settings: notificationSettings,
|
||||
});
|
||||
const settings = await storage.getNotificationSettings({
|
||||
user: 'user:default/test',
|
||||
});
|
||||
expect(settings).toEqual(notificationSettings);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { resolvePackagePath } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
DatabaseService,
|
||||
resolvePackagePath,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
NotificationGetOptions,
|
||||
NotificationModifyOptions,
|
||||
@@ -22,8 +24,9 @@ import {
|
||||
} from './NotificationsStore';
|
||||
import {
|
||||
Notification,
|
||||
NotificationSeverity,
|
||||
NotificationSettings,
|
||||
notificationSeverities,
|
||||
NotificationSeverity,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
@@ -49,6 +52,50 @@ const NOTIFICATION_COLUMNS = [
|
||||
'saved',
|
||||
];
|
||||
|
||||
type NotificationRowType = {
|
||||
id: string;
|
||||
user: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
severity: string;
|
||||
link: string | null;
|
||||
origin: string;
|
||||
scope: string | null;
|
||||
topic: string | null;
|
||||
created: Date;
|
||||
updated: Date | null;
|
||||
read: Date | null;
|
||||
saved: Date | null;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
type BroadcastRowType = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
link: string | null;
|
||||
origin: string;
|
||||
scope: string | null;
|
||||
topic: string | null;
|
||||
created: Date;
|
||||
updated: Date | null;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
type BroadcastUserStatusRowType = {
|
||||
broadcast_id: string;
|
||||
user: string;
|
||||
read: Date | null;
|
||||
saved: Date | null;
|
||||
};
|
||||
|
||||
type UserSettingsRowType = {
|
||||
user: string;
|
||||
channel: string;
|
||||
origin: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export const normalizeSeverity = (input?: string): NotificationSeverity => {
|
||||
let lower = (input ?? 'normal').toLowerCase() as NotificationSeverity;
|
||||
if (notificationSeverities.indexOf(lower) < 0) {
|
||||
@@ -69,7 +116,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
database,
|
||||
skipMigrations,
|
||||
}: {
|
||||
database: PluginDatabaseManager;
|
||||
database: DatabaseService;
|
||||
skipMigrations?: boolean;
|
||||
}): Promise<DatabaseNotificationsStore> {
|
||||
const client = await database.getClient();
|
||||
@@ -108,6 +155,29 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
}));
|
||||
};
|
||||
|
||||
private mapToNotificationSettings = (rows: any[]): NotificationSettings => {
|
||||
return rows.reduce(
|
||||
(acc, row) => {
|
||||
let chan = acc.channels.find(
|
||||
(channel: { id: string }) => channel.id === row.channel,
|
||||
);
|
||||
if (!chan) {
|
||||
acc.channels.push({
|
||||
id: row.channel,
|
||||
origins: [],
|
||||
});
|
||||
chan = acc.channels[acc.channels.length - 1];
|
||||
}
|
||||
chan.origins.push({
|
||||
id: row.origin,
|
||||
enabled: Boolean(row.enabled),
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{ channels: [] },
|
||||
);
|
||||
};
|
||||
|
||||
private mapNotificationToDbRow = (notification: Notification) => {
|
||||
return {
|
||||
id: notification.id,
|
||||
@@ -142,7 +212,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
};
|
||||
|
||||
private getBroadcastUnion = (user?: string | null) => {
|
||||
return this.db('broadcast')
|
||||
return this.db<BroadcastRowType>('broadcast')
|
||||
.leftJoin('broadcast_user_status', function clause() {
|
||||
const join = this.on('id', '=', 'broadcast_user_status.broadcast_id');
|
||||
if (user !== null && user !== undefined) {
|
||||
@@ -157,7 +227,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
) => {
|
||||
const { user, orderField } = options;
|
||||
|
||||
const subQuery = this.db('notification')
|
||||
const subQuery = this.db<NotificationRowType>('notification')
|
||||
.select(NOTIFICATION_COLUMNS)
|
||||
.unionAll([this.getBroadcastUnion(user)])
|
||||
.as('notifications');
|
||||
@@ -295,7 +365,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
scope: string;
|
||||
origin: string;
|
||||
}) {
|
||||
const query = this.db('notification')
|
||||
const query = this.db<NotificationRowType>('notification')
|
||||
.where('user', options.user)
|
||||
.where('scope', options.scope)
|
||||
.where('origin', options.origin)
|
||||
@@ -305,11 +375,11 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
if (!rows || rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return rows[0] as Notification;
|
||||
return this.mapToNotifications(rows)[0];
|
||||
}
|
||||
|
||||
async getExistingScopeBroadcast(options: { scope: string; origin: string }) {
|
||||
const query = this.db('broadcast')
|
||||
const query = this.db<BroadcastRowType>('broadcast')
|
||||
.where('scope', options.scope)
|
||||
.where('origin', options.origin)
|
||||
.limit(1);
|
||||
@@ -318,7 +388,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
if (!rows || rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return rows[0] as Notification;
|
||||
return this.mapToNotifications(rows)[0];
|
||||
}
|
||||
|
||||
async restoreExistingNotification({
|
||||
@@ -358,7 +428,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
const rows = await this.db
|
||||
.select('*')
|
||||
.from(
|
||||
this.db('notification')
|
||||
this.db<NotificationRowType>('notification')
|
||||
.select(NOTIFICATION_COLUMNS)
|
||||
.unionAll([this.getBroadcastUnion(options.user)])
|
||||
.as('notifications'),
|
||||
@@ -377,7 +447,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
read?: Date | null,
|
||||
saved?: Date | null,
|
||||
) => {
|
||||
await this.db('notification')
|
||||
await this.db<NotificationRowType>('notification')
|
||||
.whereIn('id', ids)
|
||||
.where('user', user)
|
||||
.update({ read, saved });
|
||||
@@ -388,7 +458,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
|
||||
if (broadcasts.length > 0)
|
||||
if (!this.isSQLite) {
|
||||
await this.db('broadcast_user_status')
|
||||
await this.db<BroadcastUserStatusRowType>('broadcast_user_status')
|
||||
.insert(
|
||||
broadcasts.map(b => ({
|
||||
broadcast_id: b.id,
|
||||
@@ -402,7 +472,9 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
} else {
|
||||
// SQLite does not support upsert so fall back to this (mostly for tests and local dev)
|
||||
for (const b of broadcasts) {
|
||||
const baseQuery = this.db('broadcast_user_status')
|
||||
const baseQuery = this.db<BroadcastUserStatusRowType>(
|
||||
'broadcast_user_status',
|
||||
)
|
||||
.where('broadcast_id', b.id)
|
||||
.where('user', user);
|
||||
const exists = await baseQuery.clone().limit(1).select().first();
|
||||
@@ -432,4 +504,63 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
async markUnsaved(options: NotificationModifyOptions): Promise<void> {
|
||||
await this.markReadSaved(options.ids, options.user, undefined, null);
|
||||
}
|
||||
|
||||
async getUserNotificationOrigins(options: {
|
||||
user: string;
|
||||
}): Promise<{ origins: string[] }> {
|
||||
const rows: { origin: string }[] = await this.db<NotificationRowType>(
|
||||
'notification',
|
||||
)
|
||||
.where('user', options.user)
|
||||
.select('origin')
|
||||
.distinct();
|
||||
return { origins: rows.map(row => row.origin) };
|
||||
}
|
||||
|
||||
async getNotificationSettings(options: {
|
||||
user: string;
|
||||
origin?: string;
|
||||
channel?: string;
|
||||
}): Promise<NotificationSettings> {
|
||||
const settingsQuery = this.db<UserSettingsRowType>('user_settings').where(
|
||||
'user',
|
||||
options.user,
|
||||
);
|
||||
if (options.origin) {
|
||||
settingsQuery.where('origin', options.origin);
|
||||
}
|
||||
|
||||
if (options.channel) {
|
||||
settingsQuery.where('channel', options.channel);
|
||||
}
|
||||
const settings = await settingsQuery.select();
|
||||
return this.mapToNotificationSettings(settings);
|
||||
}
|
||||
|
||||
async saveNotificationSettings(options: {
|
||||
user: string;
|
||||
settings: NotificationSettings;
|
||||
}): Promise<void> {
|
||||
const rows: {
|
||||
user: string;
|
||||
channel: string;
|
||||
origin: string;
|
||||
enabled: boolean;
|
||||
}[] = [];
|
||||
options.settings.channels.map(channel => {
|
||||
channel.origins.map(origin => {
|
||||
rows.push({
|
||||
user: options.user,
|
||||
channel: channel.id,
|
||||
origin: origin.id,
|
||||
enabled: origin.enabled,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await this.db<UserSettingsRowType>('user_settings')
|
||||
.where('user', options.user)
|
||||
.delete();
|
||||
await this.db<UserSettingsRowType>('user_settings').insert(rows);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import {
|
||||
Notification,
|
||||
NotificationSettings,
|
||||
NotificationSeverity,
|
||||
NotificationStatus,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
@@ -83,4 +84,17 @@ export interface NotificationsStore {
|
||||
markSaved(options: NotificationModifyOptions): Promise<void>;
|
||||
|
||||
markUnsaved(options: NotificationModifyOptions): Promise<void>;
|
||||
|
||||
getUserNotificationOrigins(options: {
|
||||
user: string;
|
||||
}): Promise<{ origins: string[] }>;
|
||||
|
||||
getNotificationSettings(options: {
|
||||
user: string;
|
||||
}): Promise<NotificationSettings>;
|
||||
|
||||
saveNotificationSettings(options: {
|
||||
user: string;
|
||||
settings: NotificationSettings;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -37,10 +37,11 @@ import {
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { SignalsService } from '@backstage/plugin-signals-node';
|
||||
import {
|
||||
isNotificationsEnabledFor,
|
||||
NewNotificationSignal,
|
||||
Notification,
|
||||
NotificationPayload,
|
||||
NotificationReadSignal,
|
||||
NotificationSettings,
|
||||
notificationSeverities,
|
||||
NotificationStatus,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
@@ -77,6 +78,7 @@ export async function createRouter(
|
||||
signals,
|
||||
} = options;
|
||||
|
||||
const WEB_NOTIFICATION_CHANNEL = 'Web';
|
||||
const store = await DatabaseNotificationsStore.create({ database });
|
||||
const frontendBaseUrl = config.getString('app.baseUrl');
|
||||
|
||||
@@ -86,10 +88,62 @@ export async function createRouter(
|
||||
return info.userEntityRef;
|
||||
};
|
||||
|
||||
const filterProcessors = (payload: NotificationPayload) => {
|
||||
const getNotificationChannels = () => {
|
||||
return [WEB_NOTIFICATION_CHANNEL, ...processors.map(p => p.getName())];
|
||||
};
|
||||
|
||||
const getNotificationSettings = async (user: string) => {
|
||||
const { origins } = await store.getUserNotificationOrigins({ user });
|
||||
const settings = await store.getNotificationSettings({ user });
|
||||
const channels = getNotificationChannels();
|
||||
|
||||
const response: NotificationSettings = {
|
||||
channels: channels.map(channel => {
|
||||
const channelSettings = settings.channels.find(c => c.id === channel);
|
||||
if (channelSettings) {
|
||||
return channelSettings;
|
||||
}
|
||||
return {
|
||||
id: channel,
|
||||
origins: origins.map(origin => ({
|
||||
id: origin,
|
||||
enabled: true,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return response;
|
||||
};
|
||||
|
||||
const isNotificationsEnabled = async (opts: {
|
||||
user: string;
|
||||
channel: string;
|
||||
origin: string;
|
||||
}) => {
|
||||
const settings = await getNotificationSettings(opts.user);
|
||||
return isNotificationsEnabledFor(settings, opts.channel, opts.origin);
|
||||
};
|
||||
|
||||
const filterProcessors = async (
|
||||
notification:
|
||||
| Notification
|
||||
| ({ origin: string; user: null } & NotificationSendOptions),
|
||||
) => {
|
||||
const result: NotificationProcessor[] = [];
|
||||
const { payload, user, origin } = notification;
|
||||
|
||||
for (const processor of processors) {
|
||||
if (user) {
|
||||
const enabled = await isNotificationsEnabled({
|
||||
user,
|
||||
origin,
|
||||
channel: processor.getName(),
|
||||
});
|
||||
if (!enabled) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (processor.getNotificationFilters) {
|
||||
const filters = processor.getNotificationFilters();
|
||||
if (filters.minSeverity) {
|
||||
@@ -122,8 +176,11 @@ export async function createRouter(
|
||||
return result;
|
||||
};
|
||||
|
||||
const processOptions = async (opts: NotificationSendOptions) => {
|
||||
const filtered = filterProcessors(opts.payload);
|
||||
const processOptions = async (
|
||||
opts: NotificationSendOptions,
|
||||
origin: string,
|
||||
) => {
|
||||
const filtered = await filterProcessors({ ...opts, origin, user: null });
|
||||
let ret = opts;
|
||||
for (const processor of filtered) {
|
||||
try {
|
||||
@@ -143,7 +200,7 @@ export async function createRouter(
|
||||
notification: Notification,
|
||||
opts: NotificationSendOptions,
|
||||
) => {
|
||||
const filtered = filterProcessors(notification.payload);
|
||||
const filtered = await filterProcessors(notification);
|
||||
let ret = notification;
|
||||
for (const processor of filtered) {
|
||||
try {
|
||||
@@ -163,7 +220,7 @@ export async function createRouter(
|
||||
notification: Notification,
|
||||
opts: NotificationSendOptions,
|
||||
) => {
|
||||
const filtered = filterProcessors(notification.payload);
|
||||
const filtered = await filterProcessors(notification);
|
||||
for (const processor of filtered) {
|
||||
if (processor.postProcess) {
|
||||
try {
|
||||
@@ -261,6 +318,33 @@ export async function createRouter(
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/settings',
|
||||
async (req: Request<any, NotificationSettings>, res) => {
|
||||
const user = await getUser(req);
|
||||
const response = await getNotificationSettings(user);
|
||||
res.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/settings',
|
||||
async (
|
||||
req: Request<any, NotificationSettings, NotificationSettings>,
|
||||
res,
|
||||
) => {
|
||||
const user = await getUser(req);
|
||||
const channels = getNotificationChannels();
|
||||
const settings: NotificationSettings = req.body;
|
||||
if (settings.channels.some(c => !channels.includes(c.id))) {
|
||||
throw new InputError('Invalid channel');
|
||||
}
|
||||
await store.saveNotificationSettings({ user, settings });
|
||||
const response = await getNotificationSettings(user);
|
||||
res.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
// Make sure this is the last "GET" handler
|
||||
router.get('/:id', async (req, res) => {
|
||||
const user = await getUser(req);
|
||||
@@ -380,37 +464,45 @@ export async function createRouter(
|
||||
};
|
||||
const notification = await preProcessNotification(userNotification, opts);
|
||||
|
||||
let existingNotification;
|
||||
if (scope) {
|
||||
existingNotification = await store.getExistingScopeNotification({
|
||||
user,
|
||||
scope,
|
||||
origin,
|
||||
});
|
||||
}
|
||||
const enabled = await isNotificationsEnabled({
|
||||
user,
|
||||
channel: WEB_NOTIFICATION_CHANNEL,
|
||||
origin: userNotification.origin,
|
||||
});
|
||||
|
||||
let ret = notification;
|
||||
if (existingNotification) {
|
||||
const restored = await store.restoreExistingNotification({
|
||||
id: existingNotification.id,
|
||||
notification,
|
||||
});
|
||||
ret = restored ?? notification;
|
||||
} else {
|
||||
await store.saveNotification(notification);
|
||||
}
|
||||
if (enabled) {
|
||||
let existingNotification;
|
||||
if (scope) {
|
||||
existingNotification = await store.getExistingScopeNotification({
|
||||
user,
|
||||
scope,
|
||||
origin,
|
||||
});
|
||||
}
|
||||
|
||||
notifications.push(ret);
|
||||
if (existingNotification) {
|
||||
const restored = await store.restoreExistingNotification({
|
||||
id: existingNotification.id,
|
||||
notification,
|
||||
});
|
||||
ret = restored ?? notification;
|
||||
} else {
|
||||
await store.saveNotification(notification);
|
||||
}
|
||||
|
||||
if (signals) {
|
||||
await signals.publish<NewNotificationSignal>({
|
||||
recipients: { type: 'user', entityRef: [user] },
|
||||
message: {
|
||||
action: 'new_notification',
|
||||
notification_id: ret.id,
|
||||
},
|
||||
channel: 'notifications',
|
||||
});
|
||||
notifications.push(ret);
|
||||
|
||||
if (signals) {
|
||||
await signals.publish<NewNotificationSignal>({
|
||||
recipients: { type: 'user', entityRef: [user] },
|
||||
message: {
|
||||
action: 'new_notification',
|
||||
notification_id: ret.id,
|
||||
},
|
||||
channel: 'notifications',
|
||||
});
|
||||
}
|
||||
}
|
||||
postProcessNotification(ret, opts);
|
||||
}
|
||||
@@ -421,16 +513,16 @@ export async function createRouter(
|
||||
router.post(
|
||||
'/',
|
||||
async (req: Request<any, Notification[], NotificationSendOptions>, res) => {
|
||||
const opts = await processOptions(req.body);
|
||||
const { recipients, payload } = opts;
|
||||
const notifications: Notification[] = [];
|
||||
let users = [];
|
||||
|
||||
const credentials = await httpAuth.credentials(req, {
|
||||
allow: ['service'],
|
||||
});
|
||||
|
||||
const origin = credentials.principal.subject;
|
||||
const opts = await processOptions(req.body, origin);
|
||||
const { recipients, payload } = opts;
|
||||
const { title, link } = payload;
|
||||
const notifications: Notification[] = [];
|
||||
let users = [];
|
||||
|
||||
if (!recipients || !title) {
|
||||
logger.error(`Invalid notification request received`);
|
||||
@@ -445,7 +537,6 @@ export async function createRouter(
|
||||
}
|
||||
}
|
||||
|
||||
const origin = credentials.principal.subject;
|
||||
const baseNotification = {
|
||||
payload: {
|
||||
...payload,
|
||||
|
||||
@@ -10,6 +10,13 @@ export const getProcessorFiltersFromConfig: (
|
||||
config: Config,
|
||||
) => NotificationProcessorFilters;
|
||||
|
||||
// @public (undocumented)
|
||||
export const isNotificationsEnabledFor: (
|
||||
settings: NotificationSettings,
|
||||
channelId: string,
|
||||
originId: string,
|
||||
) => boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
export type NewNotificationSignal = {
|
||||
action: 'new_notification';
|
||||
@@ -53,6 +60,17 @@ export type NotificationReadSignal = {
|
||||
notification_ids: string[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type NotificationSettings = {
|
||||
channels: {
|
||||
id: string;
|
||||
origins: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export const notificationSeverities: NotificationSeverity[];
|
||||
|
||||
|
||||
@@ -23,3 +23,4 @@
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export * from './filters';
|
||||
export * from './utils';
|
||||
|
||||
@@ -125,3 +125,16 @@ export type NotificationProcessorFilters = {
|
||||
maxSeverity?: NotificationSeverity;
|
||||
excludedTopics?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type NotificationSettings = {
|
||||
channels: {
|
||||
id: string;
|
||||
origins: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 { NotificationSettings } from './types';
|
||||
|
||||
/** @public */
|
||||
export const isNotificationsEnabledFor = (
|
||||
settings: NotificationSettings,
|
||||
channelId: string,
|
||||
originId: string,
|
||||
) => {
|
||||
const channel = settings.channels.find(c => c.id === channelId);
|
||||
if (!channel) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const origin = channel.origins.find(o => o.id === originId);
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
return origin.enabled;
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { FetchApi } from '@backstage/core-plugin-api';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
import { Notification as Notification_2 } from '@backstage/plugin-notifications-common';
|
||||
import { NotificationSettings } from '@backstage/plugin-notifications-common';
|
||||
import { NotificationSeverity } from '@backstage/plugin-notifications-common';
|
||||
import { NotificationStatus } from '@backstage/plugin-notifications-common';
|
||||
import { default as React_2 } from 'react';
|
||||
@@ -46,11 +47,17 @@ export interface NotificationsApi {
|
||||
options?: GetNotificationsOptions,
|
||||
): Promise<GetNotificationsResponse>;
|
||||
// (undocumented)
|
||||
getNotificationSettings(): Promise<NotificationSettings>;
|
||||
// (undocumented)
|
||||
getStatus(): Promise<NotificationStatus>;
|
||||
// (undocumented)
|
||||
updateNotifications(
|
||||
options: UpdateNotificationsOptions,
|
||||
): Promise<Notification_2[]>;
|
||||
// (undocumented)
|
||||
updateNotificationSettings(
|
||||
settings: NotificationSettings,
|
||||
): Promise<NotificationSettings>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -66,11 +73,17 @@ export class NotificationsClient implements NotificationsApi {
|
||||
options?: GetNotificationsOptions,
|
||||
): Promise<GetNotificationsResponse>;
|
||||
// (undocumented)
|
||||
getNotificationSettings(): Promise<NotificationSettings>;
|
||||
// (undocumented)
|
||||
getStatus(): Promise<NotificationStatus>;
|
||||
// (undocumented)
|
||||
updateNotifications(
|
||||
options: UpdateNotificationsOptions,
|
||||
): Promise<Notification_2[]>;
|
||||
// (undocumented)
|
||||
updateNotificationSettings(
|
||||
settings: NotificationSettings,
|
||||
): Promise<NotificationSettings>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -176,5 +189,10 @@ export function useNotificationsApi<T>(
|
||||
value: T;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const UserNotificationSettingsCard: (props: {
|
||||
originNames?: Record<string, string>;
|
||||
}) => React_2.JSX.Element;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
Notification,
|
||||
NotificationSettings,
|
||||
NotificationSeverity,
|
||||
NotificationStatus,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
@@ -64,4 +65,10 @@ export interface NotificationsApi {
|
||||
updateNotifications(
|
||||
options: UpdateNotificationsOptions,
|
||||
): Promise<Notification[]>;
|
||||
|
||||
getNotificationSettings(): Promise<NotificationSettings>;
|
||||
|
||||
updateNotificationSettings(
|
||||
settings: NotificationSettings,
|
||||
): Promise<NotificationSettings>;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import {
|
||||
Notification,
|
||||
NotificationSettings,
|
||||
NotificationStatus,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
|
||||
@@ -93,6 +94,20 @@ export class NotificationsClient implements NotificationsApi {
|
||||
});
|
||||
}
|
||||
|
||||
async getNotificationSettings(): Promise<NotificationSettings> {
|
||||
return await this.request<NotificationSettings>('settings');
|
||||
}
|
||||
|
||||
async updateNotificationSettings(
|
||||
settings: NotificationSettings,
|
||||
): Promise<NotificationSettings> {
|
||||
return await this.request<NotificationSettings>('settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init?: any): Promise<T> {
|
||||
const baseUrl = `${await this.discoveryApi.getBaseUrl('notifications')}/`;
|
||||
const url = new URL(path, baseUrl);
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 React, { useEffect } from 'react';
|
||||
import { ErrorPanel, InfoCard, Progress } from '@backstage/core-components';
|
||||
import { useNotificationsApi } from '../../hooks';
|
||||
import { NotificationSettings } from '@backstage/plugin-notifications-common';
|
||||
import { notificationsApiRef } from '../../api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { UserNotificationSettingsPanel } from './UserNotificationSettingsPanel';
|
||||
|
||||
/** @public */
|
||||
export const UserNotificationSettingsCard = (props: {
|
||||
originNames?: Record<string, string>;
|
||||
}) => {
|
||||
const [settings, setNotificationSettings] = React.useState<
|
||||
NotificationSettings | undefined
|
||||
>(undefined);
|
||||
|
||||
const client = useApi(notificationsApiRef);
|
||||
const { error, value, loading } = useNotificationsApi(api => {
|
||||
return api.getNotificationSettings();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !error) {
|
||||
setNotificationSettings(value);
|
||||
}
|
||||
}, [loading, value, error]);
|
||||
|
||||
const onUpdate = (newSettings: NotificationSettings) => {
|
||||
client
|
||||
.updateNotificationSettings(newSettings)
|
||||
.then(updatedSettings => setNotificationSettings(updatedSettings));
|
||||
};
|
||||
|
||||
return (
|
||||
<InfoCard title="Notification settings" variant="gridItem">
|
||||
{loading && <Progress />}
|
||||
{error && <ErrorPanel title="Failed to load settings" error={error} />}
|
||||
{settings && (
|
||||
<UserNotificationSettingsPanel
|
||||
settings={settings}
|
||||
onChange={onUpdate}
|
||||
originNames={props.originNames}
|
||||
/>
|
||||
)}
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import {
|
||||
isNotificationsEnabledFor,
|
||||
NotificationSettings,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import MuiTableCell from '@material-ui/core/TableCell';
|
||||
import { withStyles } from '@material-ui/core/styles';
|
||||
import TableHead from '@material-ui/core/TableHead';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Switch from '@material-ui/core/Switch';
|
||||
import { capitalize } from 'lodash';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
|
||||
const TableCell = withStyles({
|
||||
root: {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
})(MuiTableCell);
|
||||
|
||||
export const UserNotificationSettingsPanel = (props: {
|
||||
settings: NotificationSettings;
|
||||
onChange: (settings: NotificationSettings) => void;
|
||||
originNames?: Record<string, string>;
|
||||
}) => {
|
||||
const { settings, onChange } = props;
|
||||
const allOrigins = [
|
||||
...new Set(
|
||||
settings.channels.flatMap(channel =>
|
||||
channel.origins.map(origin => origin.id),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const handleChange = (
|
||||
channelId: string,
|
||||
originId: string,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
const updatedSettings = {
|
||||
channels: settings.channels.map(channel => {
|
||||
if (channel.id !== channelId) {
|
||||
return channel;
|
||||
}
|
||||
return {
|
||||
...channel,
|
||||
origins: channel.origins.map(origin => {
|
||||
if (origin.id !== originId) {
|
||||
return origin;
|
||||
}
|
||||
return {
|
||||
...origin,
|
||||
enabled,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
onChange(updatedSettings);
|
||||
};
|
||||
|
||||
const formatOriginName = (originId: string) => {
|
||||
if (props.originNames && originId in props.originNames) {
|
||||
return props.originNames[originId];
|
||||
}
|
||||
return capitalize(originId.replaceAll(/[_:]/g, ' '));
|
||||
};
|
||||
|
||||
if (settings.channels.length === 0 || allOrigins.length === 0) {
|
||||
return (
|
||||
<Typography variant="body1">
|
||||
No notification settings available, check back later
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle1">Origin</Typography>
|
||||
</TableCell>
|
||||
{settings.channels.map(channel => (
|
||||
<TableCell>
|
||||
<Typography variant="subtitle1">{channel.id}</Typography>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{allOrigins.map(origin => (
|
||||
<TableRow>
|
||||
<TableCell>{formatOriginName(origin)}</TableCell>
|
||||
{settings.channels.map(channel => (
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={`Enable or disable ${channel.id.toLocaleLowerCase(
|
||||
'en-US',
|
||||
)} notifications from ${formatOriginName(
|
||||
origin,
|
||||
).toLocaleLowerCase('en-US')}`}
|
||||
>
|
||||
<Switch
|
||||
checked={isNotificationsEnabledFor(
|
||||
settings,
|
||||
channel.id,
|
||||
origin,
|
||||
)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleChange(channel.id, origin, event.target.checked);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export { UserNotificationSettingsCard } from './UserNotificationSettingsCard';
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
export * from './NotificationsSideBarItem';
|
||||
export * from './NotificationsTable';
|
||||
export * from './UserNotificationSettingsCard';
|
||||
export type { NotificationsPageProps } from './NotificationsPage';
|
||||
|
||||
@@ -6,6 +6,7 @@ metadata:
|
||||
spec:
|
||||
targets:
|
||||
- ./remote-templates.yaml
|
||||
- ./notifications-demo/template.yaml
|
||||
# For local development of a template, you can reference your local templates here.
|
||||
# Examples:
|
||||
#
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
apiVersion: scaffolder.backstage.io/v1beta3
|
||||
kind: Template
|
||||
metadata:
|
||||
name: notifications-demo
|
||||
title: Test Notifications template
|
||||
description: scaffolder v1beta3 template demo sending notification
|
||||
spec:
|
||||
owner: backstage/techdocs-core
|
||||
type: service
|
||||
parameters:
|
||||
- title: Notification
|
||||
required:
|
||||
- recipients
|
||||
- title
|
||||
properties:
|
||||
recipients:
|
||||
title: Recipients
|
||||
type: string
|
||||
description: Notification recipients
|
||||
default: entity
|
||||
enum:
|
||||
- entity
|
||||
- broadcast
|
||||
entityRefs:
|
||||
title: Entities
|
||||
type: array
|
||||
description: Entities to send the notification. Required if recipients is entity
|
||||
ui:field: MultiEntityPicker
|
||||
ui:options:
|
||||
defaultNamespace: default
|
||||
title:
|
||||
title: Title
|
||||
type: string
|
||||
description: Notification title
|
||||
description:
|
||||
title: Description
|
||||
type: string
|
||||
description: Notification longer description
|
||||
link:
|
||||
title: Link
|
||||
type: string
|
||||
description: Notification link
|
||||
severity:
|
||||
title: Severity
|
||||
type: string
|
||||
description: Notification severity
|
||||
default: normal
|
||||
enum:
|
||||
- low
|
||||
- normal
|
||||
- high
|
||||
- critical
|
||||
scope:
|
||||
title: Scope
|
||||
type: string
|
||||
description: Notification scope
|
||||
|
||||
steps:
|
||||
- id: send-notification
|
||||
name: Send notification
|
||||
action: notification:send
|
||||
input:
|
||||
recipients: ${{ parameters.recipients }}
|
||||
entityRefs: ${{ parameters.entityRefs }}
|
||||
title: ${{ parameters.title }}
|
||||
description: ${{ parameters.description }}
|
||||
link: ${{ parameters.link }}
|
||||
severity: ${{ parameters.severity }}
|
||||
scope: ${{ parameters.scope }}
|
||||
@@ -7212,7 +7212,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-scaffolder-backend-module-notifications@workspace:plugins/scaffolder-backend-module-notifications":
|
||||
"@backstage/plugin-scaffolder-backend-module-notifications@workspace:^, @backstage/plugin-scaffolder-backend-module-notifications@workspace:plugins/scaffolder-backend-module-notifications":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-scaffolder-backend-module-notifications@workspace:plugins/scaffolder-backend-module-notifications"
|
||||
dependencies:
|
||||
@@ -27080,6 +27080,7 @@ __metadata:
|
||||
"@backstage/plugin-proxy-backend": "workspace:^"
|
||||
"@backstage/plugin-scaffolder-backend": "workspace:^"
|
||||
"@backstage/plugin-scaffolder-backend-module-github": "workspace:^"
|
||||
"@backstage/plugin-scaffolder-backend-module-notifications": "workspace:^"
|
||||
"@backstage/plugin-search-backend": "workspace:^"
|
||||
"@backstage/plugin-search-backend-module-catalog": "workspace:^"
|
||||
"@backstage/plugin-search-backend-module-explore": "workspace:^"
|
||||
|
||||
Reference in New Issue
Block a user