From 7e7ed57de26e89c410787e523086e0948280dfeb Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Tue, 5 Aug 2025 13:24:21 +0300 Subject: [PATCH] feat(notifications): extension point to modify user resolving this change adds a new extension point method that can be used to pass a custom function that can modify how individual user entity references are resolved inside the notifications backend. Signed-off-by: Hellgren Heikki --- .changeset/twenty-shoes-tickle.md | 9 ++ plugins/notifications-backend/src/plugin.ts | 18 ++++ .../src/service/router.test.ts | 85 +++++++++++++++++++ .../src/service/router.ts | 30 +++++-- plugins/notifications-node/report.api.md | 10 +++ plugins/notifications-node/src/extensions.ts | 16 ++++ 6 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 .changeset/twenty-shoes-tickle.md diff --git a/.changeset/twenty-shoes-tickle.md b/.changeset/twenty-shoes-tickle.md new file mode 100644 index 0000000000..d13d93adde --- /dev/null +++ b/.changeset/twenty-shoes-tickle.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-notifications-backend': patch +'@backstage/plugin-notifications-node': patch +--- + +A new extension point method was added that can be used to modify how the users receiving notifications +are resolved. The function passed to the extension point should only return complete user entity references +based on the notification target references and the excluded entity references. Note that the input is a list +of entity references that can be any entity kind, not just user entities. diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts index 5f430318b5..49b3924a68 100644 --- a/plugins/notifications-backend/src/plugin.ts +++ b/plugins/notifications-backend/src/plugin.ts @@ -22,6 +22,7 @@ import { createRouter } from './service/router'; import { signalsServiceRef } from '@backstage/plugin-signals-node'; import { NotificationProcessor, + NotificationRecipientResolver, notificationsProcessingExtensionPoint, NotificationsProcessingExtensionPoint, } from '@backstage/plugin-notifications-node'; @@ -33,6 +34,7 @@ class NotificationsProcessingExtensionPointImpl implements NotificationsProcessingExtensionPoint { #processors = new Array(); + #recipientResolver: NotificationRecipientResolver | undefined = undefined; addProcessor( ...processors: Array> @@ -43,6 +45,21 @@ class NotificationsProcessingExtensionPointImpl get processors() { return this.#processors; } + + setNotificationRecipientResolver( + resolver: NotificationRecipientResolver, + ): void { + if (this.#recipientResolver) { + throw new Error( + 'Notification recipient resolver is already set. You can only set it once.', + ); + } + this.#recipientResolver = resolver; + } + + get recipientResolver() { + return this.#recipientResolver; + } } /** @@ -98,6 +115,7 @@ export const notificationsPlugin = createBackendPlugin({ catalog, signals, processors: processingExtensions.processors, + recipientResolver: processingExtensions.recipientResolver, }), ); httpRouter.addAuthPolicy({ diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index 980762edbc..c66331c278 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -501,6 +501,91 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { }); }); + describe('POST /notifications with custom receiver resolver', () => { + const httpAuth = mockServices.httpAuth({ + defaultCredentials: mockCredentials.service(), + }); + + const recipientResolver = jest.fn(); + + beforeAll(async () => { + const router = await createRouter({ + logger: mockServices.logger.mock(), + store, + signals: signalService, + userInfo, + config, + httpAuth, + auth, + catalog, + recipientResolver, + }); + app = express().use(router).use(mockErrorHandler()); + }); + + beforeEach(async () => { + jest.resetAllMocks(); + const client = await database.getClient(); + await client('notification').del(); + await client('broadcast').del(); + await client('user_settings').del(); + }); + + const sendNotification = async (data: NotificationSendOptions) => + request(app) + .post('/notifications') + .send(data) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + it('should use custom recipient resolver', async () => { + recipientResolver.mockResolvedValue(['user:default/mock']); + const response = await sendNotification({ + recipients: { + type: 'entity', + entityRef: ['system: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 client = await database.getClient(); + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(1); + }); + + it('should return error if recipient resolver returns something other than an array of user entity refs', async () => { + recipientResolver.mockResolvedValue(['system:default/mock']); + const response = await sendNotification({ + recipients: { + type: 'entity', + entityRef: ['system:default/mock'], + }, + payload: { + title: 'test notification', + }, + }); + expect(response.status).toEqual(400); + }); + }); + describe('GET /', () => { const httpAuth = mockServices.httpAuth({ defaultCredentials: mockCredentials.user(), diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 5805092096..51faf66279 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -26,6 +26,7 @@ import { v4 as uuid } from 'uuid'; import { CatalogService } from '@backstage/plugin-catalog-node'; import { NotificationProcessor, + NotificationRecipientResolver, NotificationSendOptions, } from '@backstage/plugin-notifications-node'; import { InputError, NotFoundError } from '@backstage/errors'; @@ -52,6 +53,7 @@ import { getUsersForEntityRef } from './getUsersForEntityRef'; import { Config, readDurationFromConfig } from '@backstage/config'; import { durationToMilliseconds } from '@backstage/types'; import pThrottle from 'p-throttle'; +import { parseEntityRef } from '@backstage/catalog-model'; /** @internal */ export interface RouterOptions { @@ -64,6 +66,7 @@ export interface RouterOptions { signals?: SignalsService; catalog: CatalogService; processors?: NotificationProcessor[]; + recipientResolver?: NotificationRecipientResolver; } /** @internal */ @@ -80,6 +83,7 @@ export async function createRouter( catalog, processors = [], signals, + recipientResolver, } = options; const WEB_NOTIFICATION_CHANNEL = 'Web'; @@ -639,6 +643,17 @@ export async function createRouter( opts: NotificationSendOptions, origin: string, ): Promise => { + if ( + users.find(u => { + const compound = parseEntityRef(u); + return compound.kind.toLocaleLowerCase('en-US') !== 'user'; + }) + ) { + throw new InputError( + 'Invalid user entity reference provided in recipients', + ); + } + const { scope } = opts.payload; const uniqueUsers = [...new Set(users)]; const throttled = throttle((user: string) => @@ -702,11 +717,16 @@ export async function createRouter( const entityRef = recipients.entityRef; try { - users = await getUsersForEntityRef( - entityRef, - recipients.excludeEntityRef ?? [], - { auth, catalog }, - ); + users = recipientResolver + ? await recipientResolver( + entityRef, + recipients.excludeEntityRef ?? [], + ) + : await getUsersForEntityRef( + entityRef, + recipients.excludeEntityRef ?? [], + { auth, catalog }, + ); } catch (e) { throw new InputError('Failed to resolve notification receivers', e); } diff --git a/plugins/notifications-node/report.api.md b/plugins/notifications-node/report.api.md index 995b98bc21..6b424e0fa5 100644 --- a/plugins/notifications-node/report.api.md +++ b/plugins/notifications-node/report.api.md @@ -41,6 +41,12 @@ export interface NotificationProcessor { // @public @deprecated (undocumented) export type NotificationProcessorFilters = NotificationProcessorFilters_2; +// @public +export type NotificationRecipientResolver = ( + entityRef: string | string[] | null, + excludeEntityRefs: string | string[], +) => Promise; + // @public (undocumented) export type NotificationRecipients = | { @@ -83,6 +89,10 @@ export interface NotificationsProcessingExtensionPoint { addProcessor( ...processors: Array> ): void; + // (undocumented) + setNotificationRecipientResolver( + resolver: NotificationRecipientResolver, + ): void; } // @public (undocumented) diff --git a/plugins/notifications-node/src/extensions.ts b/plugins/notifications-node/src/extensions.ts index 03ce47fea6..1e525a8d2b 100644 --- a/plugins/notifications-node/src/extensions.ts +++ b/plugins/notifications-node/src/extensions.ts @@ -100,6 +100,19 @@ export interface NotificationProcessor { getNotificationFilters?(): NotificationProcessorFilters; } +/** + * NotificationRecipientResolver is a function that resolves the individual users to receive the notification + * based on the entity reference(s) and the excluded entity reference(s). + * + * The function should return a list of user entity references that should receive the notification. + * + * @public + */ +export type NotificationRecipientResolver = ( + entityRef: string | string[] | null, + excludeEntityRefs: string | string[], +) => Promise; + /** * @public */ @@ -107,6 +120,9 @@ export interface NotificationsProcessingExtensionPoint { addProcessor( ...processors: Array> ): void; + setNotificationRecipientResolver( + resolver: NotificationRecipientResolver, + ): void; } /**