diff --git a/.changeset/afraid-bugs-unite.md b/.changeset/afraid-bugs-unite.md new file mode 100644 index 0000000000..f5f2e6e94f --- /dev/null +++ b/.changeset/afraid-bugs-unite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend': patch +--- + +Added an action to get a user's notifications diff --git a/docs/ai/well-known-actions.md b/docs/ai/well-known-actions.md index 40b38f1773..547f7d31b7 100644 --- a/docs/ai/well-known-actions.md +++ b/docs/ai/well-known-actions.md @@ -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. diff --git a/plugins/notifications-backend/src/actions/createGetNotificationsAction.test.ts b/plugins/notifications-backend/src/actions/createGetNotificationsAction.test.ts new file mode 100644 index 0000000000..19dfbeaffe --- /dev/null +++ b/plugins/notifications-backend/src/actions/createGetNotificationsAction.test.ts @@ -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 { + 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'); + }); +}); diff --git a/plugins/notifications-backend/src/actions/createGetNotificationsAction.ts b/plugins/notifications-backend/src/actions/createGetNotificationsAction.ts new file mode 100644 index 0000000000..2f6b980fce --- /dev/null +++ b/plugins/notifications-backend/src/actions/createGetNotificationsAction.ts @@ -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, + }, + }; + }, + }); +}; diff --git a/plugins/notifications-backend/src/actions/index.ts b/plugins/notifications-backend/src/actions/index.ts new file mode 100644 index 0000000000..8dd527d6f2 --- /dev/null +++ b/plugins/notifications-backend/src/actions/index.ts @@ -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); +}; diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts index 49b3924a68..db25c1a94e 100644 --- a/plugins/notifications-backend/src/plugin.ts +++ b/plugins/notifications-backend/src/plugin.ts @@ -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 }); }, }); },