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 <heikki.hellgren@op.fi>
This commit is contained in:
Hellgren Heikki
2025-08-05 13:24:21 +03:00
parent a59be35d43
commit 7e7ed57de2
6 changed files with 163 additions and 5 deletions
+9
View File
@@ -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.
@@ -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<NotificationProcessor>();
#recipientResolver: NotificationRecipientResolver | undefined = undefined;
addProcessor(
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
@@ -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({
@@ -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(),
@@ -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<Notification[]> => {
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);
}
+10
View File
@@ -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<string[]>;
// @public (undocumented)
export type NotificationRecipients =
| {
@@ -83,6 +89,10 @@ export interface NotificationsProcessingExtensionPoint {
addProcessor(
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
): void;
// (undocumented)
setNotificationRecipientResolver(
resolver: NotificationRecipientResolver,
): void;
}
// @public (undocumented)
@@ -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<string[]>;
/**
* @public
*/
@@ -107,6 +120,9 @@ export interface NotificationsProcessingExtensionPoint {
addProcessor(
...processors: Array<NotificationProcessor | Array<NotificationProcessor>>
): void;
setNotificationRecipientResolver(
resolver: NotificationRecipientResolver,
): void;
}
/**