@rmdes/indiekit-endpoint-conversations 2.3.1 → 2.4.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.
@@ -80,6 +80,19 @@ async function dashboard(request, response) {
80
80
  }
81
81
  }
82
82
 
83
+ // Get item counts by channel (ingestion path)
84
+ let channelCounts = {};
85
+ if (itemsCollection) {
86
+ const counts = await itemsCollection
87
+ .aggregate([
88
+ { $group: { _id: "$channel", count: { $sum: 1 } } },
89
+ ])
90
+ .toArray();
91
+ for (const c of counts) {
92
+ channelCounts[c._id] = c.count;
93
+ }
94
+ }
95
+
83
96
  response.render("conversations", {
84
97
  title: response.__
85
98
  ? response.__("conversations.title")
@@ -90,6 +103,7 @@ async function dashboard(request, response) {
90
103
  summaries,
91
104
  recentItems,
92
105
  platformCounts,
106
+ channelCounts,
93
107
  typeCounts,
94
108
  baseUrl: config.mountPath || "/conversations",
95
109
  });
@@ -103,6 +117,7 @@ async function dashboard(request, response) {
103
117
  summaries: [],
104
118
  recentItems: [],
105
119
  platformCounts: {},
120
+ channelCounts: {},
106
121
  typeCounts: {},
107
122
  });
108
123
  }
@@ -399,6 +414,7 @@ async function ingest(request, response) {
399
414
  const item = {
400
415
  canonical_url: canonicalUrl,
401
416
  source: classification.source,
417
+ channel: "webhook",
402
418
  type: classification.type,
403
419
  author: webmention.author || {
404
420
  name: "Unknown",
@@ -33,10 +33,10 @@ export async function findCanonicalPost(application, syndicationUrl) {
33
33
  export async function resolveCanonicalUrl(application, targetUrl, siteUrl) {
34
34
  // If the target is already on our domain, it's likely canonical
35
35
  if (targetUrl.startsWith(siteUrl)) {
36
- return targetUrl;
36
+ return targetUrl.replace(/\/$/, "");
37
37
  }
38
38
 
39
39
  // Otherwise try to find via syndication reverse lookup
40
40
  const canonical = await findCanonicalPost(application, targetUrl);
41
- return canonical || targetUrl;
41
+ return (canonical || targetUrl).replace(/\/$/, "");
42
42
  }
@@ -95,11 +95,89 @@ export async function runPollCycle(indiekit, options) {
95
95
  await pollActivityPub(indiekit, stateCollection, state);
96
96
  }
97
97
 
98
+ // Normalize trailing slashes in canonical_url (one-time migration)
99
+ await normalizeCanonicalUrls(indiekit, stateCollection);
100
+
98
101
  // Backfill missing avatars from ap_notifications (one-time sweep per cycle)
99
102
  await backfillMissingAvatars(indiekit, stateCollection);
100
103
 
101
104
  // Backfill platform names for items stored as "activitypub" (one-time)
102
105
  await backfillPlatformNames(indiekit, stateCollection);
106
+
107
+ // Backfill channel field for items predating its introduction (one-time)
108
+ await backfillChannelField(indiekit, stateCollection);
109
+ }
110
+
111
+ /**
112
+ * One-time migration: normalize trailing slashes in canonical_url.
113
+ * Strips trailing slash from all canonical_url values, then removes
114
+ * duplicates that were created by the slash inconsistency.
115
+ */
116
+ async function normalizeCanonicalUrls(indiekit, stateCollection) {
117
+ try {
118
+ const state = await stateCollection.findOne({ _id: "poll_cursors" });
119
+ if (state?.canonical_url_normalized) return;
120
+
121
+ const itemsCollection = indiekit.collections.get("conversation_items");
122
+ if (!itemsCollection) return;
123
+
124
+ // Find all items with trailing slash in canonical_url
125
+ const itemsWithSlash = await itemsCollection
126
+ .find({ canonical_url: /\/$/ })
127
+ .toArray();
128
+
129
+ if (itemsWithSlash.length === 0) {
130
+ await stateCollection.findOneAndUpdate(
131
+ { _id: "poll_cursors" },
132
+ { $set: { canonical_url_normalized: true } },
133
+ { upsert: true },
134
+ );
135
+ return;
136
+ }
137
+
138
+ let updated = 0;
139
+ let deduped = 0;
140
+
141
+ for (const item of itemsWithSlash) {
142
+ const normalized = item.canonical_url.replace(/\/$/, "");
143
+
144
+ // Check if a non-slash version already exists with same platform_id
145
+ const existing = await itemsCollection.findOne({
146
+ canonical_url: normalized,
147
+ platform_id: item.platform_id,
148
+ });
149
+
150
+ if (existing) {
151
+ // Duplicate — remove the trailing-slash version
152
+ await itemsCollection.deleteOne({ _id: item._id });
153
+ deduped++;
154
+ } else {
155
+ // No duplicate — just normalize the URL
156
+ await itemsCollection.updateOne(
157
+ { _id: item._id },
158
+ { $set: { canonical_url: normalized } },
159
+ );
160
+ updated++;
161
+ }
162
+ }
163
+
164
+ if (updated > 0 || deduped > 0) {
165
+ console.info(
166
+ `[Conversations] URL normalization: updated ${updated}, removed ${deduped} duplicates from ${itemsWithSlash.length} items with trailing slashes`,
167
+ );
168
+ }
169
+
170
+ await stateCollection.findOneAndUpdate(
171
+ { _id: "poll_cursors" },
172
+ { $set: { canonical_url_normalized: true } },
173
+ { upsert: true },
174
+ );
175
+ } catch (error) {
176
+ console.warn(
177
+ "[Conversations] URL normalization error:",
178
+ error.message,
179
+ );
180
+ }
103
181
  }
104
182
 
105
183
  /**
@@ -302,6 +380,84 @@ async function backfillPlatformNames(indiekit, stateCollection) {
302
380
  }
303
381
  }
304
382
 
383
+ /**
384
+ * Backfill the `channel` field for existing items that predate its introduction.
385
+ * Derives channel from platform_id prefix and bridgy_url presence.
386
+ * One-time operation — marks complete after first successful run.
387
+ */
388
+ async function backfillChannelField(indiekit, stateCollection) {
389
+ try {
390
+ const itemsCollection = indiekit.collections.get("conversation_items");
391
+ if (!itemsCollection) return;
392
+
393
+ const state = await stateCollection.findOne({ _id: "poll_cursors" });
394
+ if (state?.channel_backfill_complete) return;
395
+
396
+ // Find all items missing the channel field
397
+ const itemsWithoutChannel = await itemsCollection
398
+ .find({ channel: { $exists: false } })
399
+ .project({ _id: 1, platform_id: 1, bridgy_url: 1 })
400
+ .toArray();
401
+
402
+ if (itemsWithoutChannel.length === 0) {
403
+ await stateCollection.findOneAndUpdate(
404
+ { _id: "poll_cursors" },
405
+ { $set: { channel_backfill_complete: true } },
406
+ { upsert: true },
407
+ );
408
+ return;
409
+ }
410
+
411
+ // Group items by derived channel for batched updateMany
412
+ const groups = new Map();
413
+
414
+ for (const item of itemsWithoutChannel) {
415
+ const pid = item.platform_id || "";
416
+ const hasBridgy = !!item.bridgy_url;
417
+ let channel;
418
+
419
+ if (pid.startsWith("activitypub:")) {
420
+ channel = "activitypub_inbox";
421
+ } else if (hasBridgy) {
422
+ channel = "webhook";
423
+ } else if (pid.startsWith("mastodon:")) {
424
+ channel = "mastodon_api";
425
+ } else if (pid.startsWith("bluesky:")) {
426
+ channel = "bluesky_api";
427
+ } else {
428
+ channel = "webhook";
429
+ }
430
+
431
+ if (!groups.has(channel)) groups.set(channel, []);
432
+ groups.get(channel).push(item._id);
433
+ }
434
+
435
+ let updated = 0;
436
+
437
+ for (const [channel, ids] of groups) {
438
+ const result = await itemsCollection.updateMany(
439
+ { _id: { $in: ids } },
440
+ { $set: { channel } },
441
+ );
442
+ updated += result.modifiedCount;
443
+ }
444
+
445
+ if (updated > 0) {
446
+ console.info(
447
+ `[Conversations] Channel backfill: set channel on ${updated} items`,
448
+ );
449
+ }
450
+
451
+ await stateCollection.findOneAndUpdate(
452
+ { _id: "poll_cursors" },
453
+ { $set: { channel_backfill_complete: true } },
454
+ { upsert: true },
455
+ );
456
+ } catch (error) {
457
+ console.warn("[Conversations] Channel backfill error:", error.message);
458
+ }
459
+ }
460
+
305
461
  /**
306
462
  * Poll Mastodon notifications and store matching interactions
307
463
  */
@@ -356,6 +512,7 @@ async function pollMastodon(indiekit, stateCollection, state, credentials) {
356
512
  await upsertConversationItem(indiekit, {
357
513
  canonical_url: canonicalUrl,
358
514
  source: "mastodon",
515
+ channel: "mastodon_api",
359
516
  type: notification.type,
360
517
  author: notification.author,
361
518
  content: notification.content,
@@ -452,6 +609,7 @@ async function pollBluesky(indiekit, stateCollection, state, credentials) {
452
609
  await upsertConversationItem(indiekit, {
453
610
  canonical_url: canonicalUrl,
454
611
  source: "bluesky",
612
+ channel: "bluesky_api",
455
613
  type: notification.type,
456
614
  author: notification.author,
457
615
  content: notification.content,
@@ -569,6 +727,7 @@ async function pollActivityPub(indiekit, stateCollection, state) {
569
727
  await upsertConversationItem(indiekit, {
570
728
  canonical_url: interaction.canonical_url,
571
729
  source: interaction.platform,
730
+ channel: "activitypub_inbox",
572
731
  type: interaction.type,
573
732
  author: interaction.author,
574
733
  content: interaction.content,
@@ -22,6 +22,11 @@ function getCollection(application) {
22
22
  export async function upsertConversationItem(application, item) {
23
23
  const collection = getCollection(application);
24
24
 
25
+ // Normalize canonical_url — strip trailing slash for consistent deduplication
26
+ if (item.canonical_url) {
27
+ item.canonical_url = item.canonical_url.replace(/\/$/, "");
28
+ }
29
+
25
30
  const result = await collection.findOneAndUpdate(
26
31
  {
27
32
  canonical_url: item.canonical_url,
@@ -159,4 +164,9 @@ export async function createIndexes(application) {
159
164
  { name: "source_filter" },
160
165
  );
161
166
 
167
+ await collection.createIndex(
168
+ { channel: 1, received_at: -1 },
169
+ { name: "channel_filter" },
170
+ );
171
+
162
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-conversations",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "Conversation aggregation endpoint for Indiekit. Backend enrichment service that polls Mastodon/Bluesky notifications and serves JF2-compatible data for the interactions page.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -37,7 +37,7 @@
37
37
  </p>
38
38
  {% endif %}
39
39
  <p style="font-size: 0.85em; margin: 0.25rem 0">
40
- {{ platformCounts.mastodon or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
40
+ {{ channelCounts.mastodon_api or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
41
41
  </p>
42
42
  {% else %}
43
43
  <p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
@@ -71,7 +71,7 @@
71
71
  </p>
72
72
  {% endif %}
73
73
  <p style="font-size: 0.85em; margin: 0.25rem 0">
74
- {{ platformCounts.bluesky or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
74
+ {{ channelCounts.bluesky_api or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
75
75
  </p>
76
76
  {% else %}
77
77
  <p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
@@ -105,7 +105,7 @@
105
105
  </p>
106
106
  {% endif %}
107
107
  <p style="font-size: 0.85em; margin: 0.25rem 0">
108
- {{ platformCounts.activitypub or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
108
+ {{ channelCounts.activitypub_inbox or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
109
109
  </p>
110
110
  {% else %}
111
111
  <p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
@@ -130,7 +130,7 @@
130
130
  POST {{ baseUrl }}/ingest
131
131
  </code>
132
132
  <p style="font-size: 0.85em; margin: 0.25rem 0">
133
- {{ platformCounts.webmention or 0 }} {{ __("conversations.dashboard.itemsReceived") }}
133
+ {{ channelCounts.webhook or 0 }} {{ __("conversations.dashboard.itemsReceived") }}
134
134
  </p>
135
135
  </div>
136
136
  </div>