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:
Hellgren Heikki
2026-04-10 09:59:21 +03:00
parent 5b61c2f339
commit 4c1fd438b3
6 changed files with 470 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-notifications-backend': patch
---
Added an action to get a user's notifications
+4
View File
@@ -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 (1100). 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 });
},
});
},