@access-mcp/announcements 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -135
- package/dist/server.d.ts +85 -90
- package/dist/server.js +905 -152
- package/package.json +2 -2
- package/src/index.ts +1 -1
- package/src/server.integration.test.ts +355 -26
- package/src/server.test.ts +1194 -30
- package/src/server.ts +1156 -58
- package/vitest.integration.config.ts +1 -1
package/dist/server.js
CHANGED
|
@@ -1,87 +1,314 @@
|
|
|
1
|
-
import { BaseAccessServer } from "@access-mcp/shared";
|
|
1
|
+
import { BaseAccessServer, DrupalAuthProvider, getRequestContext, } from "@access-mcp/shared";
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const { version } = require("../package.json");
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Server
|
|
7
|
+
// ============================================================================
|
|
2
8
|
export class AnnouncementsServer extends BaseAccessServer {
|
|
9
|
+
drupalAuth;
|
|
10
|
+
tagCache = new Map(); // name -> uuid
|
|
11
|
+
tagCacheExpiry;
|
|
12
|
+
static TAG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
3
13
|
constructor() {
|
|
4
|
-
super("access-announcements",
|
|
14
|
+
super("access-announcements", version, "https://support.access-ci.org", {
|
|
15
|
+
requireApiKey: true,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Get or create the Drupal auth provider for JSON:API write operations.
|
|
20
|
+
* Requires DRUPAL_API_URL, DRUPAL_USERNAME, and DRUPAL_PASSWORD env vars.
|
|
21
|
+
*
|
|
22
|
+
* Acting user is determined in priority order:
|
|
23
|
+
* 1. X-Acting-User header (from request context)
|
|
24
|
+
* 2. ACTING_USER environment variable (fallback)
|
|
25
|
+
*/
|
|
26
|
+
getDrupalAuth() {
|
|
27
|
+
if (!this.drupalAuth) {
|
|
28
|
+
const baseUrl = process.env.DRUPAL_API_URL;
|
|
29
|
+
const username = process.env.DRUPAL_USERNAME;
|
|
30
|
+
const password = process.env.DRUPAL_PASSWORD;
|
|
31
|
+
if (!baseUrl || !username || !password) {
|
|
32
|
+
throw new Error("CRUD operations require DRUPAL_API_URL, DRUPAL_USERNAME, and DRUPAL_PASSWORD environment variables");
|
|
33
|
+
}
|
|
34
|
+
this.drupalAuth = new DrupalAuthProvider(baseUrl, username, password);
|
|
35
|
+
}
|
|
36
|
+
// Update acting user from request context (takes priority) or env var (fallback)
|
|
37
|
+
const context = getRequestContext();
|
|
38
|
+
const actingUser = context?.actingUser || process.env.ACTING_USER;
|
|
39
|
+
if (actingUser) {
|
|
40
|
+
this.drupalAuth.setActingUser(actingUser);
|
|
41
|
+
}
|
|
42
|
+
return this.drupalAuth;
|
|
5
43
|
}
|
|
6
44
|
getTools() {
|
|
7
45
|
return [
|
|
46
|
+
// Read operations (existing)
|
|
8
47
|
{
|
|
9
48
|
name: "search_announcements",
|
|
10
|
-
description: "Search ACCESS announcements
|
|
49
|
+
description: "Search ACCESS announcements (news, updates, notifications). Read-only view of public announcements. For managing your own announcements (update/delete), use get_my_announcements which returns UUIDs. Returns {total, items: [{title, summary, body, published_date, tags, affiliation, affinity_group}]}.",
|
|
11
50
|
inputSchema: {
|
|
12
51
|
type: "object",
|
|
13
52
|
properties: {
|
|
53
|
+
query: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "Full-text search across title, body, and summary",
|
|
56
|
+
},
|
|
14
57
|
tags: {
|
|
15
58
|
type: "string",
|
|
16
|
-
description: "Filter by
|
|
59
|
+
description: "Filter by topics: gpu, ml, hpc",
|
|
60
|
+
},
|
|
61
|
+
date: {
|
|
62
|
+
type: "string",
|
|
63
|
+
description: "Time period filter: today, this_week (last 7 days), this_month (last 30 days), past (last year)",
|
|
64
|
+
enum: ["today", "this_week", "this_month", "past"],
|
|
65
|
+
},
|
|
66
|
+
limit: {
|
|
67
|
+
type: "number",
|
|
68
|
+
description: "Max results (default: 25)",
|
|
69
|
+
default: 25,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
// CRUD operations (new)
|
|
75
|
+
{
|
|
76
|
+
name: "create_announcement",
|
|
77
|
+
description: `Create a new ACCESS announcement (saved as draft for staff review).
|
|
78
|
+
|
|
79
|
+
BEFORE CALLING:
|
|
80
|
+
1. Call get_announcement_context first to get tags, check coordinator status, and tailor the conversation.
|
|
81
|
+
2. Gather information - either conversationally OR by parsing pasted content:
|
|
82
|
+
- title (required): Clear, specific headline
|
|
83
|
+
- body (required): Full content with details, dates, links. HTML supported.
|
|
84
|
+
- summary (required): 1-2 sentence teaser for listings
|
|
85
|
+
- tags (recommended): 1-6 tags help users find it
|
|
86
|
+
- affiliation (optional): "ACCESS Collaboration" or "Community" (default)
|
|
87
|
+
- external_link (if relevant): URL and link text for external references
|
|
88
|
+
3. IF user is a coordinator (check get_announcement_context response):
|
|
89
|
+
- affinity_group: Ask which group to associate with
|
|
90
|
+
- where_to_share: Ask preferences (defaults: Announcements page + Bi-Weekly Digest)
|
|
91
|
+
|
|
92
|
+
WORKFLOW:
|
|
93
|
+
If user pastes content (event description, draft text, etc.):
|
|
94
|
+
1. Call get_announcement_context
|
|
95
|
+
2. Parse the pasted content to extract title, body, dates, links, etc.
|
|
96
|
+
3. Match content to available tags from context
|
|
97
|
+
4. Ask only about missing/unclear fields
|
|
98
|
+
5. If coordinator: ask about affinity group and where to share
|
|
99
|
+
6. Show preview and get confirmation
|
|
100
|
+
7. Create the announcement
|
|
101
|
+
|
|
102
|
+
If user describes what they want conversationally:
|
|
103
|
+
1. Call get_announcement_context
|
|
104
|
+
2. Guide them through providing title, body, summary
|
|
105
|
+
3. Suggest relevant tags from context
|
|
106
|
+
4. If coordinator: ask about affinity group and where to share
|
|
107
|
+
5. Show preview and get confirmation
|
|
108
|
+
6. Create the announcement
|
|
109
|
+
|
|
110
|
+
PREVIEW FORMAT (show before creating):
|
|
111
|
+
---
|
|
112
|
+
**Title:** [title]
|
|
113
|
+
**Summary:** [summary or "None provided"]
|
|
114
|
+
**Body:**
|
|
115
|
+
[body content, truncate if very long]
|
|
116
|
+
|
|
117
|
+
**Tags:** [tag1, tag2, ...] or "None"
|
|
118
|
+
**Affiliation:** [affiliation or "Community (default)"]
|
|
119
|
+
**External Link:** [link text](url) or omit if none
|
|
120
|
+
[If coordinator:]
|
|
121
|
+
**Affinity Group:** [group name] or "None"
|
|
122
|
+
**Share to:** [list of selected options]
|
|
123
|
+
---
|
|
124
|
+
Ask "Does this look correct?" before creating.
|
|
125
|
+
|
|
126
|
+
Returns: {success, uuid, title, edit_url}
|
|
127
|
+
ALWAYS display the edit_url to the user so they can review their draft in Drupal.`,
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: "object",
|
|
130
|
+
properties: {
|
|
131
|
+
title: {
|
|
132
|
+
type: "string",
|
|
133
|
+
description: "Announcement title - clear and specific, under 100 characters",
|
|
17
134
|
},
|
|
18
|
-
|
|
135
|
+
body: {
|
|
19
136
|
type: "string",
|
|
20
|
-
description: "
|
|
137
|
+
description: "Full announcement content. HTML allowed (basic_html format: <p>, <a>, <strong>, <em>, <ul>, <li>)",
|
|
138
|
+
},
|
|
139
|
+
summary: {
|
|
140
|
+
type: "string",
|
|
141
|
+
description: "Brief teaser (1-2 sentences) shown in announcement listings. Required.",
|
|
142
|
+
},
|
|
143
|
+
published_date: {
|
|
144
|
+
type: "string",
|
|
145
|
+
description: "When to display (YYYY-MM-DD). Defaults to today.",
|
|
146
|
+
},
|
|
147
|
+
tags: {
|
|
148
|
+
type: "array",
|
|
149
|
+
items: { type: "string" },
|
|
150
|
+
description: "Tag names (1-6 required for publication). Use list_available_tags to find valid tags.",
|
|
21
151
|
},
|
|
22
152
|
affiliation: {
|
|
23
153
|
type: "string",
|
|
24
|
-
description: "
|
|
154
|
+
description: "Source of announcement",
|
|
155
|
+
enum: ["ACCESS Collaboration", "Community"],
|
|
25
156
|
},
|
|
26
|
-
|
|
157
|
+
affinity_group: {
|
|
27
158
|
type: "string",
|
|
28
|
-
description: "
|
|
159
|
+
description: "Affinity group name or UUID. User must be coordinator of the group. Use list_affinity_groups to find valid groups.",
|
|
160
|
+
},
|
|
161
|
+
external_link: {
|
|
162
|
+
type: "object",
|
|
163
|
+
description: "External link related to this announcement",
|
|
164
|
+
properties: {
|
|
165
|
+
uri: { type: "string", description: "URL (must include https://)" },
|
|
166
|
+
title: { type: "string", description: "Link text to display" },
|
|
167
|
+
},
|
|
168
|
+
required: ["uri"],
|
|
29
169
|
},
|
|
30
|
-
|
|
170
|
+
where_to_share: {
|
|
171
|
+
type: "array",
|
|
172
|
+
items: { type: "string" },
|
|
173
|
+
description: "Where to share the announcement. Defaults to 'Announcements page' + 'Bi-Weekly Digest'. Options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
required: ["title", "body", "summary"],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: "update_announcement",
|
|
181
|
+
description: `Update an existing announcement. User must own the announcement.
|
|
182
|
+
|
|
183
|
+
BEFORE CALLING:
|
|
184
|
+
1. Use get_my_announcements to find the announcement UUID
|
|
185
|
+
2. Confirm which fields the user wants to change
|
|
186
|
+
3. Only include fields that are changing
|
|
187
|
+
4. Show a preview of the changes and ask for confirmation before updating
|
|
188
|
+
|
|
189
|
+
Returns: {success, uuid, title, edit_url}
|
|
190
|
+
ALWAYS display the edit_url to the user so they can review changes in Drupal.`,
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
uuid: {
|
|
31
195
|
type: "string",
|
|
32
|
-
description: "
|
|
196
|
+
description: "UUID of the announcement (get from get_my_announcements)",
|
|
33
197
|
},
|
|
34
|
-
|
|
198
|
+
title: {
|
|
35
199
|
type: "string",
|
|
36
|
-
description: "
|
|
37
|
-
format: "date"
|
|
200
|
+
description: "New title (only if changing)",
|
|
38
201
|
},
|
|
39
|
-
|
|
202
|
+
body: {
|
|
40
203
|
type: "string",
|
|
41
|
-
description: "
|
|
42
|
-
|
|
204
|
+
description: "New body content (only if changing)",
|
|
205
|
+
},
|
|
206
|
+
summary: {
|
|
207
|
+
type: "string",
|
|
208
|
+
description: "New summary (only if changing)",
|
|
209
|
+
},
|
|
210
|
+
published_date: {
|
|
211
|
+
type: "string",
|
|
212
|
+
description: "New publication date YYYY-MM-DD (only if changing)",
|
|
213
|
+
},
|
|
214
|
+
tags: {
|
|
215
|
+
type: "array",
|
|
216
|
+
items: { type: "string" },
|
|
217
|
+
description: "New tags - replaces ALL existing tags (only if changing)",
|
|
218
|
+
},
|
|
219
|
+
affinity_group: {
|
|
220
|
+
type: "string",
|
|
221
|
+
description: "Affinity group name or UUID. User must be coordinator.",
|
|
222
|
+
},
|
|
223
|
+
external_link: {
|
|
224
|
+
type: "object",
|
|
225
|
+
description: "External link related to this announcement",
|
|
226
|
+
properties: {
|
|
227
|
+
uri: { type: "string", description: "URL (must include https://)" },
|
|
228
|
+
title: { type: "string", description: "Link text to display" },
|
|
229
|
+
},
|
|
230
|
+
required: ["uri"],
|
|
231
|
+
},
|
|
232
|
+
where_to_share: {
|
|
233
|
+
type: "array",
|
|
234
|
+
items: { type: "string" },
|
|
235
|
+
description: "Where to share the announcement. Options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'",
|
|
43
236
|
},
|
|
44
|
-
limit: {
|
|
45
|
-
type: "number",
|
|
46
|
-
description: "Maximum number of announcements to return. Requested limit will be rounded to nearest valid API page size (5, 10, 25, or 50). Use 5-10 for quick overviews, 25-50 for comprehensive searches.",
|
|
47
|
-
default: 25
|
|
48
|
-
}
|
|
49
237
|
},
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
238
|
+
required: ["uuid"],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: "delete_announcement",
|
|
243
|
+
description: `Permanently delete an announcement. User must own the announcement.
|
|
244
|
+
|
|
245
|
+
CRITICAL: NEVER delete without explicit user confirmation for EACH announcement.
|
|
246
|
+
|
|
247
|
+
BEFORE CALLING (required for EVERY delete):
|
|
248
|
+
1. Use get_my_announcements to find the announcement
|
|
249
|
+
2. Show the user: "Delete '[title]' (status: [status])? This cannot be undone."
|
|
250
|
+
3. For published announcements, add warning: "⚠️ This is currently visible to the public."
|
|
251
|
+
4. Wait for explicit "yes" or confirmation - general statements like "sure" or "go ahead" are NOT sufficient
|
|
252
|
+
5. Only proceed after receiving clear confirmation for THIS SPECIFIC announcement
|
|
253
|
+
|
|
254
|
+
BULK DELETES: When user asks to delete multiple announcements, confirm EACH ONE individually.
|
|
255
|
+
Do NOT batch delete based on general consent. Each deletion requires its own confirmation prompt.
|
|
256
|
+
|
|
257
|
+
Returns: {success, uuid}`,
|
|
258
|
+
inputSchema: {
|
|
259
|
+
type: "object",
|
|
260
|
+
properties: {
|
|
261
|
+
uuid: {
|
|
262
|
+
type: "string",
|
|
263
|
+
description: "UUID of the announcement to delete",
|
|
57
264
|
},
|
|
58
|
-
{
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
tags: "gpu,nvidia",
|
|
62
|
-
relative_start_date: "-1 year",
|
|
63
|
-
limit: 25
|
|
64
|
-
}
|
|
265
|
+
confirmed: {
|
|
266
|
+
type: "boolean",
|
|
267
|
+
description: "Set to true only after user has explicitly confirmed deletion of THIS specific announcement. Required.",
|
|
65
268
|
},
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
269
|
+
},
|
|
270
|
+
required: ["uuid", "confirmed"],
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: "get_my_announcements",
|
|
275
|
+
description: `List all announcements created by the authenticated user.
|
|
276
|
+
|
|
277
|
+
Returns announcements with: uuid, nid, title, status (draft/published), created date, published_date, summary, edit_url
|
|
278
|
+
|
|
279
|
+
Use this to:
|
|
280
|
+
- Find announcement UUIDs for update/delete operations
|
|
281
|
+
- Check status of submitted announcements
|
|
282
|
+
- Review what the user has created
|
|
283
|
+
- Get edit_url links for users to review in Drupal`,
|
|
284
|
+
inputSchema: {
|
|
285
|
+
type: "object",
|
|
286
|
+
properties: {
|
|
287
|
+
limit: {
|
|
288
|
+
type: "number",
|
|
289
|
+
description: "Max results (default: 25)",
|
|
290
|
+
default: 25,
|
|
73
291
|
},
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
name: "get_announcement_context",
|
|
297
|
+
description: `Get user context and options BEFORE creating an announcement. Call this first.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
- tags: Available tags for announcements [{name, uuid}]
|
|
301
|
+
- affinity_groups: Groups the user coordinates (empty if not a coordinator)
|
|
302
|
+
- is_coordinator: Boolean - if true, ask about affinity_group and where_to_share
|
|
303
|
+
- affiliations: Available affiliation options
|
|
304
|
+
- where_to_share_options: Available sharing options (only relevant for coordinators)
|
|
305
|
+
|
|
306
|
+
Use this to tailor the announcement creation conversation based on the user's role.`,
|
|
307
|
+
inputSchema: {
|
|
308
|
+
type: "object",
|
|
309
|
+
properties: {},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
85
312
|
];
|
|
86
313
|
}
|
|
87
314
|
getResources() {
|
|
@@ -90,29 +317,157 @@ export class AnnouncementsServer extends BaseAccessServer {
|
|
|
90
317
|
uri: "accessci://announcements",
|
|
91
318
|
name: "ACCESS Support Announcements",
|
|
92
319
|
description: "Recent announcements and notifications from ACCESS support",
|
|
93
|
-
mimeType: "application/json"
|
|
94
|
-
}
|
|
320
|
+
mimeType: "application/json",
|
|
321
|
+
},
|
|
322
|
+
];
|
|
323
|
+
}
|
|
324
|
+
getPrompts() {
|
|
325
|
+
return [
|
|
326
|
+
{
|
|
327
|
+
name: "create_announcement_guide",
|
|
328
|
+
description: "Guide the user through creating a new ACCESS announcement step by step",
|
|
329
|
+
arguments: [
|
|
330
|
+
{
|
|
331
|
+
name: "topic",
|
|
332
|
+
description: "Brief description of what the announcement is about (optional, helps tailor guidance)",
|
|
333
|
+
required: false,
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: "manage_announcements_guide",
|
|
339
|
+
description: "Help the user view, update, or delete their existing announcements",
|
|
340
|
+
arguments: [],
|
|
341
|
+
},
|
|
95
342
|
];
|
|
96
343
|
}
|
|
344
|
+
async handleGetPrompt(request) {
|
|
345
|
+
const { name, arguments: args = {} } = request.params;
|
|
346
|
+
if (name === "create_announcement_guide") {
|
|
347
|
+
const topic = args.topic || "";
|
|
348
|
+
return {
|
|
349
|
+
description: "Guide for creating an ACCESS announcement",
|
|
350
|
+
messages: [
|
|
351
|
+
{
|
|
352
|
+
role: "user",
|
|
353
|
+
content: {
|
|
354
|
+
type: "text",
|
|
355
|
+
text: topic
|
|
356
|
+
? `I want to create an announcement about: ${topic}`
|
|
357
|
+
: "I want to create a new announcement",
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
role: "assistant",
|
|
362
|
+
content: {
|
|
363
|
+
type: "text",
|
|
364
|
+
text: `I'll help you create an ACCESS announcement. Let me first check your available options and coordinator status.
|
|
365
|
+
|
|
366
|
+
First, I'll call \`get_announcement_context\` to see what tags are available and whether you coordinate any affinity groups.
|
|
367
|
+
|
|
368
|
+
**Required Information:**
|
|
369
|
+
|
|
370
|
+
1. **Title** - A clear, concise title (under 100 characters)
|
|
371
|
+
|
|
372
|
+
2. **Body** - The main content (HTML supported)
|
|
373
|
+
|
|
374
|
+
**Recommended:**
|
|
375
|
+
|
|
376
|
+
3. **Summary** - Brief teaser (1-2 sentences) for listings
|
|
377
|
+
|
|
378
|
+
4. **Tags** - Help users find your announcement (1-6 recommended)
|
|
379
|
+
|
|
380
|
+
**Optional:**
|
|
381
|
+
|
|
382
|
+
5. **Affiliation** - "ACCESS Collaboration" or "Community" (default)
|
|
383
|
+
|
|
384
|
+
6. **External Link** - If referencing external content
|
|
385
|
+
|
|
386
|
+
**For Affinity Group Coordinators:**
|
|
387
|
+
- Associate with your affinity group
|
|
388
|
+
- Choose where to share (Announcements page, Bi-Weekly Digest, Affinity Group page, Email to group)
|
|
389
|
+
|
|
390
|
+
**Important:** Announcements are created as **drafts** requiring staff review. You'll receive an edit URL.
|
|
391
|
+
|
|
392
|
+
Let me check your context first, then we'll get started!${topic ? `\n\nI see you want to announce something about "${topic}" - I'll help you craft that.` : ""}`,
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
if (name === "manage_announcements_guide") {
|
|
399
|
+
return {
|
|
400
|
+
description: "Guide for managing existing announcements",
|
|
401
|
+
messages: [
|
|
402
|
+
{
|
|
403
|
+
role: "user",
|
|
404
|
+
content: {
|
|
405
|
+
type: "text",
|
|
406
|
+
text: "I want to manage my announcements",
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
role: "assistant",
|
|
411
|
+
content: {
|
|
412
|
+
type: "text",
|
|
413
|
+
text: `I can help you manage your ACCESS announcements. Here's what you can do:
|
|
414
|
+
|
|
415
|
+
**View Your Announcements**
|
|
416
|
+
Use \`get_my_announcements\` to see all announcements you've created, including:
|
|
417
|
+
- Draft announcements awaiting review
|
|
418
|
+
- Published announcements
|
|
419
|
+
- Their current status
|
|
420
|
+
|
|
421
|
+
**Update an Announcement**
|
|
422
|
+
Use \`update_announcement\` with the announcement's UUID to modify:
|
|
423
|
+
- Title
|
|
424
|
+
- Body content
|
|
425
|
+
- Summary
|
|
426
|
+
- Published date
|
|
427
|
+
- Tags
|
|
428
|
+
|
|
429
|
+
**Delete an Announcement**
|
|
430
|
+
Use \`delete_announcement\` with the UUID to remove an announcement you own.
|
|
431
|
+
|
|
432
|
+
Would you like me to:
|
|
433
|
+
1. **List your announcements** - See what you've created
|
|
434
|
+
2. **Update a specific announcement** - Make changes to an existing one
|
|
435
|
+
3. **Delete an announcement** - Remove one you no longer need
|
|
436
|
+
|
|
437
|
+
Which would you like to do?`,
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
throw new Error(`Unknown prompt: ${name}`);
|
|
444
|
+
}
|
|
97
445
|
async handleToolCall(request) {
|
|
98
446
|
const { name, arguments: args } = request.params;
|
|
99
447
|
try {
|
|
100
448
|
switch (name) {
|
|
449
|
+
// Read operations
|
|
101
450
|
case "search_announcements":
|
|
102
451
|
return await this.searchAnnouncements(args);
|
|
452
|
+
// CRUD operations
|
|
453
|
+
case "create_announcement":
|
|
454
|
+
return await this.createAnnouncement(args);
|
|
455
|
+
case "update_announcement":
|
|
456
|
+
return await this.updateAnnouncement(args);
|
|
457
|
+
case "delete_announcement":
|
|
458
|
+
return await this.deleteAnnouncement(args);
|
|
459
|
+
case "get_my_announcements":
|
|
460
|
+
return await this.getMyAnnouncements(args);
|
|
461
|
+
// Helper operations
|
|
462
|
+
case "get_announcement_context":
|
|
463
|
+
return await this.getAnnouncementContext();
|
|
103
464
|
default:
|
|
104
|
-
|
|
465
|
+
return this.errorResponse(`Unknown tool: ${name}`);
|
|
105
466
|
}
|
|
106
467
|
}
|
|
107
468
|
catch (error) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
type: "text",
|
|
112
|
-
text: `Error: ${error.message}`
|
|
113
|
-
}
|
|
114
|
-
]
|
|
115
|
-
};
|
|
469
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
470
|
+
return this.errorResponse(message);
|
|
116
471
|
}
|
|
117
472
|
}
|
|
118
473
|
async handleResourceRead(request) {
|
|
@@ -125,30 +480,31 @@ export class AnnouncementsServer extends BaseAccessServer {
|
|
|
125
480
|
{
|
|
126
481
|
uri,
|
|
127
482
|
mimeType: "application/json",
|
|
128
|
-
text: JSON.stringify(announcements, null, 2)
|
|
129
|
-
}
|
|
130
|
-
]
|
|
483
|
+
text: JSON.stringify(announcements, null, 2),
|
|
484
|
+
},
|
|
485
|
+
],
|
|
131
486
|
};
|
|
132
487
|
}
|
|
133
488
|
catch (error) {
|
|
489
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
134
490
|
return {
|
|
135
491
|
contents: [
|
|
136
492
|
{
|
|
137
493
|
uri,
|
|
138
494
|
mimeType: "text/plain",
|
|
139
|
-
text: `Error loading announcements: ${
|
|
140
|
-
}
|
|
141
|
-
]
|
|
495
|
+
text: `Error loading announcements: ${message}`,
|
|
496
|
+
},
|
|
497
|
+
],
|
|
142
498
|
};
|
|
143
499
|
}
|
|
144
500
|
}
|
|
145
501
|
throw new Error(`Unknown resource: ${uri}`);
|
|
146
502
|
}
|
|
503
|
+
// ============================================================================
|
|
504
|
+
// Read Operations (existing)
|
|
505
|
+
// ============================================================================
|
|
147
506
|
normalizeLimit(limit) {
|
|
148
|
-
|
|
149
|
-
const validSizes = [5, 10, 25, 50];
|
|
150
|
-
const requestedLimit = limit || 25; // Default to 25 (valid API value)
|
|
151
|
-
// Find the closest valid size
|
|
507
|
+
const requestedLimit = limit || 25;
|
|
152
508
|
if (requestedLimit <= 5)
|
|
153
509
|
return 5;
|
|
154
510
|
if (requestedLimit <= 10)
|
|
@@ -159,29 +515,25 @@ export class AnnouncementsServer extends BaseAccessServer {
|
|
|
159
515
|
}
|
|
160
516
|
buildAnnouncementsUrl(filters) {
|
|
161
517
|
const params = new URLSearchParams();
|
|
162
|
-
// Add required pagination parameter for 2.2 API
|
|
163
|
-
// The Drupal API only accepts: 5, 10, 25, or 50
|
|
164
518
|
params.append("items_per_page", String(this.normalizeLimit(filters.limit)));
|
|
519
|
+
if (filters.query) {
|
|
520
|
+
params.append("search_api_fulltext", filters.query);
|
|
521
|
+
}
|
|
165
522
|
if (filters.tags) {
|
|
166
523
|
params.append("tags", filters.tags);
|
|
167
524
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
if (filters.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (filters.start_date) {
|
|
181
|
-
params.append("start_date", filters.start_date);
|
|
182
|
-
}
|
|
183
|
-
if (filters.end_date) {
|
|
184
|
-
params.append("end_date", filters.end_date);
|
|
525
|
+
const dateMap = {
|
|
526
|
+
today: { start: "today" },
|
|
527
|
+
this_week: { start: "-1 week", end: "now" },
|
|
528
|
+
this_month: { start: "-1 month", end: "now" },
|
|
529
|
+
past: { start: "-1 year", end: "now" },
|
|
530
|
+
};
|
|
531
|
+
if (filters.date && dateMap[filters.date]) {
|
|
532
|
+
const dateRange = dateMap[filters.date];
|
|
533
|
+
params.append("relative_start_date", dateRange.start);
|
|
534
|
+
if (dateRange.end) {
|
|
535
|
+
params.append("relative_end_date", dateRange.end);
|
|
536
|
+
}
|
|
185
537
|
}
|
|
186
538
|
return `/api/2.2/announcements?${params.toString()}`;
|
|
187
539
|
}
|
|
@@ -195,82 +547,483 @@ export class AnnouncementsServer extends BaseAccessServer {
|
|
|
195
547
|
return this.enhanceAnnouncements(announcements);
|
|
196
548
|
}
|
|
197
549
|
enhanceAnnouncements(rawAnnouncements) {
|
|
198
|
-
return rawAnnouncements.map(announcement => ({
|
|
550
|
+
return rawAnnouncements.map((announcement) => ({
|
|
199
551
|
...announcement,
|
|
200
|
-
|
|
201
|
-
tags: Array.isArray(announcement.tags)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
})
|
|
208
|
-
: '',
|
|
209
|
-
body_preview: announcement.summary ||
|
|
552
|
+
// Tags come as comma-separated string from public API, convert to array
|
|
553
|
+
tags: Array.isArray(announcement.tags)
|
|
554
|
+
? announcement.tags
|
|
555
|
+
: typeof announcement.tags === "string" && announcement.tags.trim()
|
|
556
|
+
? announcement.tags.split(",").map((t) => t.trim())
|
|
557
|
+
: [],
|
|
558
|
+
summary: announcement.summary ||
|
|
210
559
|
(announcement.body
|
|
211
|
-
? announcement.body.replace(/<[^>]*>/g,
|
|
212
|
-
:
|
|
213
|
-
affinity_groups: Array.isArray(announcement.affinity_group) ? announcement.affinity_group : []
|
|
560
|
+
? announcement.body.replace(/<[^>]*>/g, "").substring(0, 200) + "..."
|
|
561
|
+
: ""),
|
|
214
562
|
}));
|
|
215
563
|
}
|
|
216
564
|
async searchAnnouncements(filters) {
|
|
217
565
|
const announcements = await this.fetchAnnouncements(filters);
|
|
218
566
|
const limited = filters.limit ? announcements.slice(0, filters.limit) : announcements;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
filters_applied.date_range = `${filters.start_date || 'any'} to ${filters.end_date || 'now'}`;
|
|
230
|
-
}
|
|
231
|
-
if (filters.limit)
|
|
232
|
-
filters_applied.limit = filters.limit;
|
|
233
|
-
const result = {
|
|
234
|
-
total_announcements: announcements.length,
|
|
235
|
-
filtered_announcements: limited.length,
|
|
236
|
-
announcements: limited,
|
|
237
|
-
filters_applied,
|
|
238
|
-
popular_tags: this.getPopularTags(limited),
|
|
239
|
-
message: limited.length === 0 ? "No announcements found matching the filters" : undefined
|
|
567
|
+
return {
|
|
568
|
+
content: [
|
|
569
|
+
{
|
|
570
|
+
type: "text",
|
|
571
|
+
text: JSON.stringify({
|
|
572
|
+
total: announcements.length,
|
|
573
|
+
items: limited,
|
|
574
|
+
}),
|
|
575
|
+
},
|
|
576
|
+
],
|
|
240
577
|
};
|
|
578
|
+
}
|
|
579
|
+
// ============================================================================
|
|
580
|
+
// CRUD Operations (new)
|
|
581
|
+
// ============================================================================
|
|
582
|
+
/**
|
|
583
|
+
* Get the acting user's ACCESS ID for content attribution.
|
|
584
|
+
*
|
|
585
|
+
* Priority order:
|
|
586
|
+
* 1. X-Acting-User header (from request context)
|
|
587
|
+
* 2. ACTING_USER environment variable (fallback)
|
|
588
|
+
*
|
|
589
|
+
* Returns the ACCESS ID (e.g., "username@access-ci.org")
|
|
590
|
+
*/
|
|
591
|
+
getActingUserAccessId() {
|
|
592
|
+
// Try request context first
|
|
593
|
+
const context = getRequestContext();
|
|
594
|
+
if (context?.actingUser) {
|
|
595
|
+
return context.actingUser;
|
|
596
|
+
}
|
|
597
|
+
// Fall back to environment variable
|
|
598
|
+
const envUser = process.env.ACTING_USER;
|
|
599
|
+
if (envUser) {
|
|
600
|
+
return envUser;
|
|
601
|
+
}
|
|
602
|
+
throw new Error("Cannot create announcement: No acting user specified.\n\n" +
|
|
603
|
+
"Either set the X-Acting-User header or the ACTING_USER environment variable " +
|
|
604
|
+
"to the ACCESS ID (e.g., username@access-ci.org) of the person creating the announcement.");
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Create a new announcement via Drupal JSON:API
|
|
608
|
+
*
|
|
609
|
+
* The X-Acting-User header (set by DrupalAuthProvider) tells Drupal which
|
|
610
|
+
* ACCESS user is creating the content. Drupal handles user resolution.
|
|
611
|
+
*/
|
|
612
|
+
async createAnnouncement(args) {
|
|
613
|
+
const auth = this.getDrupalAuth();
|
|
614
|
+
// Build the JSON:API request body
|
|
615
|
+
// Note: We don't set the uid relationship - Drupal handles author attribution
|
|
616
|
+
// based on the X-Acting-User header sent with the request
|
|
617
|
+
const requestBody = {
|
|
618
|
+
data: {
|
|
619
|
+
type: "node--access_news",
|
|
620
|
+
attributes: {
|
|
621
|
+
title: args.title,
|
|
622
|
+
moderation_state: "draft", // Use content moderation workflow
|
|
623
|
+
body: {
|
|
624
|
+
value: args.body,
|
|
625
|
+
format: "basic_html",
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
relationships: {},
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
// Add optional fields
|
|
632
|
+
if (args.summary && requestBody.data.attributes.body) {
|
|
633
|
+
requestBody.data.attributes.body.summary = args.summary;
|
|
634
|
+
}
|
|
635
|
+
if (args.published_date) {
|
|
636
|
+
requestBody.data.attributes.field_published_date = args.published_date;
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
// Default to today
|
|
640
|
+
requestBody.data.attributes.field_published_date = new Date().toISOString().split("T")[0];
|
|
641
|
+
}
|
|
642
|
+
if (args.affiliation) {
|
|
643
|
+
requestBody.data.attributes.field_affiliation = args.affiliation;
|
|
644
|
+
}
|
|
645
|
+
// Look up tag UUIDs if tags provided
|
|
646
|
+
if (args.tags && args.tags.length > 0) {
|
|
647
|
+
const tagUuids = await this.getTagUuidsByName(args.tags);
|
|
648
|
+
if (tagUuids.length > 0) {
|
|
649
|
+
requestBody.data.relationships.field_tags = {
|
|
650
|
+
data: tagUuids.map((uuid) => ({
|
|
651
|
+
type: "taxonomy_term--tags",
|
|
652
|
+
id: uuid,
|
|
653
|
+
})),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// Look up affinity group UUID if provided
|
|
658
|
+
if (args.affinity_group) {
|
|
659
|
+
const groupUuid = await this.getAffinityGroupUuid(args.affinity_group);
|
|
660
|
+
if (groupUuid) {
|
|
661
|
+
requestBody.data.relationships.field_affinity_group_node = {
|
|
662
|
+
data: {
|
|
663
|
+
type: "node--affinity_group",
|
|
664
|
+
id: groupUuid,
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
throw new Error(`Affinity group not found: ${args.affinity_group}. Use list_affinity_groups to see groups you coordinate.`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Add external link if provided
|
|
673
|
+
if (args.external_link) {
|
|
674
|
+
requestBody.data.attributes.field_news_external_link = {
|
|
675
|
+
uri: args.external_link.uri,
|
|
676
|
+
title: args.external_link.title || "",
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
// Add where to share (defaults handled by Drupal if not provided)
|
|
680
|
+
if (args.where_to_share && args.where_to_share.length > 0) {
|
|
681
|
+
requestBody.data.attributes.field_choose_where_to_share_this = this.normalizeWhereToShare(args.where_to_share);
|
|
682
|
+
}
|
|
683
|
+
const result = await auth.post("/jsonapi/node/access_news", requestBody);
|
|
241
684
|
return {
|
|
242
685
|
content: [
|
|
243
686
|
{
|
|
244
687
|
type: "text",
|
|
245
|
-
text: JSON.stringify(
|
|
246
|
-
|
|
247
|
-
|
|
688
|
+
text: JSON.stringify({
|
|
689
|
+
success: true,
|
|
690
|
+
message: "Announcement created (draft status)",
|
|
691
|
+
uuid: result.data?.id,
|
|
692
|
+
title: result.data?.attributes?.title,
|
|
693
|
+
edit_url: `${process.env.DRUPAL_API_URL}/node/${result.data?.attributes?.drupal_internal__nid}/edit`,
|
|
694
|
+
}),
|
|
695
|
+
},
|
|
696
|
+
],
|
|
248
697
|
};
|
|
249
698
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
699
|
+
/**
|
|
700
|
+
* Update an existing announcement via Drupal JSON:API
|
|
701
|
+
*/
|
|
702
|
+
async updateAnnouncement(args) {
|
|
703
|
+
const auth = this.getDrupalAuth();
|
|
704
|
+
const requestBody = {
|
|
705
|
+
data: {
|
|
706
|
+
type: "node--access_news",
|
|
707
|
+
id: args.uuid,
|
|
708
|
+
attributes: {},
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
// Add fields to update
|
|
712
|
+
if (args.title) {
|
|
713
|
+
requestBody.data.attributes.title = args.title;
|
|
714
|
+
}
|
|
715
|
+
// Handle body/summary updates
|
|
716
|
+
// Fetch existing to preserve values not being changed
|
|
717
|
+
if (args.body || args.summary) {
|
|
718
|
+
const existing = await auth.get(`/jsonapi/node/access_news/${args.uuid}`);
|
|
719
|
+
const existingBody = existing.data?.attributes?.body?.value || "";
|
|
720
|
+
const existingSummary = existing.data?.attributes?.body?.summary || "";
|
|
721
|
+
requestBody.data.attributes.body = {
|
|
722
|
+
value: args.body || existingBody,
|
|
723
|
+
format: "basic_html",
|
|
724
|
+
summary: args.summary !== undefined ? args.summary : existingSummary,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
// If neither body nor summary provided, don't include body in request at all
|
|
728
|
+
if (args.published_date) {
|
|
729
|
+
requestBody.data.attributes.field_published_date = args.published_date;
|
|
730
|
+
}
|
|
731
|
+
// Update tags if provided
|
|
732
|
+
if (args.tags && args.tags.length > 0) {
|
|
733
|
+
const tagUuids = await this.getTagUuidsByName(args.tags);
|
|
734
|
+
if (tagUuids.length > 0) {
|
|
735
|
+
if (!requestBody.data.relationships) {
|
|
736
|
+
requestBody.data.relationships = {};
|
|
737
|
+
}
|
|
738
|
+
requestBody.data.relationships.field_tags = {
|
|
739
|
+
data: tagUuids.map((uuid) => ({
|
|
740
|
+
type: "taxonomy_term--tags",
|
|
741
|
+
id: uuid,
|
|
742
|
+
})),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// Update affinity group if provided
|
|
747
|
+
if (args.affinity_group) {
|
|
748
|
+
const groupUuid = await this.getAffinityGroupUuid(args.affinity_group);
|
|
749
|
+
if (groupUuid) {
|
|
750
|
+
if (!requestBody.data.relationships) {
|
|
751
|
+
requestBody.data.relationships = {};
|
|
752
|
+
}
|
|
753
|
+
requestBody.data.relationships.field_affinity_group_node = {
|
|
754
|
+
data: {
|
|
755
|
+
type: "node--affinity_group",
|
|
756
|
+
id: groupUuid,
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
throw new Error(`Affinity group not found: ${args.affinity_group}. Use list_affinity_groups to see groups you coordinate.`);
|
|
257
762
|
}
|
|
763
|
+
}
|
|
764
|
+
// Update external link if provided
|
|
765
|
+
if (args.external_link) {
|
|
766
|
+
requestBody.data.attributes.field_news_external_link = {
|
|
767
|
+
uri: args.external_link.uri,
|
|
768
|
+
title: args.external_link.title || "",
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
// Update where to share if provided
|
|
772
|
+
if (args.where_to_share && args.where_to_share.length > 0) {
|
|
773
|
+
requestBody.data.attributes.field_choose_where_to_share_this = this.normalizeWhereToShare(args.where_to_share);
|
|
774
|
+
}
|
|
775
|
+
const result = await auth.patch(`/jsonapi/node/access_news/${args.uuid}`, requestBody);
|
|
776
|
+
const nid = result.data?.attributes?.drupal_internal__nid;
|
|
777
|
+
const baseUrl = process.env.DRUPAL_API_URL;
|
|
778
|
+
return {
|
|
779
|
+
content: [
|
|
780
|
+
{
|
|
781
|
+
type: "text",
|
|
782
|
+
text: JSON.stringify({
|
|
783
|
+
success: true,
|
|
784
|
+
message: "Announcement updated",
|
|
785
|
+
uuid: result.data?.id,
|
|
786
|
+
title: result.data?.attributes?.title,
|
|
787
|
+
edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null,
|
|
788
|
+
}),
|
|
789
|
+
},
|
|
790
|
+
],
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Delete an announcement via Drupal JSON:API
|
|
795
|
+
*/
|
|
796
|
+
async deleteAnnouncement(args) {
|
|
797
|
+
// Enforce confirmation parameter
|
|
798
|
+
if (!args.confirmed) {
|
|
799
|
+
return {
|
|
800
|
+
content: [
|
|
801
|
+
{
|
|
802
|
+
type: "text",
|
|
803
|
+
text: JSON.stringify({
|
|
804
|
+
error: "Deletion requires explicit confirmation. You must show the announcement title and status to the user and get explicit confirmation before setting confirmed=true.",
|
|
805
|
+
uuid: args.uuid,
|
|
806
|
+
}),
|
|
807
|
+
},
|
|
808
|
+
],
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
const auth = this.getDrupalAuth();
|
|
812
|
+
await auth.delete(`/jsonapi/node/access_news/${args.uuid}`);
|
|
813
|
+
return {
|
|
814
|
+
content: [
|
|
815
|
+
{
|
|
816
|
+
type: "text",
|
|
817
|
+
text: JSON.stringify({
|
|
818
|
+
success: true,
|
|
819
|
+
message: "Announcement deleted",
|
|
820
|
+
uuid: args.uuid,
|
|
821
|
+
}),
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Get announcements created by the acting user
|
|
828
|
+
*/
|
|
829
|
+
async getMyAnnouncements(args) {
|
|
830
|
+
const auth = this.getDrupalAuth();
|
|
831
|
+
// Get the acting user's UUID by looking up their ACCESS ID
|
|
832
|
+
const actingUserAccessId = this.getActingUserAccessId();
|
|
833
|
+
const userUuid = await this.getUserUuidByAccessId(actingUserAccessId);
|
|
834
|
+
if (!userUuid) {
|
|
835
|
+
throw new Error(`Could not find Drupal user for ACCESS ID "${actingUserAccessId}". ` +
|
|
836
|
+
`Verify the user exists in Drupal.`);
|
|
837
|
+
}
|
|
838
|
+
const limit = args.limit || 25;
|
|
839
|
+
const result = await auth.get(`/jsonapi/node/access_news?filter[uid.id]=${userUuid}&page[limit]=${limit}&sort=-created`);
|
|
840
|
+
const baseUrl = process.env.DRUPAL_API_URL;
|
|
841
|
+
const announcements = (result.data || []).map((item) => {
|
|
842
|
+
const nid = item.attributes?.drupal_internal__nid;
|
|
843
|
+
return {
|
|
844
|
+
uuid: item.id,
|
|
845
|
+
nid,
|
|
846
|
+
title: item.attributes?.title,
|
|
847
|
+
status: item.attributes?.status ? "published" : "draft",
|
|
848
|
+
created: item.attributes?.created,
|
|
849
|
+
published_date: item.attributes?.field_published_date,
|
|
850
|
+
summary: item.attributes?.body?.summary ||
|
|
851
|
+
(item.attributes?.body?.value
|
|
852
|
+
? item.attributes.body.value.replace(/<[^>]*>/g, "").substring(0, 200) + "..."
|
|
853
|
+
: ""),
|
|
854
|
+
edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null,
|
|
855
|
+
};
|
|
258
856
|
});
|
|
259
|
-
return
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
857
|
+
return {
|
|
858
|
+
content: [
|
|
859
|
+
{
|
|
860
|
+
type: "text",
|
|
861
|
+
text: JSON.stringify({
|
|
862
|
+
total: announcements.length,
|
|
863
|
+
items: announcements,
|
|
864
|
+
}),
|
|
865
|
+
},
|
|
866
|
+
],
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
// ============================================================================
|
|
870
|
+
// Helper Methods
|
|
871
|
+
// ============================================================================
|
|
872
|
+
/**
|
|
873
|
+
* Get a user's UUID by their ACCESS ID (e.g., "user@access-ci.org").
|
|
874
|
+
*
|
|
875
|
+
* The Drupal username should match the full ACCESS ID.
|
|
876
|
+
*/
|
|
877
|
+
async getUserUuidByAccessId(accessId) {
|
|
878
|
+
const auth = this.getDrupalAuth();
|
|
879
|
+
const result = await auth.get(`/jsonapi/user/user?filter[name]=${encodeURIComponent(accessId)}`);
|
|
880
|
+
if (!result.data || result.data.length === 0) {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
return result.data[0].id;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Get announcement context - tags, affinity groups, and options for creating announcements
|
|
887
|
+
*/
|
|
888
|
+
async getAnnouncementContext() {
|
|
889
|
+
const auth = this.getDrupalAuth();
|
|
890
|
+
// Get the acting user's UUID by looking up their ACCESS ID
|
|
891
|
+
const actingUserAccessId = this.getActingUserAccessId();
|
|
892
|
+
const userUuid = await this.getUserUuidByAccessId(actingUserAccessId);
|
|
893
|
+
if (!userUuid) {
|
|
894
|
+
throw new Error(`Could not find Drupal user for ACCESS ID "${actingUserAccessId}". ` +
|
|
895
|
+
`Verify the user exists in Drupal.`);
|
|
896
|
+
}
|
|
897
|
+
// Fetch tags and affinity groups in parallel
|
|
898
|
+
const [tagsResult, groupsResult] = await Promise.all([
|
|
899
|
+
auth.get("/jsonapi/taxonomy_term/tags?page[limit]=100"),
|
|
900
|
+
auth.get(`/jsonapi/node/affinity_group?filter[field_coordinator.id]=${userUuid}&filter[status]=1&page[limit]=100`),
|
|
901
|
+
]);
|
|
902
|
+
const tags = (tagsResult.data || []).map((item) => ({
|
|
903
|
+
name: item.attributes?.name,
|
|
904
|
+
uuid: item.id,
|
|
905
|
+
}));
|
|
906
|
+
const affinityGroups = (groupsResult.data || []).map((item) => ({
|
|
907
|
+
id: item.attributes?.field_group_id,
|
|
908
|
+
uuid: item.id,
|
|
909
|
+
name: item.attributes?.title,
|
|
910
|
+
category: item.attributes?.field_affinity_group_category,
|
|
911
|
+
}));
|
|
912
|
+
const isCoordinator = affinityGroups.length > 0;
|
|
913
|
+
return {
|
|
914
|
+
content: [
|
|
915
|
+
{
|
|
916
|
+
type: "text",
|
|
917
|
+
text: JSON.stringify({
|
|
918
|
+
tags: tags,
|
|
919
|
+
affinity_groups: affinityGroups,
|
|
920
|
+
is_coordinator: isCoordinator,
|
|
921
|
+
affiliations: ["ACCESS Collaboration", "Community"],
|
|
922
|
+
where_to_share_options: [
|
|
923
|
+
{ value: "Announcements page", description: "Public announcements listing" },
|
|
924
|
+
{ value: "Bi-Weekly Digest", description: "ACCESS Support bi-weekly email digest" },
|
|
925
|
+
{
|
|
926
|
+
value: "Affinity Group page",
|
|
927
|
+
description: "Your affinity group's page (coordinators only)",
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
value: "Email to Affinity Group",
|
|
931
|
+
description: "Direct email to affinity group members (coordinators only)",
|
|
932
|
+
},
|
|
933
|
+
],
|
|
934
|
+
guidance: isCoordinator
|
|
935
|
+
? "User is an affinity group coordinator. Ask about affinity_group association and where_to_share preferences."
|
|
936
|
+
: "User is not a coordinator. Standard announcement fields apply.",
|
|
937
|
+
}),
|
|
938
|
+
},
|
|
939
|
+
],
|
|
940
|
+
};
|
|
263
941
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
942
|
+
/**
|
|
943
|
+
* Look up affinity group UUID by ID or name
|
|
944
|
+
*/
|
|
945
|
+
async getAffinityGroupUuid(idOrName) {
|
|
946
|
+
const auth = this.getDrupalAuth();
|
|
947
|
+
// If it looks like a UUID, return as-is
|
|
948
|
+
if (idOrName.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
|
|
949
|
+
return idOrName;
|
|
950
|
+
}
|
|
951
|
+
// Try by field_group_id first
|
|
952
|
+
let result = await auth.get(`/jsonapi/node/affinity_group?filter[field_group_id]=${encodeURIComponent(idOrName)}&filter[status]=1`);
|
|
953
|
+
if (result.data && result.data.length > 0) {
|
|
954
|
+
return result.data[0].id;
|
|
955
|
+
}
|
|
956
|
+
// Try by title
|
|
957
|
+
result = await auth.get(`/jsonapi/node/affinity_group?filter[title]=${encodeURIComponent(idOrName)}&filter[status]=1`);
|
|
958
|
+
if (result.data && result.data.length > 0) {
|
|
959
|
+
return result.data[0].id;
|
|
960
|
+
}
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Map human-friendly "where to share" labels to Drupal values
|
|
965
|
+
*/
|
|
966
|
+
static WHERE_TO_SHARE_MAP = {
|
|
967
|
+
"announcements page": "on_the_announcements_page",
|
|
968
|
+
"on the announcements page": "on_the_announcements_page",
|
|
969
|
+
"bi-weekly digest": "in_the_access_support_bi_weekly_digest",
|
|
970
|
+
"in the access support bi-weekly digest": "in_the_access_support_bi_weekly_digest",
|
|
971
|
+
"affinity group page": "on_your_affinity_group_page",
|
|
972
|
+
"on your affinity group page": "on_your_affinity_group_page",
|
|
973
|
+
"email to affinity group": "email_to_your_affinity_group",
|
|
974
|
+
"email to your affinity group": "email_to_your_affinity_group",
|
|
975
|
+
// Also accept the raw values
|
|
976
|
+
on_the_announcements_page: "on_the_announcements_page",
|
|
977
|
+
in_the_access_support_bi_weekly_digest: "in_the_access_support_bi_weekly_digest",
|
|
978
|
+
on_your_affinity_group_page: "on_your_affinity_group_page",
|
|
979
|
+
email_to_your_affinity_group: "email_to_your_affinity_group",
|
|
980
|
+
};
|
|
981
|
+
normalizeWhereToShare(values) {
|
|
982
|
+
return values.map((v) => {
|
|
983
|
+
const normalized = AnnouncementsServer.WHERE_TO_SHARE_MAP[v.toLowerCase()];
|
|
984
|
+
if (!normalized) {
|
|
985
|
+
throw new Error(`Invalid where_to_share value: "${v}". ` +
|
|
986
|
+
`Valid options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'`);
|
|
272
987
|
}
|
|
988
|
+
return normalized;
|
|
273
989
|
});
|
|
274
|
-
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Check if tag cache is still valid
|
|
993
|
+
*/
|
|
994
|
+
isTagCacheValid() {
|
|
995
|
+
return this.tagCacheExpiry !== undefined && new Date() < this.tagCacheExpiry;
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Populate the tag cache with all available tags
|
|
999
|
+
*/
|
|
1000
|
+
async populateTagCache() {
|
|
1001
|
+
const auth = this.getDrupalAuth();
|
|
1002
|
+
const result = await auth.get("/jsonapi/taxonomy_term/tags?page[limit]=500");
|
|
1003
|
+
this.tagCache.clear();
|
|
1004
|
+
for (const item of result.data || []) {
|
|
1005
|
+
const name = item.attributes?.name?.toLowerCase();
|
|
1006
|
+
if (name && item.id) {
|
|
1007
|
+
this.tagCache.set(name, item.id);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
this.tagCacheExpiry = new Date(Date.now() + AnnouncementsServer.TAG_CACHE_TTL_MS);
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Get tag UUIDs by their names (with caching)
|
|
1014
|
+
*/
|
|
1015
|
+
async getTagUuidsByName(tagNames) {
|
|
1016
|
+
// Ensure cache is populated
|
|
1017
|
+
if (!this.isTagCacheValid()) {
|
|
1018
|
+
await this.populateTagCache();
|
|
1019
|
+
}
|
|
1020
|
+
const uuids = [];
|
|
1021
|
+
for (const name of tagNames) {
|
|
1022
|
+
const uuid = this.tagCache.get(name.toLowerCase());
|
|
1023
|
+
if (uuid) {
|
|
1024
|
+
uuids.push(uuid);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return uuids;
|
|
275
1028
|
}
|
|
276
1029
|
}
|