Merge pull request #29301 from billyatroadie/add-topics-to-notification-settings
Adds ability for user to turn on/off notifications for specific topics within an origin.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.alterTable('user_settings', table => {
|
||||
table.string('topic').nullable().after('origin');
|
||||
table.string('settings_key_hash', 64).notNullable();
|
||||
table.dropUnique([], 'user_settings_unique_idx');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable('user_settings', table => {
|
||||
table.unique(['settings_key_hash'], 'user_settings_unique_idx');
|
||||
});
|
||||
|
||||
const rows = await knex('user_settings').select('user', 'channel', 'origin');
|
||||
for (const row of rows) {
|
||||
const rawKey = `${row.user}|${row.channel}|${row.origin}|}`;
|
||||
const hash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||
await knex('user_settings')
|
||||
.where({
|
||||
user: row.user,
|
||||
channel: row.channel,
|
||||
origin: row.origin,
|
||||
topic: row.topic,
|
||||
})
|
||||
.update({ settings_key_hash: hash });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.table('user_settings', table => {
|
||||
table.dropUnique([], 'user_settings_unique_idx');
|
||||
table.dropColumn('settings_key_hash');
|
||||
table.dropColumn('topic');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable('user_settings', table => {
|
||||
table.unique(['user', 'channel', 'origin'], {
|
||||
indexName: 'user_settings_unique_idx',
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -63,14 +63,16 @@
|
||||
|
||||
## Table `user_settings`
|
||||
|
||||
| Column | Type | Nullable | Max Length | Default |
|
||||
| --------- | ------------------- | -------- | ---------- | ------- |
|
||||
| `channel` | `character varying` | false | 255 | - |
|
||||
| `enabled` | `boolean` | false | - | `true` |
|
||||
| `origin` | `character varying` | false | 255 | - |
|
||||
| `user` | `character varying` | false | 255 | - |
|
||||
| Column | Type | Nullable | Max Length | Default |
|
||||
| ------------------- | ------------------- | -------- | ---------- | ------- |
|
||||
| `channel` | `character varying` | false | 255 | - |
|
||||
| `enabled` | `boolean` | false | - | `true` |
|
||||
| `origin` | `character varying` | false | 255 | - |
|
||||
| `settings_key_hash` | `character varying` | false | 64 | - |
|
||||
| `topic` | `character varying` | true | 255 | - |
|
||||
| `user` | `character varying` | false | 255 | - |
|
||||
|
||||
### Indices
|
||||
|
||||
- `user_settings_unique_idx` (`user`, `channel`, `origin`) unique
|
||||
- `user_settings_unique_idx` (`settings_key_hash`) unique
|
||||
- `user_settings_user_idx` (`user`)
|
||||
|
||||
@@ -157,12 +157,19 @@ const notificationSettings: NotificationSettings = {
|
||||
id: 'Web',
|
||||
origins: [
|
||||
{
|
||||
id: 'plugin-test',
|
||||
id: 'abcd-origin',
|
||||
enabled: true,
|
||||
topics: [
|
||||
{
|
||||
id: 'efgh-topic',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'plugin-test2',
|
||||
enabled: false,
|
||||
id: 'plugin-test',
|
||||
enabled: true,
|
||||
topics: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
NotificationSeverity,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { Knex } from 'knex';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-notifications-backend',
|
||||
@@ -105,6 +106,16 @@ export const normalizeSeverity = (input?: string): NotificationSeverity => {
|
||||
return lower;
|
||||
};
|
||||
|
||||
export const generateSettingsHash = (
|
||||
user: string,
|
||||
channel: string,
|
||||
origin: string,
|
||||
topic: string | null,
|
||||
): string => {
|
||||
const rawKey = `${user}|${channel}|${origin}|${topic ?? ''}`;
|
||||
return crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
private readonly isSQLite = false;
|
||||
@@ -169,10 +180,31 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
});
|
||||
chan = acc.channels[acc.channels.length - 1];
|
||||
}
|
||||
chan.origins.push({
|
||||
id: row.origin,
|
||||
enabled: Boolean(row.enabled),
|
||||
});
|
||||
let origin = chan.origins.find(
|
||||
(ori: { id: string }) => ori.id === row.origin,
|
||||
);
|
||||
if (!origin) {
|
||||
origin = {
|
||||
id: row.origin,
|
||||
enabled: true,
|
||||
topics: [],
|
||||
};
|
||||
chan.origins.push(origin);
|
||||
}
|
||||
if (row.topic === null) {
|
||||
origin.enabled = Boolean(row.enabled);
|
||||
} else {
|
||||
let topic = origin.topics.find(
|
||||
(top: { id: string }) => top.id === row.topic,
|
||||
);
|
||||
if (!topic) {
|
||||
topic = {
|
||||
id: row.topic,
|
||||
enabled: Boolean(row.enabled),
|
||||
};
|
||||
origin.topics.push(topic);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ channels: [] },
|
||||
@@ -518,10 +550,26 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
return { origins: rows.map(row => row.origin) };
|
||||
}
|
||||
|
||||
async getUserNotificationTopics(options: {
|
||||
user: string;
|
||||
}): Promise<{ topics: { origin: string; topic: string }[] }> {
|
||||
const rows: { topic: string; origin: string }[] =
|
||||
await this.db<NotificationRowType>('notification')
|
||||
.where('user', options.user)
|
||||
.select('topic', 'origin')
|
||||
.whereNotNull('topic')
|
||||
.distinct();
|
||||
|
||||
return {
|
||||
topics: rows.map(row => ({ origin: row.origin, topic: row.topic })),
|
||||
};
|
||||
}
|
||||
|
||||
async getNotificationSettings(options: {
|
||||
user: string;
|
||||
origin?: string;
|
||||
channel?: string;
|
||||
topic?: string;
|
||||
}): Promise<NotificationSettings> {
|
||||
const settingsQuery = this.db<UserSettingsRowType>('user_settings').where(
|
||||
'user',
|
||||
@@ -534,6 +582,10 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
if (options.channel) {
|
||||
settingsQuery.where('channel', options.channel);
|
||||
}
|
||||
|
||||
if (options.topic) {
|
||||
settingsQuery.where('topic', options.topic);
|
||||
}
|
||||
const settings = await settingsQuery.select();
|
||||
return this.mapToNotificationSettings(settings);
|
||||
}
|
||||
@@ -543,19 +595,45 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
settings: NotificationSettings;
|
||||
}): Promise<void> {
|
||||
const rows: {
|
||||
settings_key_hash: string;
|
||||
user: string;
|
||||
channel: string;
|
||||
origin: string;
|
||||
topic: string | null;
|
||||
enabled: boolean;
|
||||
}[] = [];
|
||||
options.settings.channels.map(channel => {
|
||||
channel.origins.map(origin => {
|
||||
|
||||
options.settings.channels.forEach(channel => {
|
||||
channel.origins.forEach(origin => {
|
||||
rows.push({
|
||||
settings_key_hash: generateSettingsHash(
|
||||
options.user,
|
||||
channel.id,
|
||||
origin.id,
|
||||
null,
|
||||
),
|
||||
user: options.user,
|
||||
channel: channel.id,
|
||||
origin: origin.id,
|
||||
topic: null,
|
||||
enabled: origin.enabled,
|
||||
});
|
||||
|
||||
origin.topics?.forEach(topic => {
|
||||
rows.push({
|
||||
settings_key_hash: generateSettingsHash(
|
||||
options.user,
|
||||
channel.id,
|
||||
origin.id,
|
||||
topic.id,
|
||||
),
|
||||
user: options.user,
|
||||
channel: channel.id,
|
||||
origin: origin.id,
|
||||
topic: topic.id,
|
||||
enabled: origin.enabled && topic.enabled,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -307,9 +307,10 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
expect(notifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not send to user entity if disabled in settings', async () => {
|
||||
it('should not send to user entity if origin is disabled in settings', async () => {
|
||||
const client = await database.getClient();
|
||||
await client('user_settings').insert({
|
||||
settings_key_hash: 'hash',
|
||||
user: 'user:default/mock',
|
||||
channel: 'Web',
|
||||
origin: 'external:test-service',
|
||||
@@ -335,6 +336,85 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not send to user entity if topic is disabled in settings', async () => {
|
||||
const client = await database.getClient();
|
||||
await client('user_settings').insert({
|
||||
settings_key_hash: 'hash',
|
||||
user: 'user:default/mock',
|
||||
channel: 'Web',
|
||||
origin: 'external:test-service',
|
||||
topic: 'test-topic',
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const response = await sendNotification({
|
||||
recipients: {
|
||||
type: 'entity',
|
||||
entityRef: ['user:default/mock'],
|
||||
},
|
||||
payload: {
|
||||
title: 'test notification',
|
||||
topic: 'test-topic',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([]);
|
||||
|
||||
const notifications = await client('notification')
|
||||
.where('user', 'user:default/mock')
|
||||
.select();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should send to user entity if origin is enabled, but topic is disabled in settings', async () => {
|
||||
const client = await database.getClient();
|
||||
await client('user_settings').insert({
|
||||
settings_key_hash: 'hash',
|
||||
user: 'user:default/mock',
|
||||
channel: 'Web',
|
||||
origin: 'external:test-service',
|
||||
enabled: true,
|
||||
});
|
||||
await client('user_settings').insert({
|
||||
settings_key_hash: 'hash1',
|
||||
user: 'user:default/mock',
|
||||
channel: 'Web',
|
||||
origin: 'external:test-service',
|
||||
topic: 'test-topic',
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const response = await sendNotification({
|
||||
recipients: {
|
||||
type: 'entity',
|
||||
entityRef: ['user:default/mock'],
|
||||
},
|
||||
payload: {
|
||||
title: 'test notification',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
created: expect.any(String),
|
||||
id: expect.any(String),
|
||||
origin: 'external:test-service',
|
||||
payload: {
|
||||
severity: 'normal',
|
||||
title: 'test notification',
|
||||
},
|
||||
user: 'user:default/mock',
|
||||
},
|
||||
]);
|
||||
|
||||
const notifications = await client('notification')
|
||||
.where('user', 'user:default/mock')
|
||||
.select();
|
||||
expect(notifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should fail without recipients', async () => {
|
||||
const response = await sendNotification({
|
||||
payload: {
|
||||
@@ -490,6 +570,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
it('should return user settings', async () => {
|
||||
const client = await database.getClient();
|
||||
await client('user_settings').insert({
|
||||
settings_key_hash: 'hash',
|
||||
user: 'user:default/mock',
|
||||
channel: 'Web',
|
||||
origin: 'external:test-service',
|
||||
@@ -502,7 +583,9 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
channels: [
|
||||
{
|
||||
id: 'Web',
|
||||
origins: [{ enabled: false, id: 'external:test-service' }],
|
||||
origins: [
|
||||
{ enabled: false, id: 'external:test-service', topics: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { SignalsService } from '@backstage/plugin-signals-node';
|
||||
import {
|
||||
ChannelSetting,
|
||||
isNotificationsEnabledFor,
|
||||
NewNotificationSignal,
|
||||
Notification,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
NotificationSettings,
|
||||
notificationSeverities,
|
||||
NotificationStatus,
|
||||
OriginSetting,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams';
|
||||
import { getUsersForEntityRef } from './getUsersForEntityRef';
|
||||
@@ -104,40 +106,86 @@ export async function createRouter(
|
||||
return info.userEntityRef;
|
||||
};
|
||||
|
||||
const getTopicSettings = (
|
||||
topic: any,
|
||||
existingOrigin: OriginSetting | undefined,
|
||||
defaultEnabled: boolean,
|
||||
) => {
|
||||
const existingTopic = existingOrigin?.topics?.find(
|
||||
t => t.id === topic.topic,
|
||||
);
|
||||
return {
|
||||
id: topic.topic,
|
||||
enabled: existingTopic ? existingTopic.enabled : defaultEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
const getOriginSettings = (
|
||||
originId: string,
|
||||
existingChannel: ChannelSetting | undefined,
|
||||
topics: { origin: string; topic: string }[],
|
||||
) => {
|
||||
const existingOrigin = existingChannel?.origins.find(
|
||||
o => o.id === originId,
|
||||
);
|
||||
const defaultEnabled = existingOrigin ? existingOrigin.enabled : true;
|
||||
return {
|
||||
id: originId,
|
||||
enabled: defaultEnabled,
|
||||
topics: topics
|
||||
.filter(t => t.origin === originId)
|
||||
.map(t => getTopicSettings(t, existingOrigin, defaultEnabled)),
|
||||
};
|
||||
};
|
||||
|
||||
const getNotificationChannels = () => {
|
||||
return [WEB_NOTIFICATION_CHANNEL, ...processors.map(p => p.getName())];
|
||||
};
|
||||
|
||||
const getChannelSettings = (
|
||||
channelId: string,
|
||||
settings: NotificationSettings,
|
||||
origins: string[],
|
||||
topics: { origin: string; topic: string }[],
|
||||
) => {
|
||||
const existingChannel = settings.channels.find(c => c.id === channelId);
|
||||
if (existingChannel) {
|
||||
return existingChannel;
|
||||
}
|
||||
return {
|
||||
id: channelId,
|
||||
origins: origins.map(originId =>
|
||||
getOriginSettings(originId, existingChannel, topics),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getNotificationSettings = async (user: string) => {
|
||||
const { origins } = await store.getUserNotificationOrigins({ user });
|
||||
const { topics } = await store.getUserNotificationTopics({ 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 {
|
||||
channels: channels.map(channelId =>
|
||||
getChannelSettings(channelId, settings, origins, topics),
|
||||
),
|
||||
};
|
||||
return response;
|
||||
};
|
||||
|
||||
const isNotificationsEnabled = async (opts: {
|
||||
user: string;
|
||||
channel: string;
|
||||
origin: string;
|
||||
topic: string | null;
|
||||
}) => {
|
||||
const settings = await getNotificationSettings(opts.user);
|
||||
return isNotificationsEnabledFor(settings, opts.channel, opts.origin);
|
||||
return isNotificationsEnabledFor(
|
||||
settings,
|
||||
opts.channel,
|
||||
opts.origin,
|
||||
opts.topic,
|
||||
);
|
||||
};
|
||||
|
||||
const filterProcessors = async (
|
||||
@@ -154,6 +202,7 @@ export async function createRouter(
|
||||
user,
|
||||
origin,
|
||||
channel: processor.getName(),
|
||||
topic: payload.topic ?? null,
|
||||
});
|
||||
if (!enabled) {
|
||||
continue;
|
||||
@@ -508,6 +557,7 @@ export async function createRouter(
|
||||
user,
|
||||
channel: WEB_NOTIFICATION_CHANNEL,
|
||||
origin: userNotification.origin,
|
||||
topic: userNotification.payload.topic ?? null,
|
||||
});
|
||||
|
||||
let ret = notification;
|
||||
|
||||
Reference in New Issue
Block a user