Skip to content

Commit 2ada29e

Browse files
committed
feat(announcements): register actions for announcements
Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
1 parent c665f7c commit 2ada29e

11 files changed

Lines changed: 1073 additions & 0 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@backstage-community/plugin-announcements-backend': patch
3+
---
4+
5+
Added actions for announcement management.
6+
7+
Registered four actions that expose the announcements backend via MCP and scaffolder templates:
8+
9+
- `announcements:list-announcements` — list announcements with optional filters for category, tags, active status, and pagination
10+
- `announcements:get-announcement` — fetch full details of a single announcement by ID
11+
- `announcements:create-announcement` — create a new announcement (requires `announcement.entity.create` permission)
12+
- `announcements:delete-announcement` — delete an announcement by ID (requires `announcement.entity.delete` permission)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright 2025 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
17+
import { NotAllowedError } from '@backstage/errors';
18+
import { AuthorizeResult } from '@backstage/plugin-permission-common';
19+
import { DateTime } from 'luxon';
20+
import { createCreateAnnouncementAction } from './createCreateAnnouncementAction';
21+
import { AnnouncementsDatabase } from '../service/persistence/AnnouncementsDatabase';
22+
import { PersistenceContext } from '../service/persistence';
23+
24+
const nowIso = '2025-01-15T10:00:00.000Z';
25+
const now = DateTime.fromISO(nowIso);
26+
27+
const mockInsertedAnnouncement = {
28+
id: 'ann-new',
29+
title: 'My New Announcement',
30+
excerpt: 'An excerpt',
31+
body: 'Full body',
32+
publisher: 'user:default/alice',
33+
active: true,
34+
created_at: now,
35+
start_at: now,
36+
until_date: undefined,
37+
updated_at: now,
38+
category: undefined,
39+
on_behalf_of: undefined,
40+
sendNotification: false,
41+
};
42+
43+
describe('createCreateAnnouncementAction', () => {
44+
let mockActionsRegistry: { register: jest.Mock };
45+
let mockStore: jest.Mocked<AnnouncementsDatabase>;
46+
let mockPersistenceContext: PersistenceContext;
47+
let mockPermissions: { authorize: jest.Mock };
48+
49+
beforeEach(() => {
50+
mockActionsRegistry = { register: jest.fn() };
51+
52+
mockStore = {
53+
insertAnnouncement: jest
54+
.fn()
55+
.mockResolvedValue(mockInsertedAnnouncement),
56+
} as any;
57+
58+
mockPersistenceContext = {
59+
announcementsStore: mockStore,
60+
} as any;
61+
62+
mockPermissions = {
63+
authorize: jest
64+
.fn()
65+
.mockResolvedValue([{ result: AuthorizeResult.ALLOW }]),
66+
};
67+
68+
createCreateAnnouncementAction({
69+
actionsRegistry: mockActionsRegistry as any,
70+
persistenceContext: mockPersistenceContext,
71+
permissions: mockPermissions as any,
72+
});
73+
});
74+
75+
it('registers the announcements:create-announcement action', () => {
76+
expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1);
77+
const reg = mockActionsRegistry.register.mock.calls[0][0];
78+
expect(reg.name).toBe('announcements:create-announcement');
79+
expect(reg.attributes.readOnly).toBe(false);
80+
expect(reg.attributes.destructive).toBe(false);
81+
expect(reg.attributes.idempotent).toBe(false);
82+
expect(reg.visibilityPermission).toBeDefined();
83+
expect(reg.visibilityPermission.name).toBe('announcement.entity.create');
84+
});
85+
86+
it('creates an announcement and returns its details', async () => {
87+
const reg = mockActionsRegistry.register.mock.calls[0][0];
88+
const credentials = mockCredentials.user();
89+
90+
const result = await reg.action({
91+
input: {
92+
title: 'My New Announcement',
93+
excerpt: 'An excerpt',
94+
body: 'Full body',
95+
publisher: 'user:default/alice',
96+
active: true,
97+
start_at: nowIso,
98+
sendNotification: false,
99+
},
100+
credentials,
101+
logger: mockServices.logger.mock(),
102+
});
103+
104+
expect(mockPermissions.authorize).toHaveBeenCalledWith(
105+
[expect.objectContaining({ permission: expect.objectContaining({ name: 'announcement.entity.create' }) })],
106+
{ credentials },
107+
);
108+
expect(mockStore.insertAnnouncement).toHaveBeenCalledWith(
109+
expect.objectContaining({
110+
title: 'My New Announcement',
111+
publisher: 'user:default/alice',
112+
}),
113+
);
114+
expect(result.output.id).toBe('ann-new');
115+
expect(result.output.title).toBe('My New Announcement');
116+
expect(result.output.created_at).toBe(nowIso);
117+
});
118+
119+
it('throws NotAllowedError when permission is denied', async () => {
120+
mockPermissions.authorize.mockResolvedValue([
121+
{ result: AuthorizeResult.DENY },
122+
]);
123+
const reg = mockActionsRegistry.register.mock.calls[0][0];
124+
const credentials = mockCredentials.user();
125+
126+
await expect(
127+
reg.action({
128+
input: {
129+
title: 'Unauthorized',
130+
excerpt: 'excerpt',
131+
body: 'body',
132+
publisher: 'user:default/alice',
133+
active: true,
134+
start_at: nowIso,
135+
sendNotification: false,
136+
},
137+
credentials,
138+
logger: mockServices.logger.mock(),
139+
}),
140+
).rejects.toThrow(NotAllowedError);
141+
});
142+
});
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/*
2+
* Copyright 2025 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import {
17+
ActionsRegistryService,
18+
} from '@backstage/backend-plugin-api/alpha';
19+
import { PermissionsService } from '@backstage/backend-plugin-api';
20+
import { NotAllowedError } from '@backstage/errors';
21+
import { AuthorizeResult } from '@backstage/plugin-permission-common';
22+
import {
23+
announcementEntityPermissions,
24+
} from '@backstage-community/plugin-announcements-common';
25+
import { DateTime } from 'luxon';
26+
import { v4 as uuid } from 'uuid';
27+
import { PersistenceContext } from '../service/persistence';
28+
29+
const { announcementCreatePermission } = announcementEntityPermissions;
30+
31+
/**
32+
* Registers the `announcements:create-announcement` action.
33+
* @internal
34+
*/
35+
export function createCreateAnnouncementAction(options: {
36+
actionsRegistry: ActionsRegistryService;
37+
persistenceContext: PersistenceContext;
38+
permissions: PermissionsService;
39+
}) {
40+
const { actionsRegistry, persistenceContext, permissions } = options;
41+
42+
actionsRegistry.register({
43+
name: 'announcements:create-announcement',
44+
title: 'Create Announcement',
45+
description:
46+
'Create a new announcement. Requires the announcement.entity.create permission.',
47+
attributes: {
48+
readOnly: false,
49+
destructive: false,
50+
idempotent: false,
51+
},
52+
visibilityPermission: announcementCreatePermission,
53+
schema: {
54+
input: z =>
55+
z.object({
56+
title: z.string().describe('Title of the announcement'),
57+
excerpt: z
58+
.string()
59+
.describe('Short summary shown in listing views'),
60+
body: z.string().describe('Full body text of the announcement'),
61+
publisher: z
62+
.string()
63+
.describe('User or team publishing the announcement'),
64+
active: z
65+
.boolean()
66+
.default(true)
67+
.describe('Whether the announcement is active'),
68+
start_at: z
69+
.string()
70+
.describe('ISO 8601 datetime when the announcement becomes active'),
71+
until_date: z
72+
.string()
73+
.optional()
74+
.describe(
75+
'ISO 8601 datetime when the announcement expires (optional)',
76+
),
77+
category: z
78+
.string()
79+
.optional()
80+
.describe('Category slug to assign to the announcement'),
81+
on_behalf_of: z
82+
.string()
83+
.optional()
84+
.describe('Optional entity ref the announcement is on behalf of'),
85+
tags: z
86+
.array(z.string())
87+
.optional()
88+
.describe('Tag slugs to attach to the announcement'),
89+
sendNotification: z
90+
.boolean()
91+
.default(false)
92+
.describe('Whether to send a notification when the announcement is created'),
93+
}),
94+
output: z =>
95+
z.object({
96+
id: z.string(),
97+
title: z.string(),
98+
excerpt: z.string(),
99+
body: z.string(),
100+
publisher: z.string(),
101+
active: z.boolean(),
102+
created_at: z.string(),
103+
start_at: z.string(),
104+
until_date: z.string().nullable().optional(),
105+
updated_at: z.string(),
106+
category: z
107+
.object({ slug: z.string(), title: z.string() })
108+
.optional(),
109+
on_behalf_of: z.string().optional(),
110+
}),
111+
},
112+
async action({ input, credentials }) {
113+
const decision = await permissions.authorize(
114+
[{ permission: announcementCreatePermission }],
115+
{ credentials },
116+
);
117+
118+
if (decision[0].result === AuthorizeResult.DENY) {
119+
throw new NotAllowedError(
120+
'Unauthorized: missing announcement.entity.create permission',
121+
);
122+
}
123+
124+
const now = DateTime.now();
125+
const startAt = DateTime.fromISO(input.start_at);
126+
const untilDate = input.until_date
127+
? DateTime.fromISO(input.until_date)
128+
: undefined;
129+
130+
const announcement =
131+
await persistenceContext.announcementsStore.insertAnnouncement({
132+
id: uuid(),
133+
title: input.title,
134+
excerpt: input.excerpt,
135+
body: input.body,
136+
publisher: input.publisher,
137+
active: input.active,
138+
sendNotification: input.sendNotification,
139+
category: input.category,
140+
on_behalf_of: input.on_behalf_of,
141+
tags: input.tags,
142+
created_at: now,
143+
start_at: startAt,
144+
until_date: untilDate,
145+
updated_at: now,
146+
});
147+
148+
return {
149+
output: {
150+
id: announcement.id,
151+
title: announcement.title,
152+
excerpt: announcement.excerpt,
153+
body: announcement.body,
154+
publisher: announcement.publisher,
155+
active: announcement.active,
156+
created_at: announcement.created_at.toISO()!,
157+
start_at: announcement.start_at.toISO()!,
158+
until_date: announcement.until_date
159+
? announcement.until_date.toISO()
160+
: undefined,
161+
updated_at: announcement.updated_at.toISO()!,
162+
category: announcement.category,
163+
on_behalf_of: announcement.on_behalf_of,
164+
},
165+
};
166+
},
167+
});
168+
}

0 commit comments

Comments
 (0)