diff --git a/workspaces/announcements/.changeset/tame-impalas-trade.md b/workspaces/announcements/.changeset/tame-impalas-trade.md new file mode 100644 index 00000000000..4d2e9fee819 --- /dev/null +++ b/workspaces/announcements/.changeset/tame-impalas-trade.md @@ -0,0 +1,12 @@ +--- +'@backstage-community/plugin-announcements-backend': patch +--- + +Added actions for announcement management. + +Registered four actions that expose the announcements backend via MCP and scaffolder templates: + +- `announcements:list-announcements` — list announcements with optional filters for category, tags, active status, and pagination +- `announcements:get-announcement` — fetch full details of a single announcement by ID +- `announcements:create-announcement` — create a new announcement (requires `announcement.entity.create` permission) +- `announcements:delete-announcement` — delete an announcement by ID (requires `announcement.entity.delete` permission) diff --git a/workspaces/announcements/plugins/announcements-backend/src/actions/createCreateAnnouncementAction.test.ts b/workspaces/announcements/plugins/announcements-backend/src/actions/createCreateAnnouncementAction.test.ts new file mode 100644 index 00000000000..840c4ec68a1 --- /dev/null +++ b/workspaces/announcements/plugins/announcements-backend/src/actions/createCreateAnnouncementAction.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { NotAllowedError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { DateTime } from 'luxon'; +import { createCreateAnnouncementAction } from './createCreateAnnouncementAction'; +import { AnnouncementsDatabase } from '../service/persistence/AnnouncementsDatabase'; +import { PersistenceContext } from '../service/persistence'; + +const nowIso = '2025-01-15T10:00:00.000Z'; +const now = DateTime.fromISO(nowIso); + +const mockInsertedAnnouncement = { + id: 'ann-new', + title: 'My New Announcement', + excerpt: 'An excerpt', + body: 'Full body', + publisher: 'user:default/alice', + active: true, + created_at: now, + start_at: now, + until_date: undefined, + updated_at: now, + category: undefined, + on_behalf_of: undefined, + sendNotification: false, +}; + +describe('createCreateAnnouncementAction', () => { + let mockActionsRegistry: { register: jest.Mock }; + let mockStore: jest.Mocked; + let mockPersistenceContext: PersistenceContext; + let mockPermissions: { authorize: jest.Mock }; + + beforeEach(() => { + mockActionsRegistry = { register: jest.fn() }; + + mockStore = { + insertAnnouncement: jest.fn().mockResolvedValue(mockInsertedAnnouncement), + } as any; + + mockPersistenceContext = { + announcementsStore: mockStore, + } as any; + + mockPermissions = { + authorize: jest + .fn() + .mockResolvedValue([{ result: AuthorizeResult.ALLOW }]), + }; + + createCreateAnnouncementAction({ + actionsRegistry: mockActionsRegistry as any, + persistenceContext: mockPersistenceContext, + permissions: mockPermissions as any, + }); + }); + + it('registers the announcements:create-announcement action', () => { + expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1); + const reg = mockActionsRegistry.register.mock.calls[0][0]; + expect(reg.name).toBe('announcements:create-announcement'); + expect(reg.attributes.readOnly).toBe(false); + expect(reg.attributes.destructive).toBe(false); + expect(reg.attributes.idempotent).toBe(false); + expect(reg.visibilityPermission).toBeDefined(); + expect(reg.visibilityPermission.name).toBe('announcement.entity.create'); + }); + + it('creates an announcement and returns its details', async () => { + const reg = mockActionsRegistry.register.mock.calls[0][0]; + const credentials = mockCredentials.user(); + + const result = await reg.action({ + input: { + title: 'My New Announcement', + excerpt: 'An excerpt', + body: 'Full body', + publisher: 'user:default/alice', + active: true, + start_at: nowIso, + sendNotification: false, + }, + credentials, + logger: mockServices.logger.mock(), + }); + + expect(mockPermissions.authorize).toHaveBeenCalledWith( + [ + expect.objectContaining({ + permission: expect.objectContaining({ + name: 'announcement.entity.create', + }), + }), + ], + { credentials }, + ); + expect(mockStore.insertAnnouncement).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'My New Announcement', + publisher: 'user:default/alice', + }), + ); + expect(result.output.id).toBe('ann-new'); + expect(result.output.title).toBe('My New Announcement'); + expect(result.output.created_at).toBe(nowIso); + }); + + it('throws NotAllowedError when permission is denied', async () => { + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + const reg = mockActionsRegistry.register.mock.calls[0][0]; + const credentials = mockCredentials.user(); + + await expect( + reg.action({ + input: { + title: 'Unauthorized', + excerpt: 'excerpt', + body: 'body', + publisher: 'user:default/alice', + active: true, + start_at: nowIso, + sendNotification: false, + }, + credentials, + logger: mockServices.logger.mock(), + }), + ).rejects.toThrow(NotAllowedError); + }); +}); diff --git a/workspaces/announcements/plugins/announcements-backend/src/actions/createCreateAnnouncementAction.ts b/workspaces/announcements/plugins/announcements-backend/src/actions/createCreateAnnouncementAction.ts new file mode 100644 index 00000000000..0a0613bf7d2 --- /dev/null +++ b/workspaces/announcements/plugins/announcements-backend/src/actions/createCreateAnnouncementAction.ts @@ -0,0 +1,164 @@ +/* + * 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 { PermissionsService } from '@backstage/backend-plugin-api'; +import { NotAllowedError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { announcementEntityPermissions } from '@backstage-community/plugin-announcements-common'; +import { DateTime } from 'luxon'; +import { v4 as uuid } from 'uuid'; +import { PersistenceContext } from '../service/persistence'; + +const { announcementCreatePermission } = announcementEntityPermissions; + +/** + * Registers the `announcements:create-announcement` action. + * @internal + */ +export function createCreateAnnouncementAction(options: { + actionsRegistry: ActionsRegistryService; + persistenceContext: PersistenceContext; + permissions: PermissionsService; +}) { + const { actionsRegistry, persistenceContext, permissions } = options; + + actionsRegistry.register({ + name: 'announcements:create-announcement', + title: 'Create Announcement', + description: + 'Create a new announcement. Requires the announcement.entity.create permission.', + attributes: { + readOnly: false, + destructive: false, + idempotent: false, + }, + visibilityPermission: announcementCreatePermission, + schema: { + input: z => + z.object({ + title: z.string().describe('Title of the announcement'), + excerpt: z.string().describe('Short summary shown in listing views'), + body: z.string().describe('Full body text of the announcement'), + publisher: z + .string() + .describe('User or team publishing the announcement'), + active: z + .boolean() + .default(true) + .describe('Whether the announcement is active'), + start_at: z + .string() + .describe('ISO 8601 datetime when the announcement becomes active'), + until_date: z + .string() + .optional() + .describe( + 'ISO 8601 datetime when the announcement expires (optional)', + ), + category: z + .string() + .optional() + .describe('Category slug to assign to the announcement'), + on_behalf_of: z + .string() + .optional() + .describe('Optional entity ref the announcement is on behalf of'), + tags: z + .array(z.string()) + .optional() + .describe('Tag slugs to attach to the announcement'), + sendNotification: z + .boolean() + .default(false) + .describe( + 'Whether to send a notification when the announcement is created', + ), + }), + output: z => + z.object({ + id: z.string(), + title: z.string(), + excerpt: z.string(), + body: z.string(), + publisher: z.string(), + active: z.boolean(), + created_at: z.string(), + start_at: z.string(), + until_date: z.string().nullable().optional(), + updated_at: z.string(), + category: z + .object({ slug: z.string(), title: z.string() }) + .optional(), + on_behalf_of: z.string().optional(), + }), + }, + async action({ input, credentials }) { + const decision = await permissions.authorize( + [{ permission: announcementCreatePermission }], + { credentials }, + ); + + if (decision[0].result === AuthorizeResult.DENY) { + throw new NotAllowedError( + 'Unauthorized: missing announcement.entity.create permission', + ); + } + + const now = DateTime.now(); + const startAt = DateTime.fromISO(input.start_at); + const untilDate = input.until_date + ? DateTime.fromISO(input.until_date) + : undefined; + + const announcement = + await persistenceContext.announcementsStore.insertAnnouncement({ + id: uuid(), + title: input.title, + excerpt: input.excerpt, + body: input.body, + publisher: input.publisher, + active: input.active, + sendNotification: input.sendNotification, + category: input.category, + on_behalf_of: input.on_behalf_of, + tags: input.tags, + created_at: now, + start_at: startAt, + until_date: untilDate, + updated_at: now, + }); + + return { + output: { + id: announcement.id, + title: announcement.title, + excerpt: announcement.excerpt, + body: announcement.body, + publisher: announcement.publisher, + active: announcement.active, + created_at: announcement.created_at.toUTC().toISO()!, + start_at: announcement.start_at.toUTC().toISO()!, + until_date: announcement.until_date + ? announcement.until_date.toUTC().toISO() + : undefined, + updated_at: announcement.updated_at.toUTC().toISO()!, + category: announcement.category, + on_behalf_of: announcement.on_behalf_of, + }, + }; + }, + }); +} diff --git a/workspaces/announcements/plugins/announcements-backend/src/actions/createDeleteAnnouncementAction.test.ts b/workspaces/announcements/plugins/announcements-backend/src/actions/createDeleteAnnouncementAction.test.ts new file mode 100644 index 00000000000..1e0265dabe3 --- /dev/null +++ b/workspaces/announcements/plugins/announcements-backend/src/actions/createDeleteAnnouncementAction.test.ts @@ -0,0 +1,139 @@ +/* + * 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 { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { NotAllowedError, NotFoundError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { DateTime } from 'luxon'; +import { createDeleteAnnouncementAction } from './createDeleteAnnouncementAction'; +import { AnnouncementsDatabase } from '../service/persistence/AnnouncementsDatabase'; +import { PersistenceContext } from '../service/persistence'; + +const nowIso = '2025-01-15T10:00:00.000Z'; +const now = DateTime.fromISO(nowIso); + +const mockAnnouncementModel = { + id: 'ann-1', + title: 'Test Announcement', + excerpt: 'Short summary', + body: 'Full body text', + publisher: 'user:default/alice', + active: true, + created_at: now, + start_at: now, + until_date: undefined, + updated_at: now, + sendNotification: false, +}; + +describe('createDeleteAnnouncementAction', () => { + let mockActionsRegistry: { register: jest.Mock }; + let mockStore: jest.Mocked; + let mockPersistenceContext: PersistenceContext; + let mockPermissions: { authorize: jest.Mock }; + + beforeEach(() => { + mockActionsRegistry = { register: jest.fn() }; + + mockStore = { + announcementByID: jest.fn().mockResolvedValue(mockAnnouncementModel), + deleteAnnouncementByID: jest.fn().mockResolvedValue(undefined), + } as any; + + mockPersistenceContext = { + announcementsStore: mockStore, + } as any; + + mockPermissions = { + authorize: jest + .fn() + .mockResolvedValue([{ result: AuthorizeResult.ALLOW }]), + }; + + createDeleteAnnouncementAction({ + actionsRegistry: mockActionsRegistry as any, + persistenceContext: mockPersistenceContext, + permissions: mockPermissions as any, + }); + }); + + it('registers the announcements:delete-announcement action', () => { + expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1); + const reg = mockActionsRegistry.register.mock.calls[0][0]; + expect(reg.name).toBe('announcements:delete-announcement'); + expect(reg.attributes.readOnly).toBe(false); + expect(reg.attributes.destructive).toBe(true); + expect(reg.attributes.idempotent).toBe(true); + expect(reg.visibilityPermission).toBeDefined(); + expect(reg.visibilityPermission.name).toBe('announcement.entity.delete'); + }); + + it('deletes an announcement and returns success', async () => { + const reg = mockActionsRegistry.register.mock.calls[0][0]; + const credentials = mockCredentials.user(); + + const result = await reg.action({ + input: { id: 'ann-1' }, + credentials, + logger: mockServices.logger.mock(), + }); + + expect(mockPermissions.authorize).toHaveBeenCalledWith( + [ + expect.objectContaining({ + permission: expect.objectContaining({ + name: 'announcement.entity.delete', + }), + }), + ], + { credentials }, + ); + expect(mockStore.announcementByID).toHaveBeenCalledWith('ann-1'); + expect(mockStore.deleteAnnouncementByID).toHaveBeenCalledWith('ann-1'); + expect(result.output.success).toBe(true); + }); + + it('throws NotAllowedError when permission is denied', async () => { + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + const reg = mockActionsRegistry.register.mock.calls[0][0]; + const credentials = mockCredentials.user(); + + await expect( + reg.action({ + input: { id: 'ann-1' }, + credentials, + logger: mockServices.logger.mock(), + }), + ).rejects.toThrow(NotAllowedError); + }); + + it('throws NotFoundError when announcement does not exist', async () => { + mockStore.announcementByID.mockResolvedValue(undefined); + const reg = mockActionsRegistry.register.mock.calls[0][0]; + const credentials = mockCredentials.user(); + + await expect( + reg.action({ + input: { id: 'non-existent' }, + credentials, + logger: mockServices.logger.mock(), + }), + ).rejects.toThrow(NotFoundError); + + expect(mockStore.deleteAnnouncementByID).not.toHaveBeenCalled(); + }); +}); diff --git a/workspaces/announcements/plugins/announcements-backend/src/actions/createDeleteAnnouncementAction.ts b/workspaces/announcements/plugins/announcements-backend/src/actions/createDeleteAnnouncementAction.ts new file mode 100644 index 00000000000..e445589bb60 --- /dev/null +++ b/workspaces/announcements/plugins/announcements-backend/src/actions/createDeleteAnnouncementAction.ts @@ -0,0 +1,85 @@ +/* + * 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 { PermissionsService } from '@backstage/backend-plugin-api'; +import { NotAllowedError, NotFoundError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { announcementEntityPermissions } from '@backstage-community/plugin-announcements-common'; +import { PersistenceContext } from '../service/persistence'; + +const { announcementDeletePermission } = announcementEntityPermissions; + +/** + * Registers the `announcements:delete-announcement` action. + * @internal + */ +export function createDeleteAnnouncementAction(options: { + actionsRegistry: ActionsRegistryService; + persistenceContext: PersistenceContext; + permissions: PermissionsService; +}) { + const { actionsRegistry, persistenceContext, permissions } = options; + + actionsRegistry.register({ + name: 'announcements:delete-announcement', + title: 'Delete Announcement', + description: + 'Delete an announcement by its ID. Requires the announcement.entity.delete permission.', + attributes: { + readOnly: false, + destructive: true, + idempotent: true, + }, + visibilityPermission: announcementDeletePermission, + schema: { + input: z => + z.object({ + id: z.string().describe('The UUID of the announcement to delete'), + }), + output: z => + z.object({ + success: z + .boolean() + .describe('True if the announcement was successfully deleted'), + }), + }, + async action({ input, credentials }) { + const decision = await permissions.authorize( + [{ permission: announcementDeletePermission }], + { credentials }, + ); + + if (decision[0].result === AuthorizeResult.DENY) { + throw new NotAllowedError( + 'Unauthorized: missing announcement.entity.delete permission', + ); + } + + const announcement = + await persistenceContext.announcementsStore.announcementByID(input.id); + + if (!announcement) { + throw new NotFoundError(`Announcement with ID ${input.id} not found`); + } + + await persistenceContext.announcementsStore.deleteAnnouncementByID( + input.id, + ); + + return { output: { success: true } }; + }, + }); +} diff --git a/workspaces/announcements/plugins/announcements-backend/src/actions/createGetAnnouncementAction.test.ts b/workspaces/announcements/plugins/announcements-backend/src/actions/createGetAnnouncementAction.test.ts new file mode 100644 index 00000000000..f7e084f7493 --- /dev/null +++ b/workspaces/announcements/plugins/announcements-backend/src/actions/createGetAnnouncementAction.test.ts @@ -0,0 +1,104 @@ +/* + * 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 { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { NotFoundError } from '@backstage/errors'; +import { DateTime } from 'luxon'; +import { createGetAnnouncementAction } from './createGetAnnouncementAction'; +import { AnnouncementsDatabase } from '../service/persistence/AnnouncementsDatabase'; +import { PersistenceContext } from '../service/persistence'; + +const nowIso = '2025-01-15T10:00:00.000Z'; +const now = DateTime.fromISO(nowIso); + +const mockAnnouncementModel = { + id: 'ann-1', + title: 'Test Announcement', + excerpt: 'Short summary', + body: 'Full body text', + publisher: 'user:default/alice', + active: true, + created_at: now, + start_at: now, + until_date: undefined, + updated_at: now, + category: undefined, + on_behalf_of: undefined, + tags: undefined, + sendNotification: false, +}; + +describe('createGetAnnouncementAction', () => { + let mockActionsRegistry: { register: jest.Mock }; + let mockStore: jest.Mocked; + let mockPersistenceContext: PersistenceContext; + + beforeEach(() => { + mockActionsRegistry = { register: jest.fn() }; + + mockStore = { + announcementByID: jest.fn().mockResolvedValue(mockAnnouncementModel), + } as any; + + mockPersistenceContext = { + announcementsStore: mockStore, + } as any; + + createGetAnnouncementAction({ + actionsRegistry: mockActionsRegistry as any, + persistenceContext: mockPersistenceContext, + }); + }); + + it('registers the announcements:get-announcement action', () => { + expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1); + const reg = mockActionsRegistry.register.mock.calls[0][0]; + expect(reg.name).toBe('announcements:get-announcement'); + expect(reg.attributes.readOnly).toBe(true); + expect(reg.attributes.destructive).toBe(false); + expect(reg.attributes.idempotent).toBe(true); + }); + + it('returns announcement with serialized dates', async () => { + const reg = mockActionsRegistry.register.mock.calls[0][0]; + const credentials = mockCredentials.user(); + + const result = await reg.action({ + input: { id: 'ann-1' }, + credentials, + logger: mockServices.logger.mock(), + }); + + expect(mockStore.announcementByID).toHaveBeenCalledWith('ann-1'); + expect(result.output.id).toBe('ann-1'); + expect(result.output.title).toBe('Test Announcement'); + expect(result.output.created_at).toBe(nowIso); + expect(result.output.start_at).toBe(nowIso); + }); + + it('throws NotFoundError when announcement does not exist', async () => { + mockStore.announcementByID.mockResolvedValue(undefined); + const reg = mockActionsRegistry.register.mock.calls[0][0]; + const credentials = mockCredentials.user(); + + await expect( + reg.action({ + input: { id: 'non-existent' }, + credentials, + logger: mockServices.logger.mock(), + }), + ).rejects.toThrow(NotFoundError); + }); +}); diff --git a/workspaces/announcements/plugins/announcements-backend/src/actions/createGetAnnouncementAction.ts b/workspaces/announcements/plugins/announcements-backend/src/actions/createGetAnnouncementAction.ts new file mode 100644 index 00000000000..789dfe3b38f --- /dev/null +++ b/workspaces/announcements/plugins/announcements-backend/src/actions/createGetAnnouncementAction.ts @@ -0,0 +1,94 @@ +/* + * 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 { NotFoundError } from '@backstage/errors'; +import { PersistenceContext } from '../service/persistence'; + +/** + * Registers the `announcements:get-announcement` action. + * @internal + */ +export function createGetAnnouncementAction(options: { + actionsRegistry: ActionsRegistryService; + persistenceContext: PersistenceContext; +}) { + const { actionsRegistry, persistenceContext } = options; + + actionsRegistry.register({ + name: 'announcements:get-announcement', + title: 'Get Announcement', + description: 'Fetch the full details of a single announcement by its ID.', + attributes: { + readOnly: true, + destructive: false, + idempotent: true, + }, + schema: { + input: z => + z.object({ + id: z.string().describe('The UUID of the announcement to retrieve'), + }), + output: z => + z.object({ + id: z.string(), + title: z.string(), + excerpt: z.string(), + body: z.string(), + publisher: z.string(), + active: z.boolean(), + created_at: z.string(), + start_at: z.string(), + until_date: z.string().nullable().optional(), + updated_at: z.string(), + category: z + .object({ slug: z.string(), title: z.string() }) + .optional(), + on_behalf_of: z.string().optional(), + tags: z + .array(z.object({ slug: z.string(), title: z.string() })) + .optional(), + }), + }, + async action({ input }) { + const announcement = + await persistenceContext.announcementsStore.announcementByID(input.id); + + if (!announcement) { + throw new NotFoundError(`Announcement with ID ${input.id} not found`); + } + + return { + output: { + id: announcement.id, + title: announcement.title, + excerpt: announcement.excerpt, + body: announcement.body, + publisher: announcement.publisher, + active: announcement.active, + created_at: announcement.created_at.toUTC().toISO()!, + start_at: announcement.start_at.toUTC().toISO()!, + until_date: announcement.until_date + ? announcement.until_date.toUTC().toISO() + : undefined, + updated_at: announcement.updated_at.toUTC().toISO()!, + category: announcement.category, + on_behalf_of: announcement.on_behalf_of, + tags: announcement.tags, + }, + }; + }, + }); +} diff --git a/workspaces/announcements/plugins/announcements-backend/src/actions/createListAnnouncementsAction.test.ts b/workspaces/announcements/plugins/announcements-backend/src/actions/createListAnnouncementsAction.test.ts new file mode 100644 index 00000000000..c5ccf8c7709 --- /dev/null +++ b/workspaces/announcements/plugins/announcements-backend/src/actions/createListAnnouncementsAction.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { DateTime } from 'luxon'; +import { createListAnnouncementsAction } from './createListAnnouncementsAction'; +import { AnnouncementsDatabase } from '../service/persistence/AnnouncementsDatabase'; +import { PersistenceContext } from '../service/persistence'; + +const nowIso = '2025-01-15T10:00:00.000Z'; +const now = DateTime.fromISO(nowIso); + +const mockAnnouncementModel = { + id: 'ann-1', + title: 'Test Announcement', + excerpt: 'Short summary', + body: 'Full body text', + publisher: 'user:default/alice', + active: true, + created_at: now, + start_at: now, + until_date: undefined, + updated_at: now, + category: undefined, + on_behalf_of: undefined, + tags: undefined, + sendNotification: false, +}; + +describe('createListAnnouncementsAction', () => { + let mockActionsRegistry: { register: jest.Mock }; + let mockStore: jest.Mocked; + let mockPersistenceContext: PersistenceContext; + + beforeEach(() => { + mockActionsRegistry = { register: jest.fn() }; + + mockStore = { + announcements: jest.fn().mockResolvedValue({ + count: 1, + results: [mockAnnouncementModel], + }), + } as any; + + mockPersistenceContext = { + announcementsStore: mockStore, + } as any; + + createListAnnouncementsAction({ + actionsRegistry: mockActionsRegistry as any, + persistenceContext: mockPersistenceContext, + }); + }); + + it('registers the announcements:list-announcements action', () => { + expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1); + const reg = mockActionsRegistry.register.mock.calls[0][0]; + expect(reg.name).toBe('announcements:list-announcements'); + expect(reg.attributes.readOnly).toBe(true); + expect(reg.attributes.destructive).toBe(false); + expect(reg.attributes.idempotent).toBe(true); + }); + + it('returns announcements with serialized ISO dates', async () => { + const reg = mockActionsRegistry.register.mock.calls[0][0]; + const credentials = mockCredentials.user(); + + const result = await reg.action({ + input: {}, + credentials, + logger: mockServices.logger.mock(), + }); + + expect(mockStore.announcements).toHaveBeenCalledWith({ + max: undefined, + offset: undefined, + category: undefined, + tags: undefined, + active: undefined, + sortBy: 'created_at', + order: 'desc', + }); + + expect(result.output.count).toBe(1); + expect(result.output.announcements).toHaveLength(1); + const ann = result.output.announcements[0]; + expect(ann.id).toBe('ann-1'); + expect(ann.title).toBe('Test Announcement'); + expect(ann.created_at).toBe(nowIso); + }); + + it('passes filters to the store', async () => { + const reg = mockActionsRegistry.register.mock.calls[0][0]; + const credentials = mockCredentials.user(); + + await reg.action({ + input: { + max: 5, + page: 2, + category: 'general', + active: true, + sortBy: 'start_at', + order: 'asc', + }, + credentials, + logger: mockServices.logger.mock(), + }); + + expect(mockStore.announcements).toHaveBeenCalledWith({ + max: 5, + offset: 5, + category: 'general', + tags: undefined, + active: true, + sortBy: 'start_at', + order: 'asc', + }); + }); + + it('returns empty list when store returns no results', async () => { + mockStore.announcements.mockResolvedValue({ count: 0, results: [] }); + const reg = mockActionsRegistry.register.mock.calls[0][0]; + const credentials = mockCredentials.user(); + + const result = await reg.action({ + input: {}, + credentials, + logger: mockServices.logger.mock(), + }); + + expect(result.output.count).toBe(0); + expect(result.output.announcements).toEqual([]); + }); +}); diff --git a/workspaces/announcements/plugins/announcements-backend/src/actions/createListAnnouncementsAction.ts b/workspaces/announcements/plugins/announcements-backend/src/actions/createListAnnouncementsAction.ts new file mode 100644 index 00000000000..30287f07ff8 --- /dev/null +++ b/workspaces/announcements/plugins/announcements-backend/src/actions/createListAnnouncementsAction.ts @@ -0,0 +1,130 @@ +/* + * 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 { PersistenceContext } from '../service/persistence'; + +/** + * Registers the `announcements:list-announcements` action. + * @internal + */ +export function createListAnnouncementsAction(options: { + actionsRegistry: ActionsRegistryService; + persistenceContext: PersistenceContext; +}) { + const { actionsRegistry, persistenceContext } = options; + + actionsRegistry.register({ + name: 'announcements:list-announcements', + title: 'List Announcements', + description: + 'List announcements with optional filters for category, active status, pagination and sort order.', + attributes: { + readOnly: true, + destructive: false, + idempotent: true, + }, + schema: { + input: z => + z.object({ + max: z + .number() + .int() + .positive() + .optional() + .describe('Maximum number of results to return'), + page: z + .number() + .int() + .positive() + .optional() + .describe('Page number (1-based) for pagination'), + category: z.string().optional().describe('Filter by category slug'), + tags: z.array(z.string()).optional().describe('Filter by tag slugs'), + active: z.boolean().optional().describe('Filter by active status'), + sortBy: z + .enum(['created_at', 'start_at', 'updated_at']) + .optional() + .describe('Field to sort by (default: created_at)'), + order: z + .enum(['asc', 'desc']) + .optional() + .describe('Sort order (default: desc)'), + }), + output: z => + z.object({ + count: z + .number() + .describe('Total number of announcements matching the query'), + announcements: z.array( + z.object({ + id: z.string(), + title: z.string(), + excerpt: z.string(), + body: z.string(), + publisher: z.string(), + active: z.boolean(), + created_at: z.string(), + start_at: z.string(), + until_date: z.string().nullable().optional(), + updated_at: z.string(), + category: z + .object({ slug: z.string(), title: z.string() }) + .optional(), + on_behalf_of: z.string().optional(), + tags: z + .array(z.object({ slug: z.string(), title: z.string() })) + .optional(), + }), + ), + }), + }, + async action({ input }) { + const { max, page, category, tags, active, sortBy, order } = input; + const results = await persistenceContext.announcementsStore.announcements( + { + max, + offset: page ? (page - 1) * (max ?? 10) : undefined, + category, + tags, + active, + sortBy: sortBy ?? 'created_at', + order: order ?? 'desc', + }, + ); + + return { + output: { + count: results.count, + announcements: results.results.map(a => ({ + id: a.id, + title: a.title, + excerpt: a.excerpt, + body: a.body, + publisher: a.publisher, + active: a.active, + created_at: a.created_at.toUTC().toISO()!, + start_at: a.start_at.toUTC().toISO()!, + until_date: a.until_date ? a.until_date.toUTC().toISO() : undefined, + updated_at: a.updated_at.toUTC().toISO()!, + category: a.category, + on_behalf_of: a.on_behalf_of, + tags: a.tags, + })), + }, + }; + }, + }); +} diff --git a/workspaces/announcements/plugins/announcements-backend/src/actions/index.ts b/workspaces/announcements/plugins/announcements-backend/src/actions/index.ts new file mode 100644 index 00000000000..bd901b90ebf --- /dev/null +++ b/workspaces/announcements/plugins/announcements-backend/src/actions/index.ts @@ -0,0 +1,42 @@ +/* + * 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. + */ +export { createListAnnouncementsAction } from './createListAnnouncementsAction'; +export { createGetAnnouncementAction } from './createGetAnnouncementAction'; +export { createCreateAnnouncementAction } from './createCreateAnnouncementAction'; +export { createDeleteAnnouncementAction } from './createDeleteAnnouncementAction'; + +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { PermissionsService } from '@backstage/backend-plugin-api'; +import { PersistenceContext } from '../service/persistence'; +import { createListAnnouncementsAction } from './createListAnnouncementsAction'; +import { createGetAnnouncementAction } from './createGetAnnouncementAction'; +import { createCreateAnnouncementAction } from './createCreateAnnouncementAction'; +import { createDeleteAnnouncementAction } from './createDeleteAnnouncementAction'; + +/** + * Registers all ActionsRegistryService actions for the announcements plugin. + * @internal + */ +export function createAnnouncementsActions(options: { + actionsRegistry: ActionsRegistryService; + persistenceContext: PersistenceContext; + permissions: PermissionsService; +}) { + createListAnnouncementsAction(options); + createGetAnnouncementAction(options); + createCreateAnnouncementAction(options); + createDeleteAnnouncementAction(options); +} diff --git a/workspaces/announcements/plugins/announcements-backend/src/plugin.ts b/workspaces/announcements/plugins/announcements-backend/src/plugin.ts index 3bc20bebe38..7dde4e6adac 100644 --- a/workspaces/announcements/plugins/announcements-backend/src/plugin.ts +++ b/workspaces/announcements/plugins/announcements-backend/src/plugin.ts @@ -17,12 +17,14 @@ import { createBackendPlugin, coreServices, } from '@backstage/backend-plugin-api'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { createRouter } from './router'; import { signalsServiceRef } from '@backstage/plugin-signals-node'; import { eventsServiceRef } from '@backstage/plugin-events-node'; import { buildAnnouncementsContext } from './service'; import { announcementEntityPermissions } from '@backstage-community/plugin-announcements-common'; import { notificationService } from '@backstage/plugin-notifications-node'; +import { createAnnouncementsActions } from './actions'; /** * A backend for the announcements plugin. @@ -44,6 +46,7 @@ export const announcementsPlugin = createBackendPlugin({ signals: signalsServiceRef, notifications: notificationService, auditor: coreServices.auditor, + actionsRegistry: actionsRegistryServiceRef, }, async init({ config, @@ -57,6 +60,7 @@ export const announcementsPlugin = createBackendPlugin({ signals, notifications, auditor, + actionsRegistry, }) { const context = await buildAnnouncementsContext({ config, @@ -78,6 +82,12 @@ export const announcementsPlugin = createBackendPlugin({ const router = await createRouter(context); http.use(router); + + createAnnouncementsActions({ + actionsRegistry, + persistenceContext: context.persistenceContext, + permissions, + }); }, }); },