feat: follow-up changes on BEP #22641

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-02-02 13:30:34 +02:00
parent b3c3672b64
commit acbe630b9d
16 changed files with 236 additions and 251 deletions
@@ -19,14 +19,17 @@ exports.up = async function up(knex) {
table.uuid('id').primary();
table.string('userRef').notNullable();
table.string('title').notNullable();
table.text('description').notNullable();
table.text('description').nullable();
table.text('severity').notNullable();
table.text('link').notNullable();
table.text('origin').notNullable();
table.text('scope').nullable();
table.text('topic').nullable();
table.datetime('created').defaultTo(knex.fn.now()).notNullable();
table.datetime('updated').nullable();
table.datetime('read').nullable();
table.datetime('done').nullable();
table.boolean('saved').defaultTo(false).notNullable();
table.datetime('saved').nullable();
});
};
@@ -95,7 +95,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
}
}
if ('ids' in options && options.ids) {
if (options.ids) {
query.whereIn('id', options.ids);
}
@@ -131,13 +131,15 @@ export class DatabaseNotificationsStore implements NotificationsStore {
};
}
async getExistingTopicNotification(options: {
async getExistingScopeNotification(options: {
user_ref: string;
topic: string;
scope: string;
origin: string;
}) {
const query = this.db('notifications')
.where('userRef', options.user_ref)
.where('topic', options.topic)
.where('scope', options.scope)
.where('origin', options.origin)
.select('*')
.limit(1);
@@ -156,11 +158,12 @@ export class DatabaseNotificationsStore implements NotificationsStore {
.where('id', options.id)
.where('userRef', options.notification.userRef);
const rows = await query.update({
title: options.notification.title,
description: options.notification.description,
link: options.notification.link,
topic: options.notification.topic,
title: options.notification.payload.title,
description: options.notification.payload.description,
link: options.notification.payload.link,
topic: options.notification.payload.topic,
updated: options.notification.created,
severity: options.notification.payload.severity,
read: null,
done: null,
});
@@ -205,11 +208,11 @@ export class DatabaseNotificationsStore implements NotificationsStore {
async markSaved(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ saved: true });
await notificationQuery.update({ saved: new Date() });
}
async markUnsaved(options: NotificationModifyOptions): Promise<void> {
const notificationQuery = this.getNotificationsBaseQuery(options);
await notificationQuery.update({ saved: false });
await notificationQuery.update({ saved: null });
}
}
@@ -23,6 +23,8 @@ import {
/** @public */
export type NotificationGetOptions = {
user_ref: string;
ids?: string[];
type?: NotificationType;
offset?: number;
limit?: number;
@@ -40,9 +42,10 @@ export interface NotificationsStore {
saveNotification(notification: Notification): Promise<void>;
getExistingTopicNotification(options: {
getExistingScopeNotification(options: {
user_ref: string;
topic: string;
scope: string;
origin: string;
}): Promise<Notification | null>;
restoreExistingNotification(options: {
@@ -209,104 +209,67 @@ export async function createRouter(
res.send(status);
});
router.post('/done', async (req, res) => {
router.post('/update', async (req, res) => {
const user = await getUser(req);
const { ids } = req.body;
const { ids, done, read, saved } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).send();
return;
}
await store.markDone({ user_ref: user, ids });
if (done === true) {
await store.markDone({ user_ref: user, ids });
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'done', notification_ids: ids },
channel: 'notifications',
});
}
} else if (done === false) {
await store.markUndone({ user_ref: user, ids });
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'undone', notification_ids: ids },
channel: 'notifications',
});
}
}
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'done', notification_ids: ids },
channel: 'notifications',
});
}
res.status(200).send({ ids });
});
if (read === true) {
await store.markRead({ user_ref: user, ids });
router.post('/undo', async (req, res) => {
const user = await getUser(req);
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).send();
return;
}
await store.markUndone({ user_ref: user, ids });
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'undone', notification_ids: ids },
channel: 'notifications',
});
}
res.status(200).send({ ids });
});
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'mark_read', notification_ids: ids },
channel: 'notifications',
});
}
} else if (read === false) {
await store.markUnread({ user_ref: user, ids });
router.post('/read', async (req, res) => {
const user = await getUser(req);
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).send();
return;
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'mark_unread', notification_ids: ids },
channel: 'notifications',
});
}
}
await store.markRead({ user_ref: user, ids });
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'mark_read', notification_ids: ids },
channel: 'notifications',
});
if (saved === true) {
await store.markSaved({ user_ref: user, ids });
} else if (saved === false) {
await store.markUnsaved({ user_ref: user, ids });
}
res.status(200).send({ ids });
});
router.post('/unread', async (req, res) => {
const user = await getUser(req);
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).send();
return;
}
await store.markUnread({ user_ref: user, ids });
if (signalService) {
await signalService.publish({
recipients: [user],
message: { action: 'mark_unread', notification_ids: ids },
channel: 'notifications',
});
}
res.status(200).send({ ids });
});
router.post('/save', async (req, res) => {
const user = await getUser(req);
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).send();
return;
}
await store.markSaved({ user_ref: user, ids });
res.status(200).send({ ids });
});
router.post('/unsave', async (req, res) => {
const user = await getUser(req);
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
res.status(400).send();
return;
}
await store.markUnsaved({ user_ref: user, ids });
res.status(200).send({ ids });
const notifications = await store.getNotifications({ ids, user_ref: user });
res.status(200).send(notifications);
});
router.post('/notifications', async (req, res) => {
const { receivers, title, description, link, topic } = req.body;
const { recipients, origin, payload } = req.body;
const notifications = [];
let users = [];
@@ -318,9 +281,17 @@ export async function createRouter(
return;
}
const { title, link, description, scope } = payload;
if (!recipients || !title || !origin || !link) {
logger.error(`Invalid notification request received`);
res.status(400).send();
return;
}
let entityRef = null;
if (receivers.entityRef && receivers.type === 'entity') {
entityRef = receivers.entityRef;
if (recipients.entityRef && recipients.type === 'entity') {
entityRef = recipients.entityRef;
}
try {
@@ -331,13 +302,13 @@ export async function createRouter(
return;
}
const baseNotification = {
title,
description,
link,
topic,
const baseNotification: Omit<Notification, 'id' | 'userRef'> = {
payload: {
...payload,
severity: payload.severity ?? 'normal',
},
origin,
created: new Date(),
saved: false,
};
for (const user of users) {
@@ -349,10 +320,11 @@ export async function createRouter(
const notification = await decorateNotification(userNotification);
let existingNotification;
if (topic) {
existingNotification = await store.getExistingTopicNotification({
if (scope) {
existingNotification = await store.getExistingScopeNotification({
user_ref: user,
topic,
scope,
origin,
});
}
+15 -9
View File
@@ -7,23 +7,29 @@
type Notification_2 = {
id: string;
userRef: string;
title: string;
description: string;
link: string;
topic?: string;
created: Date;
updated?: Date;
saved?: Date;
read?: Date;
done?: Date;
saved: boolean;
updated?: Date;
origin: string;
payload: NotificationPayload;
};
export { Notification_2 as Notification };
// @public (undocumented)
export type NotificationIds = {
ids: string[];
export type NotificationPayload = {
title: string;
description?: string;
link: string;
severity: NotificationSeverity;
topic?: string;
scope?: string;
icon?: string;
};
// @public (undocumented)
export type NotificationSeverity = 'critical' | 'high' | 'normal' | 'low';
// @public (undocumented)
export type NotificationStatus = {
unread: number;
+20 -12
View File
@@ -17,19 +17,32 @@
/** @public */
export type NotificationType = 'undone' | 'done' | 'saved';
/** @public */
export type NotificationSeverity = 'critical' | 'high' | 'normal' | 'low';
/** @public */
export type NotificationPayload = {
title: string;
description?: string;
link: string;
// TODO: Add support for additional links
// additionalLinks?: string[];
severity: NotificationSeverity;
topic?: string;
scope?: string;
icon?: string;
};
/** @public */
export type Notification = {
id: string;
userRef: string;
title: string;
description: string;
link: string;
topic?: string;
created: Date;
updated?: Date;
saved?: Date;
read?: Date;
done?: Date;
saved: boolean;
updated?: Date;
origin: string;
payload: NotificationPayload;
};
/** @public */
@@ -37,8 +50,3 @@ export type NotificationStatus = {
unread: number;
read: number;
};
/** @public */
export type NotificationIds = {
ids: string[];
};
+5 -2
View File
@@ -15,15 +15,18 @@ import { NotificationService } from '@backstage/plugin-notifications-node';
function makeCreateEnv(config: Config) {
// ...
const notificationService = DefaultNotificationService.create({
const defaultNotificationService = DefaultNotificationService.create({
logger: root.child({ type: 'plugin' }),
discovery,
tokenManager,
signalService,
});
// ...
return (plugin: string): PluginEnvironment => {
// ...
const notificationService = defaultNotificationService.forPlugin(plugin);
return {
// ...
notificationService,
@@ -55,4 +58,4 @@ a user, the notification will be sent to only that user. If it's a group, the no
members of the group. If it's some other entity, the notification will be sent to the owner of that entity.
If the notification has `topic` set and user already has notification with that topic, the existing notification
will be updated with the new notification values and moved to inbox as unread.
will be updated with the new notification values and moved to inbox as unread.
+10 -9
View File
@@ -5,7 +5,7 @@
```ts
import { DiscoveryService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Notification as Notification_2 } from '@backstage/plugin-notifications-common';
import { NotificationPayload } from '@backstage/plugin-notifications-common';
import { ServiceRef } from '@backstage/backend-plugin-api';
import { SignalService } from '@backstage/plugin-signals-node';
import { TokenManager } from '@backstage/backend-common';
@@ -19,28 +19,29 @@ export class DefaultNotificationService implements NotificationService {
discovery,
}: NotificationServiceOptions): DefaultNotificationService;
// (undocumented)
send(options: NotificationSendOptions): Promise<Notification_2[]>;
forPlugin(pluginId: string): NotificationService;
// (undocumented)
send(notification: NotificationSendOptions): Promise<void>;
}
// @public (undocumented)
export type NotificationReceivers = {
export type NotificationRecipients = {
type: 'entity';
entityRef: string | string[];
};
// @public (undocumented)
export type NotificationSendOptions = {
receivers: NotificationReceivers;
title: string;
description: string;
link: string;
topic?: string;
recipients: NotificationRecipients;
payload: NotificationPayload;
};
// @public (undocumented)
export interface NotificationService {
// (undocumented)
send(options: NotificationSendOptions): Promise<Notification_2[]>;
forPlugin(pluginId: string): NotificationService;
// (undocumented)
send(options: NotificationSendOptions): Promise<void>;
}
// @public (undocumented)
+5 -3
View File
@@ -30,18 +30,20 @@ export const notificationService = createServiceRef<NotificationService>({
createServiceFactory({
service,
deps: {
logger: coreServices.logger,
logger: coreServices.rootLogger,
discovery: coreServices.discovery,
tokenManager: coreServices.tokenManager,
pluginMetadata: coreServices.pluginMetadata,
signals: signalService,
},
factory({ logger, discovery, tokenManager, signals }) {
factory({ logger, discovery, tokenManager, signals, pluginMetadata }) {
// TODO: Convert to use createRootContext
return DefaultNotificationService.create({
logger,
discovery,
tokenManager,
signalService: signals,
});
}).forPlugin(pluginMetadata.getId());
},
}),
});
@@ -13,11 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Notification } from '@backstage/plugin-notifications-common';
import { TokenManager } from '@backstage/backend-common';
import { NotificationService } from './NotificationService';
import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api';
import { SignalService } from '@backstage/plugin-signals-node';
import { NotificationPayload } from '@backstage/plugin-notifications-common';
/** @public */
export type NotificationServiceOptions = {
@@ -28,7 +28,7 @@ export type NotificationServiceOptions = {
};
/** @public */
export type NotificationReceivers = {
export type NotificationRecipients = {
type: 'entity';
entityRef: string | string[];
};
@@ -38,11 +38,8 @@ export type NotificationReceivers = {
/** @public */
export type NotificationSendOptions = {
receivers: NotificationReceivers;
title: string;
description: string;
link: string;
topic?: string;
recipients: NotificationRecipients;
payload: NotificationPayload;
};
/** @public */
@@ -51,6 +48,7 @@ export class DefaultNotificationService implements NotificationService {
private readonly logger: LoggerService,
private readonly discovery: DiscoveryService,
private readonly tokenManager: TokenManager,
private readonly pluginId?: string,
) {}
static create({
@@ -61,22 +59,36 @@ export class DefaultNotificationService implements NotificationService {
return new DefaultNotificationService(logger, discovery, tokenManager);
}
async send(options: NotificationSendOptions): Promise<Notification[]> {
forPlugin(pluginId: string): NotificationService {
return new DefaultNotificationService(
this.logger,
this.discovery,
this.tokenManager,
pluginId,
);
}
async send(notification: NotificationSendOptions): Promise<void> {
if (!this.pluginId) {
throw new Error('Invalid initialization of the NotificationService');
}
try {
const baseUrl = await this.discovery.getBaseUrl('notifications');
const { token } = await this.tokenManager.getToken();
const response = await fetch(`${baseUrl}/notifications`, {
await fetch(`${baseUrl}/notifications`, {
method: 'POST',
body: JSON.stringify(options),
body: JSON.stringify({
...notification,
origin: `plugin-${this.pluginId}`,
}),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
return await response.json();
} catch (error) {
this.logger.error(`Failed to send notifications: ${error}`);
return [];
}
}
}
@@ -15,9 +15,10 @@
*/
import { NotificationSendOptions } from './DefaultNotificationService';
import { Notification } from '@backstage/plugin-notifications-common';
/** @public */
export interface NotificationService {
send(options: NotificationSendOptions): Promise<Notification[]>;
forPlugin(pluginId: string): NotificationService;
send(options: NotificationSendOptions): Promise<void>;
}
+14 -23
View File
@@ -11,7 +11,6 @@ import { DiscoveryApi } from '@backstage/core-plugin-api';
import { FetchApi } from '@backstage/core-plugin-api';
import { JSX as JSX_2 } from 'react';
import { Notification as Notification_2 } from '@backstage/plugin-notifications-common';
import { NotificationIds } from '@backstage/plugin-notifications-common';
import { NotificationStatus } from '@backstage/plugin-notifications-common';
import { NotificationType } from '@backstage/plugin-notifications-common';
import { default as React_2 } from 'react';
@@ -34,17 +33,9 @@ export interface NotificationsApi {
// (undocumented)
getStatus(): Promise<NotificationStatus>;
// (undocumented)
markDone(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markRead(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markSaved(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markUndone(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markUnread(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markUnsaved(ids: string[]): Promise<NotificationIds>;
updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification_2[]>;
}
// @public (undocumented)
@@ -60,17 +51,9 @@ export class NotificationsClient implements NotificationsApi {
// (undocumented)
getStatus(): Promise<NotificationStatus>;
// (undocumented)
markDone(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markRead(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markSaved(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markUndone(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markUnread(ids: string[]): Promise<NotificationIds>;
// (undocumented)
markUnsaved(ids: string[]): Promise<NotificationIds>;
updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification_2[]>;
}
// @public (undocumented)
@@ -94,6 +77,14 @@ export const NotificationsTable: (props: {
notifications?: Notification_2[];
}) => React_2.JSX.Element;
// @public (undocumented)
export type UpdateNotificationsOptions = {
ids: string[];
done?: boolean;
read?: boolean;
saved?: boolean;
};
// @public (undocumented)
export function useNotificationsApi<T>(
f: (api: NotificationsApi) => Promise<T>,
@@ -16,7 +16,6 @@
import { createApiRef } from '@backstage/core-plugin-api';
import {
Notification,
NotificationIds,
NotificationStatus,
NotificationType,
} from '@backstage/plugin-notifications-common';
@@ -34,21 +33,21 @@ export type GetNotificationsOptions = {
search?: string;
};
/** @public */
export type UpdateNotificationsOptions = {
ids: string[];
done?: boolean;
read?: boolean;
saved?: boolean;
};
/** @public */
export interface NotificationsApi {
getNotifications(options?: GetNotificationsOptions): Promise<Notification[]>;
getStatus(): Promise<NotificationStatus>;
markDone(ids: string[]): Promise<NotificationIds>;
markUndone(ids: string[]): Promise<NotificationIds>;
markRead(ids: string[]): Promise<NotificationIds>;
markUnread(ids: string[]): Promise<NotificationIds>;
markSaved(ids: string[]): Promise<NotificationIds>;
markUnsaved(ids: string[]): Promise<NotificationIds>;
updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification[]>;
}
@@ -13,12 +13,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { GetNotificationsOptions, NotificationsApi } from './NotificationsApi';
import {
GetNotificationsOptions,
NotificationsApi,
UpdateNotificationsOptions,
} from './NotificationsApi';
import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import {
Notification,
NotificationIds,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
@@ -61,50 +64,12 @@ export class NotificationsClient implements NotificationsApi {
return await this.request<NotificationStatus>('status');
}
async markDone(ids: string[]): Promise<NotificationIds> {
return await this.request<NotificationIds>('done', {
async updateNotifications(
options: UpdateNotificationsOptions,
): Promise<Notification[]> {
return await this.request<Notification[]>('update', {
method: 'POST',
body: JSON.stringify({ ids: ids }),
headers: { 'Content-Type': 'application/json' },
});
}
async markUndone(ids: string[]): Promise<NotificationIds> {
return await this.request<NotificationIds>('undone', {
method: 'POST',
body: JSON.stringify({ ids: ids }),
headers: { 'Content-Type': 'application/json' },
});
}
async markRead(ids: string[]): Promise<NotificationIds> {
return await this.request<NotificationIds>('read', {
method: 'POST',
body: JSON.stringify({ ids: ids }),
headers: { 'Content-Type': 'application/json' },
});
}
async markUnread(ids: string[]): Promise<NotificationIds> {
return await this.request<NotificationIds>('unread', {
method: 'POST',
body: JSON.stringify({ ids: ids }),
headers: { 'Content-Type': 'application/json' },
});
}
async markSaved(ids: string[]): Promise<NotificationIds> {
return await this.request<NotificationIds>('save', {
method: 'POST',
body: JSON.stringify({ ids: ids }),
headers: { 'Content-Type': 'application/json' },
});
}
async markUnsaved(ids: string[]): Promise<NotificationIds> {
return await this.request<NotificationIds>('unsave', {
method: 'POST',
body: JSON.stringify({ ids: ids }),
body: JSON.stringify(options),
headers: { 'Content-Type': 'application/json' },
});
}
@@ -148,7 +148,7 @@ export const NotificationsTable = (props: {
startIcon={<Inbox fontSize="small" />}
onClick={() => {
notificationsApi
.markUndone(selected)
.updateNotifications({ ids: selected, done: false })
.then(() => props.onUpdate());
setSelected([]);
}}
@@ -162,7 +162,7 @@ export const NotificationsTable = (props: {
startIcon={<Check fontSize="small" />}
onClick={() => {
notificationsApi
.markDone(selected)
.updateNotifications({ ids: selected, done: true })
.then(() => props.onUpdate());
setSelected([]);
}}
@@ -197,16 +197,16 @@ export const NotificationsTable = (props: {
<TableCell
onClick={() =>
notificationsApi
.markRead([notification.id])
.then(() => navigate(notification.link))
.updateNotifications({ ids: [notification.id], read: true })
.then(() => navigate(notification.payload.link))
}
style={{ paddingLeft: 0 }}
>
<Typography variant="subtitle2">
{notification.title}
{notification.payload.title}
</Typography>
<Typography variant="body2">
{notification.description}
{notification.payload.description}
</Typography>
</TableCell>
<TableCell style={{ textAlign: 'right' }}>
@@ -214,13 +214,16 @@ export const NotificationsTable = (props: {
<RelativeTime value={notification.created} />
</Box>
<Box className="showOnHover">
<Tooltip title={notification.link}>
<Tooltip title={notification.payload.link}>
<IconButton
className={styles.actionButton}
onClick={() =>
notificationsApi
.markRead([notification.id])
.then(() => navigate(notification.link))
.updateNotifications({
ids: [notification.id],
read: true,
})
.then(() => navigate(notification.payload.link))
}
>
<ArrowForwardIcon />
@@ -234,13 +237,19 @@ export const NotificationsTable = (props: {
onClick={() => {
if (notification.read) {
notificationsApi
.markUndone([notification.id])
.updateNotifications({
ids: [notification.id],
done: false,
})
.then(() => {
props.onUpdate();
});
} else {
notificationsApi
.markDone([notification.id])
.updateNotifications({
ids: [notification.id],
done: true,
})
.then(() => {
props.onUpdate();
});
@@ -262,13 +271,19 @@ export const NotificationsTable = (props: {
onClick={() => {
if (notification.saved) {
notificationsApi
.markUnsaved([notification.id])
.updateNotifications({
ids: [notification.id],
saved: false,
})
.then(() => {
props.onUpdate();
});
} else {
notificationsApi
.markSaved([notification.id])
.updateNotifications({
ids: [notification.id],
saved: true,
})
.then(() => {
props.onUpdate();
});