Merge pull request #32576 from drodil/included_topics

feat(notifications): add support for included topics
This commit is contained in:
Fredrik Adelöw
2026-02-16 22:40:13 +01:00
committed by GitHub
7 changed files with 150 additions and 9 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-notifications-backend-module-email': patch
'@backstage/plugin-notifications-backend': patch
'@backstage/plugin-notifications-common': patch
---
Allow configuring included topics for email notifications.
+6 -1
View File
@@ -161,9 +161,14 @@ export interface Config {
*/
maxSeverity?: NotificationSeverity;
/**
* A notification who's topic is in this array will not be emailed
* A notification with topic is in this array will not be emailed
*/
excludedTopics?: string[];
/**
* A notification with topic in this array will be emailed. If not defined, only
* excludedTopics takes effect.
*/
includedTopics?: string[];
};
/**
* White list of addresses to send email to
@@ -26,6 +26,7 @@ import {
TestDatabases,
} from '@backstage/backend-test-utils';
import {
NotificationProcessor,
NotificationRecipientResolver,
NotificationSendOptions,
} from '@backstage/plugin-notifications-node';
@@ -827,6 +828,118 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
});
});
describe('POST /notifications with custom processor', () => {
const httpAuth = mockServices.httpAuth({
defaultCredentials: mockCredentials.service(),
});
const customProcessor: NotificationProcessor = {
getName: () => 'customProcessor',
processOptions: jest.fn(),
preProcess: jest.fn(),
postProcess: jest.fn(),
getNotificationFilters: jest.fn(),
};
beforeEach(async () => {
jest.resetAllMocks();
const client = await database.getClient();
await client('notification').del();
await client('broadcast').del();
await client('user_settings').del();
(customProcessor.processOptions as jest.Mock).mockImplementation(
opts => opts,
);
(customProcessor.preProcess as jest.Mock).mockImplementation(
(notification, _options) => notification,
);
const router = await createRouter({
logger: mockServices.logger.mock(),
store,
signals: signalService,
userInfo,
config,
httpAuth,
auth,
catalog,
processors: [customProcessor],
});
app = express().use(router).use(mockErrorHandler());
});
const sendNotification = async (data: NotificationSendOptions) =>
request(app)
.post('/notifications')
.send(data)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
it('should not call processor preProcess if topic is excluded', async () => {
(customProcessor.getNotificationFilters as jest.Mock).mockReturnValue({
excludedTopics: ['topic1'],
});
// Should be processed
await sendNotification({
recipients: {
type: 'broadcast',
},
payload: {
title: 'test notification',
topic: 'topic2',
},
});
// Excluded, should not be processed
await sendNotification({
recipients: {
type: 'broadcast',
},
payload: {
title: 'test notification',
topic: 'topic1',
},
});
expect(customProcessor.preProcess).toHaveBeenCalledTimes(1);
});
it('should not call processor preProcess if topic is not included', async () => {
(customProcessor.getNotificationFilters as jest.Mock).mockReturnValue({
includedTopics: ['topic1'],
});
// Should not be processed, not included topic
await sendNotification({
recipients: {
type: 'broadcast',
},
payload: {
title: 'test notification',
topic: 'topic2',
},
});
// Should not be processed, no topic
await sendNotification({
recipients: {
type: 'broadcast',
},
payload: {
title: 'test notification',
},
});
// Included, should be processed
await sendNotification({
recipients: {
type: 'broadcast',
},
payload: {
title: 'test notification',
topic: 'topic1',
},
});
expect(customProcessor.preProcess).toHaveBeenCalledTimes(1);
});
});
describe('GET /', () => {
const httpAuth = mockServices.httpAuth({
defaultCredentials: mockCredentials.user(),
@@ -365,6 +365,15 @@ export async function createRouter(
continue;
}
}
if (filters.includedTopics) {
if (
!payload.topic ||
!filters.includedTopics.includes(payload.topic)
) {
continue;
}
}
}
result.push(processor);
}
@@ -418,14 +427,16 @@ export async function createRouter(
) => {
const filtered = await filterProcessors(notification);
for (const processor of filtered) {
if (processor.postProcess) {
try {
await processor.postProcess(notification, opts);
} catch (e) {
logger.error(
`Error while post processing notification with ${processor.getName()}: ${e}`,
);
}
if (!processor.postProcess) {
continue;
}
try {
await processor.postProcess(notification, opts);
} catch (e) {
logger.error(
`Error while post processing notification with ${processor.getName()}: ${e}`,
);
}
}
};
@@ -64,6 +64,7 @@ export type NotificationProcessorFilters = {
minSeverity?: NotificationSeverity;
maxSeverity?: NotificationSeverity;
excludedTopics?: string[];
includedTopics?: string[];
};
// @public (undocumented)
@@ -43,5 +43,8 @@ export const getProcessorFiltersFromConfig = (config: Config) => {
filter.excludedTopics = config.getOptionalStringArray(
'filter.excludedTopics',
);
filter.includedTopics = config.getOptionalStringArray(
'filter.includedTopics',
);
return filter;
};
@@ -130,6 +130,7 @@ export type NotificationProcessorFilters = {
minSeverity?: NotificationSeverity;
maxSeverity?: NotificationSeverity;
excludedTopics?: string[];
includedTopics?: string[];
};
/**