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:
Patrik Oldsberg
2025-06-03 13:24:18 +02:00
committed by GitHub
20 changed files with 719 additions and 108 deletions
@@ -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',
});
});
};
+9 -7
View File
@@ -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;