@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/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", "0.1.0", "https://support.access-ci.org");
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 - 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.",
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 specific topics (comma-separated). Real examples: 'gpu,nvidia', 'machine-learning,ai', 'maintenance,downtime', 'training,workshop', 'allocation-users', 'data-science', 'cloud-computing', 'bridges-2,anvil'"
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
- ag: {
135
+ body: {
19
136
  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"
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: "Filter by organization affiliation (e.g., 'ACCESS')"
154
+ description: "Source of announcement",
155
+ enum: ["ACCESS Collaboration", "Community"],
25
156
  },
26
- relative_start_date: {
157
+ affinity_group: {
27
158
  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"
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
- relative_end_date: {
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: "Filter announcements up to a relative date. Examples: 'now', 'today', '-1 week' (past), '+1 week' (future). Usually 'now' or omit for current time"
196
+ description: "UUID of the announcement (get from get_my_announcements)",
33
197
  },
34
- start_date: {
198
+ title: {
35
199
  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"
200
+ description: "New title (only if changing)",
38
201
  },
39
- end_date: {
202
+ body: {
40
203
  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"
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
- examples: [
51
- {
52
- name: "Recent announcements",
53
- arguments: {
54
- relative_start_date: "-1 month",
55
- limit: 10
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
- name: "GPU-related announcements",
60
- arguments: {
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
- name: "Anvil system announcements",
68
- arguments: {
69
- ag: "Anvil",
70
- relative_start_date: "-6 months",
71
- limit: 15
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
- name: "Machine learning announcements",
76
- arguments: {
77
- tags: "machine-learning,ai",
78
- relative_start_date: "-1 year",
79
- limit: 20
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
- throw new Error(`Unknown tool: ${name}`);
465
+ return this.errorResponse(`Unknown tool: ${name}`);
105
466
  }
106
467
  }
107
468
  catch (error) {
108
- return {
109
- content: [
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: ${error.message}`
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
- // 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
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
- 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);
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
- 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 ||
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, '').substring(0, 200) + '...'
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
- // 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
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(result, null, 2)
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
- 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
- });
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 Object.entries(tagCounts)
260
- .sort(([, a], [, b]) => b - a)
261
- .slice(0, 10)
262
- .map(([tag]) => tag);
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
- 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
- });
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
- return Array.from(groups);
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
  }