feat(catalog): change extension point to inteface
ignore other than user entity references instead error Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
@@ -3,7 +3,10 @@
|
||||
'@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
|
||||
A new extension point was added that can be used to modify how the users receiving notifications
|
||||
are resolved. The interface 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 inputs are lists
|
||||
of entity references that can be any entity kind, not just user entities.
|
||||
|
||||
Using this extension point will override the default behavior of resolving users with the
|
||||
`DefaultNotificationRecipientResolver`.
|
||||
|
||||
+80
-31
@@ -15,33 +15,28 @@
|
||||
*/
|
||||
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { getUsersForEntityRef } from './getUsersForEntityRef';
|
||||
import {
|
||||
RELATION_HAS_MEMBER,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PARENT_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
|
||||
import { DefaultNotificationRecipientResolver } from './DefaultNotificationRecipientResolver.ts';
|
||||
|
||||
describe('getUsersForEntityRef', () => {
|
||||
it('should return empty array if entityRef is null', async () => {
|
||||
await expect(
|
||||
getUsersForEntityRef(null, [], {
|
||||
auth: mockServices.auth(),
|
||||
catalog: catalogServiceMock(),
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should resolve users without calling catalog', async () => {
|
||||
const catalog = catalogServiceMock();
|
||||
jest.spyOn(catalog, 'getEntitiesByRefs');
|
||||
const resolver = new DefaultNotificationRecipientResolver(
|
||||
mockServices.auth(),
|
||||
catalog,
|
||||
);
|
||||
await expect(
|
||||
getUsersForEntityRef(['user:foo', 'user:ignored'], ['user:ignored'], {
|
||||
auth: mockServices.auth(),
|
||||
catalog,
|
||||
resolver.resolveNotificationRecipients({
|
||||
entityRefs: ['user:foo', 'user:ignored'],
|
||||
excludeEntityRefs: ['user:ignored'],
|
||||
}),
|
||||
).resolves.toEqual(['user:foo']);
|
||||
).resolves.toEqual({ userEntityRefs: ['user:foo'] });
|
||||
expect(catalog.getEntitiesByRefs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -85,16 +80,18 @@ describe('getUsersForEntityRef', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const resolver = new DefaultNotificationRecipientResolver(
|
||||
mockServices.auth(),
|
||||
catalog,
|
||||
);
|
||||
await expect(
|
||||
getUsersForEntityRef(
|
||||
'group:default/parent_group',
|
||||
['user:default/ignored'],
|
||||
{
|
||||
auth: mockServices.auth(),
|
||||
catalog,
|
||||
},
|
||||
),
|
||||
).resolves.toEqual(['user:default/foo', 'user:default/bar']);
|
||||
resolver.resolveNotificationRecipients({
|
||||
entityRefs: ['group:default/parent_group'],
|
||||
excludeEntityRefs: ['user:default/ignored'],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
userEntityRefs: ['user:default/foo', 'user:default/bar'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve user owner of entity from entity ref', async () => {
|
||||
@@ -116,12 +113,16 @@ describe('getUsersForEntityRef', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const resolver = new DefaultNotificationRecipientResolver(
|
||||
mockServices.auth(),
|
||||
catalog,
|
||||
);
|
||||
await expect(
|
||||
getUsersForEntityRef('component:default/test_component', [], {
|
||||
auth: mockServices.auth(),
|
||||
catalog,
|
||||
resolver.resolveNotificationRecipients({
|
||||
entityRefs: ['component:default/test_component'],
|
||||
excludeEntityRefs: [],
|
||||
}),
|
||||
).resolves.toEqual(['user:default/foo']);
|
||||
).resolves.toEqual({ userEntityRefs: ['user:default/foo'] });
|
||||
});
|
||||
|
||||
it('should resolve group owner of entity from entity ref', async () => {
|
||||
@@ -156,11 +157,59 @@ describe('getUsersForEntityRef', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const resolver = new DefaultNotificationRecipientResolver(
|
||||
mockServices.auth(),
|
||||
catalog,
|
||||
);
|
||||
await expect(
|
||||
getUsersForEntityRef('component:default/test_component', [], {
|
||||
auth: mockServices.auth(),
|
||||
catalog,
|
||||
resolver.resolveNotificationRecipients({
|
||||
entityRefs: ['component:default/test_component'],
|
||||
excludeEntityRefs: [],
|
||||
}),
|
||||
).resolves.toEqual(['user:default/foo']);
|
||||
).resolves.toEqual({ userEntityRefs: ['user:default/foo'] });
|
||||
});
|
||||
|
||||
it('should filter excluded refs after resolving', async () => {
|
||||
const catalog = catalogServiceMock({
|
||||
entities: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test_component',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
targetRef: 'group:default/owner_group',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'owner_group',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_HAS_MEMBER,
|
||||
targetRef: 'user:default/foo',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const resolver = new DefaultNotificationRecipientResolver(
|
||||
mockServices.auth(),
|
||||
catalog,
|
||||
);
|
||||
await expect(
|
||||
resolver.resolveNotificationRecipients({
|
||||
entityRefs: ['component:default/test_component'],
|
||||
excludeEntityRefs: ['user:default/foo'],
|
||||
}),
|
||||
).resolves.toEqual({ userEntityRefs: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
isGroupEntity,
|
||||
isUserEntity,
|
||||
parseEntityRef,
|
||||
RELATION_HAS_MEMBER,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PARENT_OF,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { CatalogService } from '@backstage/plugin-catalog-node';
|
||||
import { NotificationRecipientResolver } from '@backstage/plugin-notifications-node';
|
||||
|
||||
const isUserEntityRef = (ref: string) =>
|
||||
parseEntityRef(ref).kind.toLocaleLowerCase() === 'user';
|
||||
|
||||
// Partitions array of entity references to two arrays; user entity refs and other entity refs
|
||||
const partitionEntityRefs = (refs: string[]): string[][] => {
|
||||
const ret = [[], []] as string[][];
|
||||
for (const ref of refs) {
|
||||
if (isUserEntityRef(ref)) {
|
||||
ret[0].push(ref);
|
||||
} else {
|
||||
ret[1].push(ref);
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
|
||||
export class DefaultNotificationRecipientResolver
|
||||
implements NotificationRecipientResolver
|
||||
{
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly catalog: CatalogService,
|
||||
) {}
|
||||
|
||||
async resolveNotificationRecipients(options: {
|
||||
entityRefs: string[];
|
||||
excludeEntityRefs?: string[];
|
||||
}): Promise<{ userEntityRefs: string[] }> {
|
||||
const { entityRefs, excludeEntityRefs = [] } = options;
|
||||
|
||||
const [userEntityRefs, otherEntityRefs] = partitionEntityRefs(entityRefs);
|
||||
const users: string[] = userEntityRefs.filter(
|
||||
ref => !excludeEntityRefs.includes(ref),
|
||||
);
|
||||
const filtered = otherEntityRefs.filter(
|
||||
ref => !excludeEntityRefs.includes(ref),
|
||||
);
|
||||
|
||||
const fields = ['kind', 'metadata.name', 'metadata.namespace', 'relations'];
|
||||
let entities: Array<Entity | undefined> = [];
|
||||
if (filtered.length > 0) {
|
||||
const fetchedEntities = await this.catalog.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: filtered,
|
||||
fields,
|
||||
},
|
||||
{ credentials: await this.auth.getOwnServiceCredentials() },
|
||||
);
|
||||
entities = fetchedEntities.items;
|
||||
}
|
||||
|
||||
const cachedEntityRefs = new Map<string, string[]>();
|
||||
|
||||
const mapEntity = async (entity: Entity | undefined): Promise<string[]> => {
|
||||
if (!entity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentEntityRef = stringifyEntityRef(entity);
|
||||
if (excludeEntityRefs.includes(currentEntityRef)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (cachedEntityRefs.has(currentEntityRef)) {
|
||||
return cachedEntityRefs.get(currentEntityRef)!;
|
||||
}
|
||||
|
||||
if (isUserEntity(entity)) {
|
||||
return [currentEntityRef];
|
||||
}
|
||||
|
||||
if (isGroupEntity(entity)) {
|
||||
if (!entity.relations?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupUsers = entity.relations
|
||||
.filter(
|
||||
relation =>
|
||||
relation.type === RELATION_HAS_MEMBER &&
|
||||
isUserEntityRef(relation.targetRef),
|
||||
)
|
||||
.map(r => r.targetRef);
|
||||
|
||||
const childGroupRefs = entity.relations
|
||||
.filter(relation => relation.type === RELATION_PARENT_OF)
|
||||
.map(r => r.targetRef);
|
||||
|
||||
let childGroupUsers: string[][] = [];
|
||||
if (childGroupRefs.length > 0) {
|
||||
const childGroups = await this.catalog.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: childGroupRefs,
|
||||
fields,
|
||||
},
|
||||
{ credentials: await this.auth.getOwnServiceCredentials() },
|
||||
);
|
||||
childGroupUsers = await Promise.all(childGroups.items.map(mapEntity));
|
||||
}
|
||||
|
||||
const ret = [
|
||||
...new Set([...groupUsers, ...childGroupUsers.flat(2)]),
|
||||
].filter(ref => !excludeEntityRefs.includes(ref));
|
||||
cachedEntityRefs.set(currentEntityRef, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (entity.relations?.length) {
|
||||
const ownerRef = entity.relations.find(
|
||||
relation => relation.type === RELATION_OWNED_BY,
|
||||
)?.targetRef;
|
||||
|
||||
if (!ownerRef) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isUserEntityRef(ownerRef)) {
|
||||
if (excludeEntityRefs.includes(ownerRef)) {
|
||||
return [];
|
||||
}
|
||||
return [ownerRef];
|
||||
}
|
||||
|
||||
const owner = await this.catalog.getEntityByRef(ownerRef, {
|
||||
credentials: await this.auth.getOwnServiceCredentials(),
|
||||
});
|
||||
const ret = await mapEntity(owner);
|
||||
cachedEntityRefs.set(currentEntityRef, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
for (const entity of entities) {
|
||||
const u = await mapEntity(entity);
|
||||
users.push(...u);
|
||||
}
|
||||
|
||||
return {
|
||||
userEntityRefs: [...new Set(users)]
|
||||
.filter(Boolean)
|
||||
// Need to filter again after resolving users
|
||||
.filter(ref => !excludeEntityRefs.includes(ref)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
isGroupEntity,
|
||||
isUserEntity,
|
||||
parseEntityRef,
|
||||
RELATION_HAS_MEMBER,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PARENT_OF,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { CatalogService } from '@backstage/plugin-catalog-node';
|
||||
|
||||
const isUserEntityRef = (ref: string) =>
|
||||
parseEntityRef(ref).kind.toLocaleLowerCase() === 'user';
|
||||
|
||||
// Partitions array of entity references to two arrays; user entity refs and other entity refs
|
||||
const partitionEntityRefs = (refs: string[]): string[][] =>
|
||||
refs.reduce(
|
||||
([userEntityRefs, otherEntityRefs]: string[][], ref: string) => {
|
||||
return isUserEntityRef(ref)
|
||||
? [[...userEntityRefs, ref], otherEntityRefs]
|
||||
: [userEntityRefs, [...otherEntityRefs, ref]];
|
||||
},
|
||||
[[], []],
|
||||
);
|
||||
|
||||
export const getUsersForEntityRef = async (
|
||||
entityRef: string | string[] | null,
|
||||
excludeEntityRefs: string | string[],
|
||||
options: {
|
||||
auth: AuthService;
|
||||
catalog: CatalogService;
|
||||
},
|
||||
): Promise<string[]> => {
|
||||
const { auth, catalog } = options;
|
||||
|
||||
if (entityRef === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const excluded = Array.isArray(excludeEntityRefs)
|
||||
? excludeEntityRefs
|
||||
: [excludeEntityRefs];
|
||||
|
||||
const refsArr = Array.isArray(entityRef) ? entityRef : [entityRef];
|
||||
const [userEntityRefs, otherEntityRefs] = partitionEntityRefs(refsArr);
|
||||
const users: string[] = userEntityRefs.filter(ref => !excluded.includes(ref));
|
||||
const entityRefs = otherEntityRefs.filter(ref => !excluded.includes(ref));
|
||||
|
||||
const fields = ['kind', 'metadata.name', 'metadata.namespace', 'relations'];
|
||||
let entities: Array<Entity | undefined> = [];
|
||||
if (entityRefs.length > 0) {
|
||||
const fetchedEntities = await catalog.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs,
|
||||
fields,
|
||||
},
|
||||
{ credentials: await auth.getOwnServiceCredentials() },
|
||||
);
|
||||
entities = fetchedEntities.items;
|
||||
}
|
||||
|
||||
const mapEntity = async (entity: Entity | undefined): Promise<string[]> => {
|
||||
if (!entity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentEntityRef = stringifyEntityRef(entity);
|
||||
if (excluded.includes(currentEntityRef)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isUserEntity(entity)) {
|
||||
return [currentEntityRef];
|
||||
}
|
||||
|
||||
if (isGroupEntity(entity)) {
|
||||
if (!entity.relations?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupUsers = entity.relations
|
||||
.filter(
|
||||
relation =>
|
||||
relation.type === RELATION_HAS_MEMBER &&
|
||||
isUserEntityRef(relation.targetRef),
|
||||
)
|
||||
.map(r => r.targetRef);
|
||||
|
||||
const childGroupRefs = entity.relations
|
||||
.filter(relation => relation.type === RELATION_PARENT_OF)
|
||||
.map(r => r.targetRef);
|
||||
|
||||
let childGroupUsers: string[][] = [];
|
||||
if (childGroupRefs.length > 0) {
|
||||
const childGroups = await catalog.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: childGroupRefs,
|
||||
fields,
|
||||
},
|
||||
{ credentials: await auth.getOwnServiceCredentials() },
|
||||
);
|
||||
childGroupUsers = await Promise.all(childGroups.items.map(mapEntity));
|
||||
}
|
||||
|
||||
return [...groupUsers, ...childGroupUsers.flat(2)].filter(
|
||||
ref => !excluded.includes(ref),
|
||||
);
|
||||
}
|
||||
|
||||
if (entity.relations?.length) {
|
||||
const ownerRef = entity.relations.find(
|
||||
relation => relation.type === RELATION_OWNED_BY,
|
||||
)?.targetRef;
|
||||
|
||||
if (!ownerRef) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isUserEntityRef(ownerRef)) {
|
||||
if (excluded.includes(ownerRef)) {
|
||||
return [];
|
||||
}
|
||||
return [ownerRef];
|
||||
}
|
||||
|
||||
const owner = await catalog.getEntityByRef(ownerRef, {
|
||||
credentials: await auth.getOwnServiceCredentials(),
|
||||
});
|
||||
return mapEntity(owner);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
for (const entity of entities) {
|
||||
const u = await mapEntity(entity);
|
||||
users.push(...u);
|
||||
}
|
||||
|
||||
return [...new Set(users)].filter(Boolean);
|
||||
};
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
TestDatabaseId,
|
||||
TestDatabases,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { NotificationSendOptions } from '@backstage/plugin-notifications-node';
|
||||
import {
|
||||
NotificationRecipientResolver,
|
||||
NotificationSendOptions,
|
||||
} from '@backstage/plugin-notifications-node';
|
||||
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
|
||||
import { DatabaseService } from '@backstage/backend-plugin-api';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -506,7 +509,10 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
defaultCredentials: mockCredentials.service(),
|
||||
});
|
||||
|
||||
const recipientResolver = jest.fn();
|
||||
const resolveFn = jest.fn();
|
||||
const recipientResolver: NotificationRecipientResolver = {
|
||||
resolveNotificationRecipients: resolveFn,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
@@ -539,7 +545,9 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
.set('Accept', 'application/json');
|
||||
|
||||
it('should use custom recipient resolver', async () => {
|
||||
recipientResolver.mockResolvedValue(['user:default/mock']);
|
||||
resolveFn.mockResolvedValue({
|
||||
userEntityRefs: ['user:default/mock'],
|
||||
});
|
||||
const response = await sendNotification({
|
||||
recipients: {
|
||||
type: 'entity',
|
||||
@@ -571,8 +579,10 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
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']);
|
||||
it('should ignore if recipient resolver returns something other than an array of user entity refs', async () => {
|
||||
resolveFn.mockResolvedValue({
|
||||
userEntityRefs: ['system:default/mock'],
|
||||
});
|
||||
const response = await sendNotification({
|
||||
recipients: {
|
||||
type: 'entity',
|
||||
@@ -582,7 +592,8 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
title: 'test notification',
|
||||
},
|
||||
});
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ import {
|
||||
OriginSetting,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams';
|
||||
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';
|
||||
import { DefaultNotificationRecipientResolver } from './DefaultNotificationRecipientResolver.ts';
|
||||
|
||||
/** @internal */
|
||||
export interface RouterOptions {
|
||||
@@ -104,6 +104,10 @@ export async function createRouter(
|
||||
const defaultNotificationSettings: NotificationSettings | undefined =
|
||||
config.getOptional<NotificationSettings>('notifications.defaultSettings');
|
||||
|
||||
const usedRecipientResolver =
|
||||
recipientResolver ??
|
||||
new DefaultNotificationRecipientResolver(auth, catalog);
|
||||
|
||||
const getUser = async (req: Request<unknown>) => {
|
||||
const credentials = await httpAuth.credentials(req, { allow: ['user'] });
|
||||
const info = await userInfo.getUserInfo(credentials);
|
||||
@@ -637,25 +641,25 @@ export async function createRouter(
|
||||
return ret;
|
||||
};
|
||||
|
||||
const filterNonUserEntityRefs = (refs: string[]): string[] => {
|
||||
return refs.filter(ref => {
|
||||
try {
|
||||
const parsed = parseEntityRef(ref);
|
||||
return parsed.kind.toLowerCase() === 'user';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const sendUserNotifications = async (
|
||||
baseNotification: Omit<Notification, 'user' | 'id'>,
|
||||
users: string[],
|
||||
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 uniqueUsers = [...new Set(filterNonUserEntityRefs(users))];
|
||||
const throttled = throttle((user: string) =>
|
||||
sendUserNotification(baseNotification, user, opts, origin, scope),
|
||||
);
|
||||
@@ -676,7 +680,6 @@ export async function createRouter(
|
||||
const { recipients, payload } = opts;
|
||||
const { title, link } = payload;
|
||||
const notifications: Notification[] = [];
|
||||
let users = [];
|
||||
|
||||
if (!recipients || !title) {
|
||||
const missing = [
|
||||
@@ -714,30 +717,26 @@ export async function createRouter(
|
||||
);
|
||||
notifications.push(broadcast);
|
||||
} else if (recipients.type === 'entity') {
|
||||
const entityRef = recipients.entityRef;
|
||||
|
||||
const entityRefs = [recipients.entityRef].flat();
|
||||
const excludedEntityRefs = recipients.excludeEntityRef
|
||||
? [recipients.excludeEntityRef].flat()
|
||||
: undefined;
|
||||
try {
|
||||
users = recipientResolver
|
||||
? await recipientResolver(
|
||||
entityRef,
|
||||
recipients.excludeEntityRef ?? [],
|
||||
)
|
||||
: await getUsersForEntityRef(
|
||||
entityRef,
|
||||
recipients.excludeEntityRef ?? [],
|
||||
{ auth, catalog },
|
||||
);
|
||||
const { userEntityRefs } =
|
||||
await usedRecipientResolver.resolveNotificationRecipients({
|
||||
entityRefs,
|
||||
excludedEntityRefs,
|
||||
});
|
||||
const userNotifications = await sendUserNotifications(
|
||||
baseNotification,
|
||||
userEntityRefs,
|
||||
opts,
|
||||
origin,
|
||||
);
|
||||
notifications.push(...userNotifications);
|
||||
} catch (e) {
|
||||
throw new InputError('Failed to resolve notification receivers', e);
|
||||
throw new InputError('Failed to send user notifications', e);
|
||||
}
|
||||
|
||||
const userNotifications = await sendUserNotifications(
|
||||
baseNotification,
|
||||
users,
|
||||
opts,
|
||||
origin,
|
||||
);
|
||||
notifications.push(...userNotifications);
|
||||
} else {
|
||||
throw new InputError(
|
||||
`Invalid recipients type, please use either 'broadcast' or 'entity'`,
|
||||
|
||||
@@ -42,10 +42,15 @@ export interface NotificationProcessor {
|
||||
export type NotificationProcessorFilters = NotificationProcessorFilters_2;
|
||||
|
||||
// @public
|
||||
export type NotificationRecipientResolver = (
|
||||
entityRef: string | string[] | null,
|
||||
excludeEntityRefs: string | string[],
|
||||
) => Promise<string[]>;
|
||||
export interface NotificationRecipientResolver {
|
||||
// (undocumented)
|
||||
resolveNotificationRecipients(options: {
|
||||
entityRefs: string[];
|
||||
excludedEntityRefs?: string[];
|
||||
}): Promise<{
|
||||
userEntityRefs: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type NotificationRecipients =
|
||||
|
||||
@@ -101,17 +101,22 @@ export interface NotificationProcessor {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* NotificationRecipientResolver interface is used to resolve the individual
|
||||
* users to receive the notification.
|
||||
*
|
||||
* The function should return a list of user entity references that should receive the notification.
|
||||
* The `resolveNotificationRecipients` is used to resolve notifications sent for
|
||||
* entity references, and it should return object with a list of user
|
||||
* entity references that should receive the notification. In case the function
|
||||
* returns other than user entity references, those are ignored.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type NotificationRecipientResolver = (
|
||||
entityRef: string | string[] | null,
|
||||
excludeEntityRefs: string | string[],
|
||||
) => Promise<string[]>;
|
||||
export interface NotificationRecipientResolver {
|
||||
resolveNotificationRecipients(options: {
|
||||
entityRefs: string[];
|
||||
excludedEntityRefs?: string[];
|
||||
}): Promise<{ userEntityRefs: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
|
||||
Reference in New Issue
Block a user