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
@@ -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);
}