feat: support pagination and searching in notification backend

disabled broadcast notifications for now

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-02-01 21:19:45 +02:00
parent 50c7dbde2f
commit b3c3672b64
13 changed files with 124 additions and 56 deletions
@@ -21,17 +21,6 @@ import { PluginEnvironment } from '../types';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
// TODO: Remove this test code
setInterval(() => {
env.notificationService.send({
receivers: { type: 'broadcast' },
title: 'Test',
description: 'Test',
link: '/catalog',
topic: 'topic',
});
}, 60000);
return await createRouter({
logger: env.logger,
identity: env.identity,
@@ -72,6 +72,29 @@ export class DatabaseNotificationsStore implements NotificationsStore {
query.where('saved', true);
}
if (options.limit) {
query.limit(options.limit);
}
if (options.offset) {
query.offset(options.offset);
}
if (options.search) {
if (this.db.client.config.client === 'pg') {
query.whereRaw(
`(to_tsvector('english', notifications.title || ' ' || notifications.description) @@ websearch_to_tsquery('english', quote_literal(?))
or to_tsvector('english', notifications.title || ' ' || notifications.description) @@ to_tsquery('english',quote_literal(?)))`,
[`${options.search}`, `${options.search.replaceAll(/\s/g, '+')}:*`],
);
} else {
query.whereRaw(
`LOWER(notifications.title || ' ' || notifications.description) LIKE LOWER(?)`,
[`%${options.search}%`],
);
}
}
if ('ids' in options && options.ids) {
query.whereIn('id', options.ids);
}
@@ -24,6 +24,9 @@ import {
export type NotificationGetOptions = {
user_ref: string;
type?: NotificationType;
offset?: number;
limit?: number;
search?: string;
};
/** @public */
@@ -41,7 +41,10 @@ import { NotificationProcessor } from '../types';
import { AuthenticationError } from '@backstage/errors';
import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api';
import { SignalService } from '@backstage/plugin-signals-node';
import { Notification } from '@backstage/plugin-notifications-common';
import {
Notification,
NotificationType,
} from '@backstage/plugin-notifications-common';
/** @public */
export interface RouterOptions {
@@ -94,13 +97,9 @@ export async function createRouter(
): Promise<string[]> => {
const { token } = await tokenManager.getToken();
// Broadcast
// TODO: Support for broadcast
if (entityRef === null) {
const users = await catalogClient.getEntities({
filter: { kind: 'User' },
fields: ['kind', 'metadata.name', 'metadata.namespace'],
});
return users.items.map(stringifyEntityRef);
return [];
}
const refs = Array.isArray(entityRef) ? entityRef : [entityRef];
@@ -119,12 +118,23 @@ export async function createRouter(
if (isUserEntity(entity)) {
return [stringifyEntityRef(entity)];
} else if (isGroupEntity(entity) && entity.relations) {
return entity.relations
const users = entity.relations
.filter(
relation =>
relation.type === RELATION_HAS_MEMBER && relation.targetRef,
)
.map(r => r.targetRef);
const childGroups = await catalogClient.getEntitiesByRefs(
{
entityRefs: entity.spec.children,
fields: ['kind', 'metadata.name', 'metadata.namespace'],
},
{ token },
);
const childGroupUsers = await Promise.all(
childGroups.items.map(mapEntity),
);
return [...users, ...childGroupUsers.flat(2)];
} else if (!isGroupEntity(entity) && entity.spec?.owner) {
const owner = await catalogClient.getEntityByRef(
entity.spec.owner as string,
@@ -162,6 +172,7 @@ export async function createRouter(
}
};
// TODO: Move to use OpenAPI router instead
const router = Router();
router.use(express.json());
@@ -176,7 +187,16 @@ export async function createRouter(
user_ref: user,
};
if (req.query.type) {
opts.type = req.query.type as any;
opts.type = req.query.type.toString() as NotificationType;
}
if (req.query.offset) {
opts.offset = Number.parseInt(req.query.offset.toString(), 10);
}
if (req.query.limit) {
opts.limit = Number.parseInt(req.query.limit.toString(), 10);
}
if (req.query.search) {
opts.search = req.query.search.toString();
}
const notifications = await store.getNotifications(opts);
@@ -201,7 +221,7 @@ export async function createRouter(
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'refresh' },
message: { action: 'done', notification_ids: ids },
channel: 'notifications',
});
}
@@ -219,7 +239,7 @@ export async function createRouter(
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'refresh' },
message: { action: 'undone', notification_ids: ids },
channel: 'notifications',
});
}
@@ -238,7 +258,7 @@ export async function createRouter(
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'refresh' },
message: { action: 'mark_read', notification_ids: ids },
channel: 'notifications',
});
}
@@ -256,7 +276,7 @@ export async function createRouter(
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'refresh' },
message: { action: 'mark_unread', notification_ids: ids },
channel: 'notifications',
});
}
@@ -354,7 +374,10 @@ export async function createRouter(
if (signalService) {
await signalService.publish({
recipients: entityRef === null ? null : users,
message: { action: 'refresh', title, description, link },
message: {
action: 'new_notification',
notification: { title, description, link },
},
channel: 'notifications',
});
}
+4 -8
View File
@@ -23,14 +23,10 @@ export class DefaultNotificationService implements NotificationService {
}
// @public (undocumented)
export type NotificationReceivers =
| {
type: 'entity';
entityRef: string | string[];
}
| {
type: 'broadcast';
};
export type NotificationReceivers = {
type: 'entity';
entityRef: string | string[];
};
// @public (undocumented)
export type NotificationSendOptions = {
@@ -28,9 +28,13 @@ export type NotificationServiceOptions = {
};
/** @public */
export type NotificationReceivers =
| { type: 'entity'; entityRef: string | string[] }
| { type: 'broadcast' };
export type NotificationReceivers = {
type: 'entity';
entityRef: string | string[];
};
// TODO: Support for broadcast messages
// | { type: 'broadcast' };
/** @public */
export type NotificationSendOptions = {
+3
View File
@@ -20,6 +20,9 @@ import { RouteRef } from '@backstage/core-plugin-api';
// @public (undocumented)
export type GetNotificationsOptions = {
type?: NotificationType;
offset?: number;
limit?: number;
search?: string;
};
// @public (undocumented)
+1
View File
@@ -29,6 +29,7 @@
"@backstage/plugin-notifications-common": "workspace:^",
"@backstage/plugin-signals-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/types": "workspace:^",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.61",
@@ -29,6 +29,9 @@ export const notificationsApiRef = createApiRef<NotificationsApi>({
/** @public */
export type GetNotificationsOptions = {
type?: NotificationType;
offset?: number;
limit?: number;
search?: string;
};
/** @public */
@@ -42,6 +42,15 @@ export class NotificationsClient implements NotificationsApi {
if (options?.type) {
queryString.append('type', options.type);
}
if (options?.limit) {
queryString.append('limit', options.limit.toString(10));
}
if (options?.offset) {
queryString.append('offset', options.offset.toString(10));
}
if (options?.search) {
queryString.append('search', options.search);
}
const urlSegment = `notifications?${queryString}`;
@@ -54,7 +54,7 @@ export const NotificationsPage = () => {
const { lastSignal } = useSignal('notifications');
useEffect(() => {
if (lastSignal && lastSignal.action === 'refresh') {
if (lastSignal && lastSignal.action) {
setRefresh(true);
}
}, [lastSignal]);
@@ -68,7 +68,6 @@ export const NotificationsPage = () => {
return <ErrorPanel error={new Error('Failed to load notifications')} />;
}
// TODO: Add signals listener and refresh data on message
return (
<PageWithHeader title="Notifications" themeId="tool">
<Content>
@@ -22,6 +22,7 @@ import { rootRouteRef } from '../../routes';
import { useSignal } from '@backstage/plugin-signals-react';
import { useWebNotifications } from '../../hooks/useWebNotifications';
import { useTitleCounter } from '../../hooks/useTitleCounter';
import { JsonObject } from '@backstage/types';
/** @public */
export const NotificationsSidebarItem = () => {
@@ -31,6 +32,8 @@ export const NotificationsSidebarItem = () => {
const config = useApi(configApiRef);
const [unreadCount, setUnreadCount] = React.useState(0);
const notificationsRoute = useRouteRef(rootRouteRef);
// TODO: Add signal type support to `useSignal` to make it a bit easier to use
// TODO: Do we want to add long polling in case signals are not available
const { lastSignal } = useSignal('notifications');
const { sendWebNotification } = useWebNotifications();
const [refresh, setRefresh] = React.useState(false);
@@ -54,25 +57,36 @@ export const NotificationsSidebarItem = () => {
}, [refresh, retry]);
useEffect(() => {
if (lastSignal && lastSignal.action === 'refresh') {
if (
webNotificationsEnabled &&
'title' in lastSignal &&
'description' in lastSignal &&
'link' in lastSignal
) {
const notification = sendWebNotification({
title: lastSignal.title as string,
description: lastSignal.description as string,
});
if (notification) {
notification.onclick = event => {
event.preventDefault();
notification.close();
window.open(lastSignal.link as string, '_blank');
};
}
const handleWebNotification = (signal: JsonObject) => {
if (!webNotificationsEnabled || !('notification' in signal)) {
return;
}
const notificationData = signal.notification as JsonObject;
if (
!notificationData ||
!('title' in notificationData) ||
!('description' in notificationData) ||
!('title' in notificationData)
) {
return;
}
const notification = sendWebNotification({
title: notificationData.title as string,
description: notificationData.description as string,
});
if (notification) {
notification.onclick = event => {
event.preventDefault();
notification.close();
window.open(notificationData.link as string, '_blank');
};
}
};
if (lastSignal && lastSignal.action) {
handleWebNotification(lastSignal);
setRefresh(true);
}
}, [lastSignal, sendWebNotification, webNotificationsEnabled]);
+1
View File
@@ -7848,6 +7848,7 @@ __metadata:
"@backstage/plugin-signals-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@material-ui/core": ^4.9.13
"@material-ui/icons": ^4.9.1
"@material-ui/lab": ^4.0.0-alpha.61