@access-mcp/announcements 0.2.0 → 0.3.0

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