@rmdes/indiekit-endpoint-conversations 2.3.1 → 2.3.2

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.
@@ -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,6 +95,9 @@ 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
 
@@ -102,6 +105,78 @@ export async function runPollCycle(indiekit, options) {
102
105
  await backfillPlatformNames(indiekit, stateCollection);
103
106
  }
104
107
 
108
+ /**
109
+ * One-time migration: normalize trailing slashes in canonical_url.
110
+ * Strips trailing slash from all canonical_url values, then removes
111
+ * duplicates that were created by the slash inconsistency.
112
+ */
113
+ async function normalizeCanonicalUrls(indiekit, stateCollection) {
114
+ try {
115
+ const state = await stateCollection.findOne({ _id: "poll_cursors" });
116
+ if (state?.canonical_url_normalized) return;
117
+
118
+ const itemsCollection = indiekit.collections.get("conversation_items");
119
+ if (!itemsCollection) return;
120
+
121
+ // Find all items with trailing slash in canonical_url
122
+ const itemsWithSlash = await itemsCollection
123
+ .find({ canonical_url: /\/$/ })
124
+ .toArray();
125
+
126
+ if (itemsWithSlash.length === 0) {
127
+ await stateCollection.findOneAndUpdate(
128
+ { _id: "poll_cursors" },
129
+ { $set: { canonical_url_normalized: true } },
130
+ { upsert: true },
131
+ );
132
+ return;
133
+ }
134
+
135
+ let updated = 0;
136
+ let deduped = 0;
137
+
138
+ for (const item of itemsWithSlash) {
139
+ const normalized = item.canonical_url.replace(/\/$/, "");
140
+
141
+ // Check if a non-slash version already exists with same platform_id
142
+ const existing = await itemsCollection.findOne({
143
+ canonical_url: normalized,
144
+ platform_id: item.platform_id,
145
+ });
146
+
147
+ if (existing) {
148
+ // Duplicate — remove the trailing-slash version
149
+ await itemsCollection.deleteOne({ _id: item._id });
150
+ deduped++;
151
+ } else {
152
+ // No duplicate — just normalize the URL
153
+ await itemsCollection.updateOne(
154
+ { _id: item._id },
155
+ { $set: { canonical_url: normalized } },
156
+ );
157
+ updated++;
158
+ }
159
+ }
160
+
161
+ if (updated > 0 || deduped > 0) {
162
+ console.info(
163
+ `[Conversations] URL normalization: updated ${updated}, removed ${deduped} duplicates from ${itemsWithSlash.length} items with trailing slashes`,
164
+ );
165
+ }
166
+
167
+ await stateCollection.findOneAndUpdate(
168
+ { _id: "poll_cursors" },
169
+ { $set: { canonical_url_normalized: true } },
170
+ { upsert: true },
171
+ );
172
+ } catch (error) {
173
+ console.warn(
174
+ "[Conversations] URL normalization error:",
175
+ error.message,
176
+ );
177
+ }
178
+ }
179
+
105
180
  /**
106
181
  * Backfill empty author.photo fields in conversation_items.
107
182
  * Tries four strategies in order:
@@ -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,
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.3.2",
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",