feat(notifications): action to get users notifications
useful for mcp for example to get the daily what is going on status. Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-notifications-backend': patch
|
||||
---
|
||||
|
||||
Added an action to get a user's notifications
|
||||
@@ -22,6 +22,10 @@ This is a (non-exhaustive) list of actions that are known to be part of the Acti
|
||||
- `catalog.unregister-entity` (Unregister entity from the Catalog): Unregisters a Location entity and all entities it owns from the Backstage catalog.
|
||||
- `catalog.validate-entity` (Validate Catalog Entity): This action can be used to validate `catalog-info.yaml` file contents meant to be used with the software catalog.
|
||||
|
||||
### Notifications
|
||||
|
||||
- `notifications.get-notifications` (Get Notifications): Fetches notifications for the currently authenticated user. Defaults to returning only unread notifications. Supports filtering by read status (`unread`, `read`, `saved`, `all`), severity, topic, free-text search, and creation date. Supports pagination via `offset` and `limit`.
|
||||
|
||||
### Scaffolder
|
||||
|
||||
- `scaffolder.dry-run-template` (Dry Run Scaffolder Template): Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright 2025 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 { createGetNotificationsAction } from './createGetNotificationsAction';
|
||||
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
|
||||
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
|
||||
import { NotificationsStore } from '../database';
|
||||
import { Notification } from '@backstage/plugin-notifications-common';
|
||||
|
||||
const mockNotification: Notification = {
|
||||
id: 'notif-1',
|
||||
user: 'user:default/test',
|
||||
created: new Date('2025-04-01T10:00:00.000Z'),
|
||||
origin: 'catalog',
|
||||
payload: {
|
||||
title: 'Test notification',
|
||||
severity: 'normal',
|
||||
},
|
||||
};
|
||||
|
||||
function createMockStore(
|
||||
overrides: Partial<NotificationsStore> = {},
|
||||
): NotificationsStore {
|
||||
return {
|
||||
getNotifications: jest.fn().mockResolvedValue([]),
|
||||
getNotificationsCount: jest.fn().mockResolvedValue(0),
|
||||
saveNotification: jest.fn(),
|
||||
saveBroadcast: jest.fn(),
|
||||
getExistingScopeNotification: jest.fn(),
|
||||
getExistingScopeBroadcast: jest.fn(),
|
||||
restoreExistingNotification: jest.fn(),
|
||||
getNotification: jest.fn(),
|
||||
getStatus: jest.fn(),
|
||||
markRead: jest.fn(),
|
||||
markUnread: jest.fn(),
|
||||
markSaved: jest.fn(),
|
||||
markUnsaved: jest.fn(),
|
||||
getUserNotificationOrigins: jest.fn(),
|
||||
getUserNotificationTopics: jest.fn(),
|
||||
getNotificationSettings: jest.fn(),
|
||||
saveNotificationSettings: jest.fn(),
|
||||
getTopics: jest.fn(),
|
||||
clearNotifications: jest.fn(),
|
||||
...overrides,
|
||||
} as unknown as NotificationsStore;
|
||||
}
|
||||
|
||||
describe('createGetNotificationsAction', () => {
|
||||
const userCredentials = mockCredentials.user('user:default/test');
|
||||
const auth = mockServices.auth();
|
||||
|
||||
it('defaults to returning only unread notifications', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const store = createMockStore({
|
||||
getNotifications: jest.fn().mockResolvedValue([mockNotification]),
|
||||
getNotificationsCount: jest.fn().mockResolvedValue(1),
|
||||
});
|
||||
|
||||
createGetNotificationsAction({
|
||||
store,
|
||||
auth,
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
});
|
||||
|
||||
const result = await mockActionsRegistry.invoke({
|
||||
id: 'test:get-notifications',
|
||||
input: {},
|
||||
credentials: userCredentials,
|
||||
});
|
||||
|
||||
expect(store.getNotifications).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user: 'user:default/test',
|
||||
read: false,
|
||||
saved: undefined,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
output: { notifications: [mockNotification], totalCount: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes read: true when view is "read"', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const store = createMockStore();
|
||||
|
||||
createGetNotificationsAction({
|
||||
store,
|
||||
auth,
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
});
|
||||
|
||||
await mockActionsRegistry.invoke({
|
||||
id: 'test:get-notifications',
|
||||
input: { view: 'read' },
|
||||
credentials: userCredentials,
|
||||
});
|
||||
|
||||
expect(store.getNotifications).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ read: true, saved: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes saved: true when view is "saved"', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const store = createMockStore();
|
||||
|
||||
createGetNotificationsAction({
|
||||
store,
|
||||
auth,
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
});
|
||||
|
||||
await mockActionsRegistry.invoke({
|
||||
id: 'test:get-notifications',
|
||||
input: { view: 'saved' },
|
||||
credentials: userCredentials,
|
||||
});
|
||||
|
||||
expect(store.getNotifications).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ saved: true, read: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes no read or saved filter when view is "all"', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const store = createMockStore();
|
||||
|
||||
createGetNotificationsAction({
|
||||
store,
|
||||
auth,
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
});
|
||||
|
||||
await mockActionsRegistry.invoke({
|
||||
id: 'test:get-notifications',
|
||||
input: { view: 'all' },
|
||||
credentials: userCredentials,
|
||||
});
|
||||
|
||||
expect(store.getNotifications).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ read: undefined, saved: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards search, topic, minimumSeverity, createdAfter, offset, and limit', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const store = createMockStore();
|
||||
|
||||
createGetNotificationsAction({
|
||||
store,
|
||||
auth,
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
});
|
||||
|
||||
await mockActionsRegistry.invoke({
|
||||
id: 'test:get-notifications',
|
||||
input: {
|
||||
view: 'all',
|
||||
search: 'deploy',
|
||||
topic: 'ci/cd',
|
||||
minimumSeverity: 'high',
|
||||
createdAfter: '2025-04-01T00:00:00Z',
|
||||
offset: 20,
|
||||
limit: 50,
|
||||
},
|
||||
credentials: userCredentials,
|
||||
});
|
||||
|
||||
expect(store.getNotifications).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user: 'user:default/test',
|
||||
search: 'deploy',
|
||||
topic: 'ci/cd',
|
||||
minimumSeverity: 'high',
|
||||
createdAfter: new Date('2025-04-01T00:00:00Z'),
|
||||
offset: 20,
|
||||
limit: 50,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws InputError when called without user credentials', async () => {
|
||||
const mockActionsRegistry = actionsRegistryServiceMock();
|
||||
const store = createMockStore();
|
||||
|
||||
createGetNotificationsAction({
|
||||
store,
|
||||
auth,
|
||||
actionsRegistry: mockActionsRegistry,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mockActionsRegistry.invoke({
|
||||
id: 'test:get-notifications',
|
||||
input: {},
|
||||
credentials: mockCredentials.service(),
|
||||
}),
|
||||
).rejects.toThrow('This action requires user credentials');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright 2026 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 { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import { NotificationsStore } from '../database';
|
||||
|
||||
export const createGetNotificationsAction = ({
|
||||
store,
|
||||
auth,
|
||||
actionsRegistry,
|
||||
}: {
|
||||
store: NotificationsStore;
|
||||
auth: AuthService;
|
||||
actionsRegistry: ActionsRegistryService;
|
||||
}) => {
|
||||
actionsRegistry.register({
|
||||
name: 'get-notifications',
|
||||
title: 'Get Notifications',
|
||||
description: `
|
||||
Fetches notifications for the currently authenticated user from the Backstage notifications system.
|
||||
|
||||
Each notification has an \`id\`, \`origin\` (the plugin or service that sent it), a \`payload\` with
|
||||
\`title\`, optional \`description\`, optional \`link\`, \`severity\` (one of "critical", "high", "normal", "low"),
|
||||
and optional \`topic\`. Notifications also carry \`created\` and optionally \`read\`, \`saved\`, and \`updated\` timestamps.
|
||||
|
||||
## Filters
|
||||
|
||||
Use the \`view\` field to control which notifications are returned:
|
||||
- \`"unread"\` (default) — only notifications the user has not yet read. Use this to surface new, actionable items.
|
||||
- \`"read"\` — only notifications the user has already read.
|
||||
- \`"saved"\` — only notifications the user has explicitly saved/bookmarked.
|
||||
- \`"all"\` — all notifications regardless of read or saved status.
|
||||
|
||||
Additional filters can be combined with \`view\`:
|
||||
- \`search\` — free-text search across notification titles and descriptions.
|
||||
- \`topic\` — filter to a specific topic string, e.g. "ci/cd" or "deployment".
|
||||
- \`minimumSeverity\` — minimum severity level; returns this severity and anything more severe ("critical" > "high" > "normal" > "low").
|
||||
- \`createdAfter\` — ISO 8601 datetime string; only return notifications created after this time.
|
||||
|
||||
## Pagination
|
||||
|
||||
Use \`offset\` and \`limit\` to paginate. The response includes \`totalCount\` so you can calculate further pages.
|
||||
|
||||
## Examples
|
||||
|
||||
- Get my unread notifications: use default values (no input required).
|
||||
- Get all notifications from the past week: set \`view: "all"\` and \`createdAfter\` to 7 days ago.
|
||||
- Get high-priority unread alerts: set \`minimumSeverity: "high"\` (view defaults to "unread").
|
||||
`,
|
||||
attributes: {
|
||||
destructive: false,
|
||||
readOnly: true,
|
||||
idempotent: true,
|
||||
},
|
||||
schema: {
|
||||
input: z =>
|
||||
z.object({
|
||||
view: z
|
||||
.enum(['unread', 'read', 'saved', 'all'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Which notifications to return. Defaults to "unread". "unread" returns only unread notifications, "read" returns only read notifications, "saved" returns bookmarked notifications, "all" returns everything.',
|
||||
),
|
||||
search: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Free-text search string to filter notifications by title or description.',
|
||||
),
|
||||
topic: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Filter to notifications with this specific topic, e.g. "ci/cd".',
|
||||
),
|
||||
minimumSeverity: z
|
||||
.enum(['critical', 'high', 'normal', 'low'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Minimum severity to include. "critical" is most severe, "low" is least. For example, "high" returns "critical" and "high" notifications only.',
|
||||
),
|
||||
createdAfter: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'ISO 8601 datetime string. Only return notifications created after this time, e.g. "2025-01-01T00:00:00Z".',
|
||||
),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe(
|
||||
'Number of notifications to skip for pagination. Defaults to 0.',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe(
|
||||
'Maximum number of notifications to return (1–100). Defaults to 10.',
|
||||
),
|
||||
}),
|
||||
output: z =>
|
||||
z.object({
|
||||
notifications: z
|
||||
.array(z.object({}).passthrough())
|
||||
.describe('List of notifications matching the filters.'),
|
||||
totalCount: z
|
||||
.number()
|
||||
.describe(
|
||||
'Total number of notifications matching the filters, useful for pagination.',
|
||||
),
|
||||
}),
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: 'Get my unread notifications',
|
||||
description: 'Returns up to 10 unread notifications (the default).',
|
||||
input: {},
|
||||
output: {
|
||||
notifications: [
|
||||
{
|
||||
id: 'abc123',
|
||||
origin: 'catalog',
|
||||
created: '2025-04-01T10:00:00.000Z',
|
||||
payload: {
|
||||
title: 'Component entity missing owner',
|
||||
severity: 'high',
|
||||
link: '/catalog/default/component/my-service',
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Get all notifications from the past week',
|
||||
input: {
|
||||
view: 'all',
|
||||
createdAfter: '2025-04-03T00:00:00Z',
|
||||
limit: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Get high-priority unread alerts',
|
||||
input: { minimumSeverity: 'high' },
|
||||
},
|
||||
],
|
||||
action: async ({ input, credentials, logger }) => {
|
||||
if (!auth.isPrincipal(credentials, 'user')) {
|
||||
throw new NotAllowedError('This action requires user credentials');
|
||||
}
|
||||
|
||||
const { userEntityRef } = credentials.principal;
|
||||
|
||||
logger.info(
|
||||
`Fetching notifications for user "${userEntityRef}" (view=${
|
||||
input.view ?? 'unread'
|
||||
})`,
|
||||
);
|
||||
|
||||
let read: boolean | undefined;
|
||||
if (input.view === 'unread' || input.view === undefined) {
|
||||
read = false;
|
||||
} else if (input.view === 'read') {
|
||||
read = true;
|
||||
}
|
||||
|
||||
const opts = {
|
||||
user: userEntityRef,
|
||||
offset: input.offset ?? 0,
|
||||
limit: input.limit ?? 10,
|
||||
search: input.search,
|
||||
topic: input.topic,
|
||||
minimumSeverity: input.minimumSeverity,
|
||||
createdAfter: input.createdAfter
|
||||
? new Date(input.createdAfter)
|
||||
: undefined,
|
||||
read,
|
||||
saved: input.view === 'saved' ? true : undefined,
|
||||
};
|
||||
|
||||
const [notifications, totalCount] = await Promise.all([
|
||||
store.getNotifications(opts),
|
||||
store.getNotificationsCount(opts),
|
||||
]);
|
||||
|
||||
return {
|
||||
output: {
|
||||
notifications,
|
||||
totalCount,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2025 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 { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { NotificationsStore } from '../database';
|
||||
import { createGetNotificationsAction } from './createGetNotificationsAction';
|
||||
|
||||
export const createNotificationsActions = (options: {
|
||||
actionsRegistry: ActionsRegistryService;
|
||||
auth: AuthService;
|
||||
store: NotificationsStore;
|
||||
}) => {
|
||||
createGetNotificationsAction(options);
|
||||
};
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha';
|
||||
import { createRouter } from './service/router';
|
||||
import { createNotificationsActions } from './actions';
|
||||
import { signalsServiceRef } from '@backstage/plugin-signals-node';
|
||||
import {
|
||||
NotificationProcessor,
|
||||
@@ -89,6 +91,7 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
config: coreServices.rootConfig,
|
||||
catalog: catalogServiceRef,
|
||||
scheduler: coreServices.scheduler,
|
||||
actionsRegistry: actionsRegistryServiceRef,
|
||||
},
|
||||
async init({
|
||||
auth,
|
||||
@@ -101,6 +104,7 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
config,
|
||||
catalog,
|
||||
scheduler,
|
||||
actionsRegistry,
|
||||
}) {
|
||||
const store = await DatabaseNotificationsStore.create({ database });
|
||||
|
||||
@@ -130,6 +134,8 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
store,
|
||||
);
|
||||
await cleaner.initTaskRunner();
|
||||
|
||||
createNotificationsActions({ actionsRegistry, auth, store });
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user