@plusscommunities/pluss-core-aws 2.0.25-beta.6 → 2.0.25-beta.8

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.
@@ -144,17 +144,43 @@ const fanOutHqSource = async (hqSourceRow, tableName) => {
144
144
  if (targets.length === 0) {
145
145
  return [];
146
146
  }
147
+ return writeCopiesForTargets(hqSourceRow, targets, tableName);
148
+ };
149
+
150
+ /**
151
+ * Write one community-copy row per site in `targetSites` (all of the source's
152
+ * PublishedToSites on INSERT fan-out, or just the newly-added subset on a
153
+ * MODIFY target-set change). Shared by fanOutHqSource and syncHqTargets. The
154
+ * deterministic RowId makes each write idempotent AND makes re-adding a
155
+ * previously-removed (soft-deleted) site a lossless resurrection — the PUT
156
+ * overwrites the row and drops the Deleted flag.
157
+ *
158
+ * @param {object} hqSourceRow — the HQ-source row.
159
+ * @param {string[]} targetSites — sites to write copies for.
160
+ * @param {string} tableName — entity's table name.
161
+ * @returns {Promise<object[]>} the copy rows written.
162
+ */
163
+ const writeCopiesForTargets = (hqSourceRow, targetSites, tableName) => {
147
164
  const copyId = `hqcopy-${hqSourceRow.Id}`;
148
- // IMPORTANT: milliseconds (.valueOf), not seconds (.unix). Newsletter
149
- // rows written by addNewsletterEntry.js use `moment.utc().valueOf()` for
150
- // UnixTimestamp. UnixTimestampReverse-keyed feed queries (the standard
151
- // Pluss list pattern) would otherwise rank HQ copies ~1000× ahead of
152
- // locally-authored posts foreverMAX_SAFE_INTEGER minus a number 10^9
153
- // is vastly larger than MAX_SAFE_INTEGER minus 10^12. Keep parity with
154
- // the canonical addNewsletterEntry convention.
165
+ // Preserve the HQ-source row's own UnixTimestamp on each copy rather than
166
+ // stamping "now". For an immediate publish the source timestamp already IS
167
+ // ~now, so feed ranking is unchanged; for a SCHEDULED publish (future
168
+ // timestamp set via the compose "Schedule Post" tab) preserving it keeps the
169
+ // copies hidden until the scheduled moment BOTH consumers key off it: the
170
+ // noFuture feed filter, and the deferred-notification scheduler
171
+ // (watchNewsletter / the INSERT-path checkSendNotifications both gate on
172
+ // `UnixTimestamp < now`). Stamping "now" here published scheduled HQ posts
173
+ // (feed + push) immediately, ignoring the schedule. Fall back to now only if
174
+ // the source somehow lacks a timestamp.
175
+ //
176
+ // IMPORTANT: milliseconds (.valueOf), not seconds (.unix) — parity with the
177
+ // addNewsletterEntry UnixTimestamp convention. UnixTimestampReverse-keyed
178
+ // feed queries would otherwise mis-rank an ms value against a seconds value
179
+ // (MAX_SAFE_INTEGER minus 10^9 vs minus 10^12).
155
180
  const now = moment.utc().valueOf();
181
+ const publishTs = hqSourceRow.UnixTimestamp || now;
156
182
 
157
- const writes = targets.map((targetSiteId) => {
183
+ const writes = targetSites.map((targetSiteId) => {
158
184
  // Spread + override pattern: keep all content fields, swap identity
159
185
  // fields, drop HQ-only fields. Spread is shallow — nested objects
160
186
  // (Author, etc.) share references with the source row, which is fine
@@ -164,8 +190,8 @@ const fanOutHqSource = async (hqSourceRow, tableName) => {
164
190
  copy.Site = targetSiteId;
165
191
  copy.RowId = `${targetSiteId}_${copyId}`;
166
192
  copy.HqSourceId = hqSourceRow.Id;
167
- copy.UnixTimestamp = now;
168
- copy.UnixTimestampReverse = Number.MAX_SAFE_INTEGER - now;
193
+ copy.UnixTimestamp = publishTs;
194
+ copy.UnixTimestampReverse = Number.MAX_SAFE_INTEGER - publishTs;
169
195
  // Each copy's own INSERT fires publishNotifications via the existing
170
196
  // pipeline. Setting UnsentNotification: "X" matches the convention
171
197
  // used by addNewsletterEntry when caller requested a notification.
@@ -173,11 +199,73 @@ const fanOutHqSource = async (hqSourceRow, tableName) => {
173
199
  // HQ-only fields don't travel:
174
200
  delete copy.PublishedToSites;
175
201
  delete copy.Deleted; // copies start undeleted; retract cascade is separate
202
+ // Tagged references HQ-site users; on a community copy those IDs are
203
+ // orphaned (cosmetic "tagged people" display + a createUserEntries write
204
+ // keyed on non-members in newsletterChanged's INSERT branch). Audience
205
+ // targeting (All/Category) already governs who sees the copy.
206
+ delete copy.Tagged;
176
207
  return updateRef(tableName, copy).then(() => copy);
177
208
  });
178
209
  return Promise.all(writes);
179
210
  };
180
211
 
212
+ /**
213
+ * Reconcile community copies when an HQ-source MODIFY changes PublishedToSites:
214
+ * fan out copies for newly-ADDED sites, soft-delete copies for REMOVED sites.
215
+ * Content propagation to surviving copies is propagateHqEdit's job; this only
216
+ * reconciles the target SET. Re-adding a removed site resurrects its copy
217
+ * losslessly (deterministic RowId + PUT — see writeCopiesForTargets).
218
+ *
219
+ * MUST run sequentially relative to propagateHqEdit (never concurrently): both
220
+ * use editRef (get-merge-put), and two concurrent writes to the same removed-
221
+ * copy RowId would race (one clobbering the other's Deleted flag).
222
+ *
223
+ * @param {object} prevRow — OldImage of the HQ-source row.
224
+ * @param {object} nextRow — NewImage of the HQ-source row.
225
+ * @param {string} tableName — entity's table name.
226
+ * @param {object} [options]
227
+ * @param {string} [options.indexName="NewsletterEntriesHqSourceIdIndex"]
228
+ * @returns {Promise<{added: number, removed: number}>}
229
+ */
230
+ const syncHqTargets = async (prevRow, nextRow, tableName, options = {}) => {
231
+ const indexName = options.indexName || "NewsletterEntriesHqSourceIdIndex";
232
+ if (!nextRow || nextRow.Site !== "hq" || nextRow.Deleted) {
233
+ return { added: 0, removed: 0 };
234
+ }
235
+ const prev = Array.isArray(prevRow?.PublishedToSites)
236
+ ? prevRow.PublishedToSites
237
+ : [];
238
+ const next = Array.isArray(nextRow.PublishedToSites)
239
+ ? nextRow.PublishedToSites
240
+ : [];
241
+ const added = next.filter((s) => !prev.includes(s));
242
+ const removed = prev.filter((s) => !next.includes(s));
243
+
244
+ if (added.length > 0) {
245
+ await writeCopiesForTargets(nextRow, added, tableName);
246
+ }
247
+ if (removed.length > 0) {
248
+ const copies = await findCopiesByHqSourceId(
249
+ nextRow.Id,
250
+ tableName,
251
+ indexName,
252
+ );
253
+ const toRemove = copies.filter((c) => removed.includes(c.Site));
254
+ // Per-call fresh payload — editRef sync-mutates updates[keyCol]; sharing
255
+ // across Promise.all(map) would leak RowId between iterations (same
256
+ // rationale as propagateHqEdit / cascadeHqRetract).
257
+ await Promise.all(
258
+ toRemove.map((c) =>
259
+ editRef(tableName, "RowId", c.RowId, {
260
+ Deleted: true,
261
+ Changed: moment.utc().unix(),
262
+ }),
263
+ ),
264
+ );
265
+ }
266
+ return { added: added.length, removed: removed.length };
267
+ };
268
+
181
269
  /**
182
270
  * Propagate an HQ-source MODIFY to every community copy via the GSI lookup.
183
271
  * Skips the retract case (where `Deleted: true`) — that flows through
@@ -312,8 +400,8 @@ const cascadeHqRetract = async (hqSourceRow, tableName, options = {}) => {
312
400
  * @param {string} templateId — the `Id` of the contentLibrary row.
313
401
  * @param {string} contentLibraryTable — entity-agnostic; caller passes the
314
402
  * table name (without prefix).
315
- * @returns {Promise<boolean>} `true` if increment applied; `false` on graceful
316
- * skip (table not shipped yet, or no template with that Id exists).
403
+ * @returns {Promise<boolean>} `true` if increment applied; `false` if table
404
+ * doesn't exist (graceful skip).
317
405
  */
318
406
  const incrementTemplateUseCount = async (templateId, contentLibraryTable) => {
319
407
  if (!templateId || !contentLibraryTable) {
@@ -322,16 +410,11 @@ const incrementTemplateUseCount = async (templateId, contentLibraryTable) => {
322
410
  const params = {
323
411
  TableName: `${process.env.tablePrefix}${contentLibraryTable}`,
324
412
  Key: { Id: templateId },
325
- UpdateExpression: "ADD UseCount :inc SET LastUsedTimestamp = :now",
326
- // Guard against minting a phantom row: ADD on a missing key would CREATE
327
- // it with UseCount=1. Only increment a template that actually exists; a
328
- // stale/bogus TemplateId is skipped (ConditionalCheckFailed below).
329
- ConditionExpression: "attribute_exists(Id)",
413
+ UpdateExpression:
414
+ "ADD UseCount :inc SET LastUsedTimestamp = :now",
330
415
  ExpressionAttributeValues: {
331
416
  ":inc": 1,
332
- // epoch ms — the contentLibrary timestamp convention (matches
333
- // UpdatedAt). Was .unix() (seconds); reconciled to .valueOf().
334
- ":now": moment().utc().valueOf(),
417
+ ":now": moment.utc().unix(),
335
418
  },
336
419
  };
337
420
  try {
@@ -349,12 +432,6 @@ const incrementTemplateUseCount = async (templateId, contentLibraryTable) => {
349
432
  );
350
433
  return false;
351
434
  }
352
- if (error?.code === "ConditionalCheckFailedException") {
353
- // TemplateId references no existing template — skip rather than
354
- // create a phantom row. Not an error; continue the stream handler.
355
- log("incrementTemplateUseCount", "NoSuchTemplate", { templateId });
356
- return false;
357
- }
358
435
  // Anything else is genuinely unexpected — surface it.
359
436
  log("incrementTemplateUseCount", "Error", error);
360
437
  throw error;
@@ -367,6 +444,8 @@ module.exports = {
367
444
  // Story B
368
445
  findCopiesByHqSourceId,
369
446
  fanOutHqSource,
447
+ writeCopiesForTargets,
448
+ syncHqTargets,
370
449
  propagateHqEdit,
371
450
  cascadeHqRetract,
372
451
  incrementTemplateUseCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plusscommunities/pluss-core-aws",
3
- "version": "2.0.25-beta.6",
3
+ "version": "2.0.25-beta.8",
4
4
  "description": "Core extension package for Pluss Communities platform",
5
5
  "scripts": {
6
6
  "betapatch": "npm version prepatch --preid=beta",