@access-mcp/announcements 0.3.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,4 +1,4 @@
1
- import { BaseAccessServer, DrupalAuthProvider } from "@access-mcp/shared";
1
+ import { BaseAccessServer, DrupalAuthProvider, getRequestContext, } from "@access-mcp/shared";
2
2
  import { createRequire } from "module";
3
3
  const require = createRequire(import.meta.url);
4
4
  const { version } = require("../package.json");
@@ -11,11 +11,17 @@ export class AnnouncementsServer extends BaseAccessServer {
11
11
  tagCacheExpiry;
12
12
  static TAG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
13
13
  constructor() {
14
- super("access-announcements", version, "https://support.access-ci.org");
14
+ super("access-announcements", version, "https://support.access-ci.org", {
15
+ requireApiKey: true,
16
+ });
15
17
  }
16
18
  /**
17
19
  * Get or create the Drupal auth provider for JSON:API write operations.
18
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)
19
25
  */
20
26
  getDrupalAuth() {
21
27
  if (!this.drupalAuth) {
@@ -27,6 +33,12 @@ export class AnnouncementsServer extends BaseAccessServer {
27
33
  }
28
34
  this.drupalAuth = new DrupalAuthProvider(baseUrl, username, password);
29
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
+ }
30
42
  return this.drupalAuth;
31
43
  }
32
44
  getTools() {
@@ -40,24 +52,24 @@ export class AnnouncementsServer extends BaseAccessServer {
40
52
  properties: {
41
53
  query: {
42
54
  type: "string",
43
- description: "Full-text search across title, body, and summary"
55
+ description: "Full-text search across title, body, and summary",
44
56
  },
45
57
  tags: {
46
58
  type: "string",
47
- description: "Filter by topics: gpu, ml, hpc"
59
+ description: "Filter by topics: gpu, ml, hpc",
48
60
  },
49
61
  date: {
50
62
  type: "string",
51
63
  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"]
64
+ enum: ["today", "this_week", "this_month", "past"],
53
65
  },
54
66
  limit: {
55
67
  type: "number",
56
68
  description: "Max results (default: 25)",
57
- default: 25
58
- }
59
- }
60
- }
69
+ default: 25,
70
+ },
71
+ },
72
+ },
61
73
  },
62
74
  // CRUD operations (new)
63
75
  {
@@ -118,51 +130,51 @@ ALWAYS display the edit_url to the user so they can review their draft in Drupal
118
130
  properties: {
119
131
  title: {
120
132
  type: "string",
121
- description: "Announcement title - clear and specific, under 100 characters"
133
+ description: "Announcement title - clear and specific, under 100 characters",
122
134
  },
123
135
  body: {
124
136
  type: "string",
125
- description: "Full announcement content. HTML allowed (basic_html format: <p>, <a>, <strong>, <em>, <ul>, <li>)"
137
+ description: "Full announcement content. HTML allowed (basic_html format: <p>, <a>, <strong>, <em>, <ul>, <li>)",
126
138
  },
127
139
  summary: {
128
140
  type: "string",
129
- description: "Brief teaser (1-2 sentences) shown in announcement listings. Required."
141
+ description: "Brief teaser (1-2 sentences) shown in announcement listings. Required.",
130
142
  },
131
143
  published_date: {
132
144
  type: "string",
133
- description: "When to display (YYYY-MM-DD). Defaults to today."
145
+ description: "When to display (YYYY-MM-DD). Defaults to today.",
134
146
  },
135
147
  tags: {
136
148
  type: "array",
137
149
  items: { type: "string" },
138
- description: "Tag names (1-6 required for publication). Use list_available_tags to find valid tags."
150
+ description: "Tag names (1-6 required for publication). Use list_available_tags to find valid tags.",
139
151
  },
140
152
  affiliation: {
141
153
  type: "string",
142
154
  description: "Source of announcement",
143
- enum: ["ACCESS Collaboration", "Community"]
155
+ enum: ["ACCESS Collaboration", "Community"],
144
156
  },
145
157
  affinity_group: {
146
158
  type: "string",
147
- description: "Affinity group name or UUID. User must be coordinator of the group. Use list_affinity_groups to find valid groups."
159
+ description: "Affinity group name or UUID. User must be coordinator of the group. Use list_affinity_groups to find valid groups.",
148
160
  },
149
161
  external_link: {
150
162
  type: "object",
151
163
  description: "External link related to this announcement",
152
164
  properties: {
153
165
  uri: { type: "string", description: "URL (must include https://)" },
154
- title: { type: "string", description: "Link text to display" }
166
+ title: { type: "string", description: "Link text to display" },
155
167
  },
156
- required: ["uri"]
168
+ required: ["uri"],
157
169
  },
158
170
  where_to_share: {
159
171
  type: "array",
160
172
  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'"
162
- }
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
+ },
163
175
  },
164
- required: ["title", "body", "summary"]
165
- }
176
+ required: ["title", "body", "summary"],
177
+ },
166
178
  },
167
179
  {
168
180
  name: "update_announcement",
@@ -181,50 +193,50 @@ ALWAYS display the edit_url to the user so they can review changes in Drupal.`,
181
193
  properties: {
182
194
  uuid: {
183
195
  type: "string",
184
- description: "UUID of the announcement (get from get_my_announcements)"
196
+ description: "UUID of the announcement (get from get_my_announcements)",
185
197
  },
186
198
  title: {
187
199
  type: "string",
188
- description: "New title (only if changing)"
200
+ description: "New title (only if changing)",
189
201
  },
190
202
  body: {
191
203
  type: "string",
192
- description: "New body content (only if changing)"
204
+ description: "New body content (only if changing)",
193
205
  },
194
206
  summary: {
195
207
  type: "string",
196
- description: "New summary (only if changing)"
208
+ description: "New summary (only if changing)",
197
209
  },
198
210
  published_date: {
199
211
  type: "string",
200
- description: "New publication date YYYY-MM-DD (only if changing)"
212
+ description: "New publication date YYYY-MM-DD (only if changing)",
201
213
  },
202
214
  tags: {
203
215
  type: "array",
204
216
  items: { type: "string" },
205
- description: "New tags - replaces ALL existing tags (only if changing)"
217
+ description: "New tags - replaces ALL existing tags (only if changing)",
206
218
  },
207
219
  affinity_group: {
208
220
  type: "string",
209
- description: "Affinity group name or UUID. User must be coordinator."
221
+ description: "Affinity group name or UUID. User must be coordinator.",
210
222
  },
211
223
  external_link: {
212
224
  type: "object",
213
225
  description: "External link related to this announcement",
214
226
  properties: {
215
227
  uri: { type: "string", description: "URL (must include https://)" },
216
- title: { type: "string", description: "Link text to display" }
228
+ title: { type: "string", description: "Link text to display" },
217
229
  },
218
- required: ["uri"]
230
+ required: ["uri"],
219
231
  },
220
232
  where_to_share: {
221
233
  type: "array",
222
234
  items: { type: "string" },
223
- description: "Where to share the announcement. Options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'"
224
- }
235
+ description: "Where to share the announcement. Options: 'Announcements page', 'Bi-Weekly Digest', 'Affinity Group page', 'Email to Affinity Group'",
236
+ },
225
237
  },
226
- required: ["uuid"]
227
- }
238
+ required: ["uuid"],
239
+ },
228
240
  },
229
241
  {
230
242
  name: "delete_announcement",
@@ -248,15 +260,15 @@ Returns: {success, uuid}`,
248
260
  properties: {
249
261
  uuid: {
250
262
  type: "string",
251
- description: "UUID of the announcement to delete"
263
+ description: "UUID of the announcement to delete",
252
264
  },
253
265
  confirmed: {
254
266
  type: "boolean",
255
- description: "Set to true only after user has explicitly confirmed deletion of THIS specific announcement. Required."
256
- }
267
+ description: "Set to true only after user has explicitly confirmed deletion of THIS specific announcement. Required.",
268
+ },
257
269
  },
258
- required: ["uuid", "confirmed"]
259
- }
270
+ required: ["uuid", "confirmed"],
271
+ },
260
272
  },
261
273
  {
262
274
  name: "get_my_announcements",
@@ -275,10 +287,10 @@ Use this to:
275
287
  limit: {
276
288
  type: "number",
277
289
  description: "Max results (default: 25)",
278
- default: 25
279
- }
280
- }
281
- }
290
+ default: 25,
291
+ },
292
+ },
293
+ },
282
294
  },
283
295
  {
284
296
  name: "get_announcement_context",
@@ -294,9 +306,9 @@ Returns:
294
306
  Use this to tailor the announcement creation conversation based on the user's role.`,
295
307
  inputSchema: {
296
308
  type: "object",
297
- properties: {}
298
- }
299
- }
309
+ properties: {},
310
+ },
311
+ },
300
312
  ];
301
313
  }
302
314
  getResources() {
@@ -305,8 +317,8 @@ Use this to tailor the announcement creation conversation based on the user's ro
305
317
  uri: "accessci://announcements",
306
318
  name: "ACCESS Support Announcements",
307
319
  description: "Recent announcements and notifications from ACCESS support",
308
- mimeType: "application/json"
309
- }
320
+ mimeType: "application/json",
321
+ },
310
322
  ];
311
323
  }
312
324
  getPrompts() {
@@ -468,9 +480,9 @@ Which would you like to do?`,
468
480
  {
469
481
  uri,
470
482
  mimeType: "application/json",
471
- text: JSON.stringify(announcements, null, 2)
472
- }
473
- ]
483
+ text: JSON.stringify(announcements, null, 2),
484
+ },
485
+ ],
474
486
  };
475
487
  }
476
488
  catch (error) {
@@ -480,9 +492,9 @@ Which would you like to do?`,
480
492
  {
481
493
  uri,
482
494
  mimeType: "text/plain",
483
- text: `Error loading announcements: ${message}`
484
- }
485
- ]
495
+ text: `Error loading announcements: ${message}`,
496
+ },
497
+ ],
486
498
  };
487
499
  }
488
500
  }
@@ -514,7 +526,7 @@ Which would you like to do?`,
514
526
  today: { start: "today" },
515
527
  this_week: { start: "-1 week", end: "now" },
516
528
  this_month: { start: "-1 month", end: "now" },
517
- past: { start: "-1 year", end: "now" }
529
+ past: { start: "-1 year", end: "now" },
518
530
  };
519
531
  if (filters.date && dateMap[filters.date]) {
520
532
  const dateRange = dateMap[filters.date];
@@ -535,61 +547,73 @@ Which would you like to do?`,
535
547
  return this.enhanceAnnouncements(announcements);
536
548
  }
537
549
  enhanceAnnouncements(rawAnnouncements) {
538
- return rawAnnouncements.map(announcement => ({
550
+ return rawAnnouncements.map((announcement) => ({
539
551
  ...announcement,
540
552
  // Tags come as comma-separated string from public API, convert to array
541
553
  tags: Array.isArray(announcement.tags)
542
554
  ? 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) + '...' : '')
555
+ : typeof announcement.tags === "string" && announcement.tags.trim()
556
+ ? announcement.tags.split(",").map((t) => t.trim())
557
+ : [],
558
+ summary: announcement.summary ||
559
+ (announcement.body
560
+ ? announcement.body.replace(/<[^>]*>/g, "").substring(0, 200) + "..."
561
+ : ""),
547
562
  }));
548
563
  }
549
564
  async searchAnnouncements(filters) {
550
565
  const announcements = await this.fetchAnnouncements(filters);
551
566
  const limited = filters.limit ? announcements.slice(0, filters.limit) : announcements;
552
567
  return {
553
- content: [{
568
+ content: [
569
+ {
554
570
  type: "text",
555
571
  text: JSON.stringify({
556
572
  total: announcements.length,
557
- items: limited
558
- })
559
- }]
573
+ items: limited,
574
+ }),
575
+ },
576
+ ],
560
577
  };
561
578
  }
562
579
  // ============================================================================
563
580
  // CRUD Operations (new)
564
581
  // ============================================================================
565
582
  /**
566
- * Get the acting user's UID from environment variable.
567
- * This identifies who the announcement should be attributed to.
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")
568
590
  */
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
- }
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;
576
601
  }
577
602
  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.");
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.");
580
605
  }
581
606
  /**
582
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.
583
611
  */
584
612
  async createAnnouncement(args) {
585
613
  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
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
593
617
  const requestBody = {
594
618
  data: {
595
619
  type: "node--access_news",
@@ -598,21 +622,14 @@ Which would you like to do?`,
598
622
  moderation_state: "draft", // Use content moderation workflow
599
623
  body: {
600
624
  value: args.body,
601
- format: "basic_html"
602
- }
625
+ format: "basic_html",
626
+ },
603
627
  },
604
- relationships: {
605
- uid: {
606
- data: {
607
- type: "user--user",
608
- id: userUuid
609
- }
610
- }
611
- }
612
- }
628
+ relationships: {},
629
+ },
613
630
  };
614
631
  // Add optional fields
615
- if (args.summary) {
632
+ if (args.summary && requestBody.data.attributes.body) {
616
633
  requestBody.data.attributes.body.summary = args.summary;
617
634
  }
618
635
  if (args.published_date) {
@@ -620,7 +637,7 @@ Which would you like to do?`,
620
637
  }
621
638
  else {
622
639
  // Default to today
623
- requestBody.data.attributes.field_published_date = new Date().toISOString().split('T')[0];
640
+ requestBody.data.attributes.field_published_date = new Date().toISOString().split("T")[0];
624
641
  }
625
642
  if (args.affiliation) {
626
643
  requestBody.data.attributes.field_affiliation = args.affiliation;
@@ -630,10 +647,10 @@ Which would you like to do?`,
630
647
  const tagUuids = await this.getTagUuidsByName(args.tags);
631
648
  if (tagUuids.length > 0) {
632
649
  requestBody.data.relationships.field_tags = {
633
- data: tagUuids.map(uuid => ({
650
+ data: tagUuids.map((uuid) => ({
634
651
  type: "taxonomy_term--tags",
635
- id: uuid
636
- }))
652
+ id: uuid,
653
+ })),
637
654
  };
638
655
  }
639
656
  }
@@ -644,8 +661,8 @@ Which would you like to do?`,
644
661
  requestBody.data.relationships.field_affinity_group_node = {
645
662
  data: {
646
663
  type: "node--affinity_group",
647
- id: groupUuid
648
- }
664
+ id: groupUuid,
665
+ },
649
666
  };
650
667
  }
651
668
  else {
@@ -656,7 +673,7 @@ Which would you like to do?`,
656
673
  if (args.external_link) {
657
674
  requestBody.data.attributes.field_news_external_link = {
658
675
  uri: args.external_link.uri,
659
- title: args.external_link.title || ""
676
+ title: args.external_link.title || "",
660
677
  };
661
678
  }
662
679
  // Add where to share (defaults handled by Drupal if not provided)
@@ -665,16 +682,18 @@ Which would you like to do?`,
665
682
  }
666
683
  const result = await auth.post("/jsonapi/node/access_news", requestBody);
667
684
  return {
668
- content: [{
685
+ content: [
686
+ {
669
687
  type: "text",
670
688
  text: JSON.stringify({
671
689
  success: true,
672
690
  message: "Announcement created (draft status)",
673
691
  uuid: result.data?.id,
674
692
  title: result.data?.attributes?.title,
675
- edit_url: `${process.env.DRUPAL_API_URL}/node/${result.data?.attributes?.drupal_internal__nid}/edit`
676
- })
677
- }]
693
+ edit_url: `${process.env.DRUPAL_API_URL}/node/${result.data?.attributes?.drupal_internal__nid}/edit`,
694
+ }),
695
+ },
696
+ ],
678
697
  };
679
698
  }
680
699
  /**
@@ -686,8 +705,8 @@ Which would you like to do?`,
686
705
  data: {
687
706
  type: "node--access_news",
688
707
  id: args.uuid,
689
- attributes: {}
690
- }
708
+ attributes: {},
709
+ },
691
710
  };
692
711
  // Add fields to update
693
712
  if (args.title) {
@@ -702,7 +721,7 @@ Which would you like to do?`,
702
721
  requestBody.data.attributes.body = {
703
722
  value: args.body || existingBody,
704
723
  format: "basic_html",
705
- summary: args.summary !== undefined ? args.summary : existingSummary
724
+ summary: args.summary !== undefined ? args.summary : existingSummary,
706
725
  };
707
726
  }
708
727
  // If neither body nor summary provided, don't include body in request at all
@@ -717,10 +736,10 @@ Which would you like to do?`,
717
736
  requestBody.data.relationships = {};
718
737
  }
719
738
  requestBody.data.relationships.field_tags = {
720
- data: tagUuids.map(uuid => ({
739
+ data: tagUuids.map((uuid) => ({
721
740
  type: "taxonomy_term--tags",
722
- id: uuid
723
- }))
741
+ id: uuid,
742
+ })),
724
743
  };
725
744
  }
726
745
  }
@@ -734,8 +753,8 @@ Which would you like to do?`,
734
753
  requestBody.data.relationships.field_affinity_group_node = {
735
754
  data: {
736
755
  type: "node--affinity_group",
737
- id: groupUuid
738
- }
756
+ id: groupUuid,
757
+ },
739
758
  };
740
759
  }
741
760
  else {
@@ -746,7 +765,7 @@ Which would you like to do?`,
746
765
  if (args.external_link) {
747
766
  requestBody.data.attributes.field_news_external_link = {
748
767
  uri: args.external_link.uri,
749
- title: args.external_link.title || ""
768
+ title: args.external_link.title || "",
750
769
  };
751
770
  }
752
771
  // Update where to share if provided
@@ -757,16 +776,18 @@ Which would you like to do?`,
757
776
  const nid = result.data?.attributes?.drupal_internal__nid;
758
777
  const baseUrl = process.env.DRUPAL_API_URL;
759
778
  return {
760
- content: [{
779
+ content: [
780
+ {
761
781
  type: "text",
762
782
  text: JSON.stringify({
763
783
  success: true,
764
784
  message: "Announcement updated",
765
785
  uuid: result.data?.id,
766
786
  title: result.data?.attributes?.title,
767
- edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null
768
- })
769
- }]
787
+ edit_url: nid ? `${baseUrl}/node/${nid}/edit` : null,
788
+ }),
789
+ },
790
+ ],
770
791
  };
771
792
  }
772
793
  /**
@@ -776,26 +797,30 @@ Which would you like to do?`,
776
797
  // Enforce confirmation parameter
777
798
  if (!args.confirmed) {
778
799
  return {
779
- content: [{
800
+ content: [
801
+ {
780
802
  type: "text",
781
803
  text: JSON.stringify({
782
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.",
783
- uuid: args.uuid
784
- })
785
- }]
805
+ uuid: args.uuid,
806
+ }),
807
+ },
808
+ ],
786
809
  };
787
810
  }
788
811
  const auth = this.getDrupalAuth();
789
812
  await auth.delete(`/jsonapi/node/access_news/${args.uuid}`);
790
813
  return {
791
- content: [{
814
+ content: [
815
+ {
792
816
  type: "text",
793
817
  text: JSON.stringify({
794
818
  success: true,
795
819
  message: "Announcement deleted",
796
- uuid: args.uuid
797
- })
798
- }]
820
+ uuid: args.uuid,
821
+ }),
822
+ },
823
+ ],
799
824
  };
800
825
  }
801
826
  /**
@@ -803,11 +828,12 @@ Which would you like to do?`,
803
828
  */
804
829
  async getMyAnnouncements(args) {
805
830
  const auth = this.getDrupalAuth();
806
- // Get the acting user's UUID
807
- const actingUserUid = this.getActingUserUid();
808
- const userUuid = await this.getUserUuidByUid(actingUserUid);
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);
809
834
  if (!userUuid) {
810
- throw new Error(`Could not find user with UID ${actingUserUid}. Verify the ACTING_USER_UID is correct.`);
835
+ throw new Error(`Could not find Drupal user for ACCESS ID "${actingUserAccessId}". ` +
836
+ `Verify the user exists in Drupal.`);
811
837
  }
812
838
  const limit = args.limit || 25;
813
839
  const result = await auth.get(`/jsonapi/node/access_news?filter[uid.id]=${userUuid}&page[limit]=${limit}&sort=-created`);
@@ -822,30 +848,35 @@ Which would you like to do?`,
822
848
  created: item.attributes?.created,
823
849
  published_date: item.attributes?.field_published_date,
824
850
  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
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,
828
855
  };
829
856
  });
830
857
  return {
831
- content: [{
858
+ content: [
859
+ {
832
860
  type: "text",
833
861
  text: JSON.stringify({
834
862
  total: announcements.length,
835
- items: announcements
836
- })
837
- }]
863
+ items: announcements,
864
+ }),
865
+ },
866
+ ],
838
867
  };
839
868
  }
840
869
  // ============================================================================
841
870
  // Helper Methods
842
871
  // ============================================================================
843
872
  /**
844
- * Get a user's UUID by their Drupal user ID (internal helper)
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.
845
876
  */
846
- async getUserUuidByUid(uid) {
877
+ async getUserUuidByAccessId(accessId) {
847
878
  const auth = this.getDrupalAuth();
848
- const result = await auth.get(`/jsonapi/user/user?filter[drupal_internal__uid]=${uid}`);
879
+ const result = await auth.get(`/jsonapi/user/user?filter[name]=${encodeURIComponent(accessId)}`);
849
880
  if (!result.data || result.data.length === 0) {
850
881
  return null;
851
882
  }
@@ -856,30 +887,32 @@ Which would you like to do?`,
856
887
  */
857
888
  async getAnnouncementContext() {
858
889
  const auth = this.getDrupalAuth();
859
- // Get the acting user's UUID
860
- const actingUserUid = this.getActingUserUid();
861
- const userUuid = await this.getUserUuidByUid(actingUserUid);
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);
862
893
  if (!userUuid) {
863
- throw new Error(`Could not find user with UID ${actingUserUid}`);
894
+ throw new Error(`Could not find Drupal user for ACCESS ID "${actingUserAccessId}". ` +
895
+ `Verify the user exists in Drupal.`);
864
896
  }
865
897
  // Fetch tags and affinity groups in parallel
866
898
  const [tagsResult, groupsResult] = await Promise.all([
867
899
  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`)
900
+ auth.get(`/jsonapi/node/affinity_group?filter[field_coordinator.id]=${userUuid}&filter[status]=1&page[limit]=100`),
869
901
  ]);
870
902
  const tags = (tagsResult.data || []).map((item) => ({
871
903
  name: item.attributes?.name,
872
- uuid: item.id
904
+ uuid: item.id,
873
905
  }));
874
906
  const affinityGroups = (groupsResult.data || []).map((item) => ({
875
907
  id: item.attributes?.field_group_id,
876
908
  uuid: item.id,
877
909
  name: item.attributes?.title,
878
- category: item.attributes?.field_affinity_group_category
910
+ category: item.attributes?.field_affinity_group_category,
879
911
  }));
880
912
  const isCoordinator = affinityGroups.length > 0;
881
913
  return {
882
- content: [{
914
+ content: [
915
+ {
883
916
  type: "text",
884
917
  text: JSON.stringify({
885
918
  tags: tags,
@@ -889,14 +922,21 @@ Which would you like to do?`,
889
922
  where_to_share_options: [
890
923
  { value: "Announcements page", description: "Public announcements listing" },
891
924
  { 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)" }
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
+ },
894
933
  ],
895
934
  guidance: isCoordinator
896
935
  ? "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
- }]
936
+ : "User is not a coordinator. Standard announcement fields apply.",
937
+ }),
938
+ },
939
+ ],
900
940
  };
901
941
  }
902
942
  /**
@@ -933,13 +973,13 @@ Which would you like to do?`,
933
973
  "email to affinity group": "email_to_your_affinity_group",
934
974
  "email to your affinity group": "email_to_your_affinity_group",
935
975
  // 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",
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",
940
980
  };
941
981
  normalizeWhereToShare(values) {
942
- return values.map(v => {
982
+ return values.map((v) => {
943
983
  const normalized = AnnouncementsServer.WHERE_TO_SHARE_MAP[v.toLowerCase()];
944
984
  if (!normalized) {
945
985
  throw new Error(`Invalid where_to_share value: "${v}". ` +