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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user