@plusscommunities/pluss-core-aws 2.0.26-auth.0 → 2.0.26-beta.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.
Files changed (94) hide show
  1. package/aws/getDefaultEmailAddress.js +21 -21
  2. package/aws/getEmailService.js +16 -16
  3. package/aws/getEmailServiceInfo.js +26 -26
  4. package/aws/sendEmail.js +31 -31
  5. package/config.js +1 -1
  6. package/db/activity/publishActivity.js +22 -22
  7. package/db/analytics/checkActivityExists.js +15 -15
  8. package/db/analytics/logAnalyticsActivity.js +69 -37
  9. package/db/analytics/scheduleOldAggregation.js +14 -14
  10. package/db/auth/getSiteSetting.js +12 -12
  11. package/db/auth/getSiteUserTypes.js +16 -16
  12. package/db/auth/getUserAuth.js +13 -13
  13. package/db/automatedactions/getActionBySiteTrigger.js +9 -9
  14. package/db/common/deleteRef.js +21 -21
  15. package/db/common/editRef.js +36 -36
  16. package/db/common/getRef.js +23 -23
  17. package/db/common/getTableCount.js +18 -18
  18. package/db/common/indexQuery.js +17 -17
  19. package/db/common/indexQueryRecursive.js +20 -20
  20. package/db/common/scanRef.js +18 -18
  21. package/db/common/scanRefRecursive.js +20 -20
  22. package/db/common/updateAttribute.js +27 -27
  23. package/db/common/updateRef.js +20 -20
  24. package/db/linkedUsers/getLinkedBy.js +21 -21
  25. package/db/linkedUsers/getLinkedTo.js +21 -21
  26. package/db/notifications/deleteNotificationsByEntity.js +21 -21
  27. package/db/notifications/getNotificationSetting.js +14 -14
  28. package/db/notifications/publishNotifications.js +39 -39
  29. package/db/postgres/getKnex.js +214 -0
  30. package/db/postgres/safeError.js +40 -0
  31. package/db/property/Property.js +128 -0
  32. package/db/property/PropertyRepository.js +86 -0
  33. package/db/scheduledActions/deleteActionQueue.js +1 -1
  34. package/db/scheduledActions/getActionQueueByEntityId.js +10 -10
  35. package/db/scheduledActions/getActionQueueByEntityKey.js +9 -9
  36. package/db/scheduledActions/getActionQueueById.js +9 -9
  37. package/db/scheduledActions/getActionQueueByTriggerAt.js +14 -14
  38. package/db/scheduledActions/updateActionQueue.js +29 -29
  39. package/db/strings/getString.js +20 -20
  40. package/db/strings/logUpdate.js +18 -18
  41. package/db/templates/getTemplateById.js +1 -1
  42. package/db/templates/getTemplatesList.js +10 -10
  43. package/db/templates/updateTemplate.js +9 -9
  44. package/db/users/getRole.js +1 -1
  45. package/db/users/getUser.js +9 -9
  46. package/db/users/getUserByEmail.js +17 -17
  47. package/helper/audience/filterByAudienceType.js +27 -27
  48. package/helper/audience/filterOnAudienceType.js +26 -26
  49. package/helper/audience/getAudience.js +187 -187
  50. package/helper/audience/getMatchingAudienceTypes.js +21 -21
  51. package/helper/audience/getMatchingAudienceTypesFromPreview.js +60 -60
  52. package/helper/audience/getMatchingTags.js +15 -15
  53. package/helper/audience/isValidAudience.js +20 -20
  54. package/helper/auth/checkTokenBlacklist.js +17 -17
  55. package/helper/auth/getApiKeyFromReq.js +2 -2
  56. package/helper/auth/getSessionUser.js +70 -85
  57. package/helper/auth/getSessionUserFromReq.js +2 -2
  58. package/helper/auth/getSessionUserFromReqAuthKey.js +11 -11
  59. package/helper/auth/validateApiKey.js +32 -32
  60. package/helper/auth/validateMasterAuth.js +174 -174
  61. package/helper/auth/validateSiteAccess.js +12 -12
  62. package/helper/auth/validateSiteSetting.js +7 -7
  63. package/helper/auth/validateUserLoggedIn.js +19 -19
  64. package/helper/createGuid.js +5 -5
  65. package/helper/generateJsonResponse.js +27 -27
  66. package/helper/getUserPreview.js +57 -57
  67. package/helper/getUserPreviewFromHeader.js +17 -17
  68. package/helper/getUserPreviewFromReq.js +17 -17
  69. package/helper/hqPublishing.js +465 -0
  70. package/helper/index.js +28 -28
  71. package/helper/notifySiteConfigs.js +67 -52
  72. package/helper/opengraph/getOpenGraph.js +12 -12
  73. package/helper/rates/checkRateLimit.js +38 -38
  74. package/helper/requestToSource.js +10 -10
  75. package/helper/sendEmail.js +120 -120
  76. package/helper/templates/replacePlaceHolders.js +29 -29
  77. package/helper/time/getLocalTimestamp.js +18 -18
  78. package/helper/time/getSiteTimezone.js +11 -11
  79. package/helper/triggerAutomatedAction.js +25 -25
  80. package/helper/userToUserPreview.js +23 -23
  81. package/helper/users/getUserTypesByPermission.js +24 -24
  82. package/helper/users/getUsersByPermission.js +20 -20
  83. package/notification/prepNotification.js +144 -144
  84. package/notification/sendNotifications.js +166 -166
  85. package/package.json +37 -40
  86. package/templates/supportTicketEmails.js +8 -8
  87. package/helper/auth/context/AuthenticationContext.js +0 -50
  88. package/helper/auth/context/AuthenticationStrategy.js +0 -20
  89. package/helper/auth/context/auth0/Strategy.js +0 -12
  90. package/helper/auth/context/auth0/functions/decodeAccessToken.js +0 -102
  91. package/helper/auth/context/auth0/functions/getSessionUser.js +0 -21
  92. package/helper/auth/context/boltonclarke/Strategy.js +0 -10
  93. package/helper/auth/context/cognito/Strategy.js +0 -12
  94. package/helper/auth/context/cognito/functions/getSessionUser.js +0 -76
@@ -3,66 +3,66 @@ const getUser = require("../db/users/getUser");
3
3
  const { getConfig } = require("../config");
4
4
 
5
5
  module.exports = async (id, includeType, includeSite, options) => {
6
- return new Promise((resolve, reject) => {
7
- const logId = log("getUserPreview", "Input", {
8
- id,
9
- includeType,
10
- includeSite,
11
- options,
12
- });
6
+ return new Promise((resolve, reject) => {
7
+ const logId = log("getUserPreview", "Input", {
8
+ id,
9
+ includeType,
10
+ includeSite,
11
+ options,
12
+ });
13
13
 
14
- if (!id) {
15
- log("getUserPreview", "NoId", "id is missing", logId);
16
- return resolve({
17
- id: null,
18
- userId: null,
19
- displayName: "Pluss",
20
- profilePic: null,
21
- type: null,
22
- site: getConfig().anySignUpSite,
23
- });
24
- }
14
+ if (!id) {
15
+ log("getUserPreview", "NoId", "id is missing", logId);
16
+ return resolve({
17
+ id: null,
18
+ userId: null,
19
+ displayName: "Pluss",
20
+ profilePic: null,
21
+ type: null,
22
+ site: getConfig().anySignUpSite,
23
+ });
24
+ }
25
25
 
26
- getUser(id)
27
- .then((user) => {
28
- const returnObj = {
29
- id,
30
- userId: id,
31
- displayName: user.displayName,
32
- profilePic: user.profilePic || null,
33
- };
26
+ getUser(id)
27
+ .then((user) => {
28
+ const returnObj = {
29
+ id,
30
+ userId: id,
31
+ displayName: user.displayName,
32
+ profilePic: user.profilePic || null,
33
+ };
34
34
 
35
- if (includeType || includeSite) {
36
- returnObj.Roles = user.Roles;
37
- if (!user.Roles) {
38
- returnObj.Roles = [
39
- {
40
- type: user.type,
41
- site: user.site,
42
- },
43
- ];
44
- }
45
- }
35
+ if (includeType || includeSite) {
36
+ returnObj.Roles = user.Roles;
37
+ if (!user.Roles) {
38
+ returnObj.Roles = [
39
+ {
40
+ type: user.type,
41
+ site: user.site,
42
+ },
43
+ ];
44
+ }
45
+ }
46
46
 
47
- if (includeType) {
48
- returnObj.type = user.type || null;
49
- }
50
- if (includeSite) {
51
- returnObj.site = user.site || null;
52
- }
53
- if (options) {
54
- if (options.includeEmail) {
55
- returnObj.email = user.email !== "empty" ? user.email : null;
56
- }
57
- }
58
- log("getUserPreview", "Result", returnObj, logId);
47
+ if (includeType) {
48
+ returnObj.type = user.type || null;
49
+ }
50
+ if (includeSite) {
51
+ returnObj.site = user.site || null;
52
+ }
53
+ if (options) {
54
+ if (options.includeEmail) {
55
+ returnObj.email = user.email !== "empty" ? user.email : null;
56
+ }
57
+ }
58
+ log("getUserPreview", "Result", returnObj, logId);
59
59
 
60
- resolve(returnObj);
61
- })
62
- .catch((error) => {
63
- log("getUserPreview", "Error", error, logId);
64
- console.log("failed to get user preview", id);
65
- reject(error);
66
- });
67
- });
60
+ resolve(returnObj);
61
+ })
62
+ .catch((error) => {
63
+ log("getUserPreview", "Error", error, logId);
64
+ console.log("failed to get user preview", id);
65
+ reject(error);
66
+ });
67
+ });
68
68
  };
@@ -3,21 +3,21 @@ const getSessionUser = require("./auth/getSessionUser");
3
3
  const getUserPreview = require("./getUserPreview");
4
4
 
5
5
  module.exports = async (authkey, includeType, inlcudeSite, options) => {
6
- return new Promise((resolve, reject) => {
7
- getSessionUser(authkey)
8
- .then((uid) => {
9
- getUserPreview(uid, includeType, inlcudeSite, options)
10
- .then((user) => {
11
- resolve(user);
12
- })
13
- .catch((error) => {
14
- log("getUserPreviewFromHeader", "Error:getUserPreview", error);
15
- reject(error);
16
- });
17
- })
18
- .catch((error) => {
19
- log("getUserPreviewFromHeader", "Error:getSessionUser", error);
20
- reject(error);
21
- });
22
- });
6
+ return new Promise((resolve, reject) => {
7
+ getSessionUser(authkey)
8
+ .then((uid) => {
9
+ getUserPreview(uid, includeType, inlcudeSite, options)
10
+ .then((user) => {
11
+ resolve(user);
12
+ })
13
+ .catch((error) => {
14
+ log("getUserPreviewFromHeader", "Error:getUserPreview", error);
15
+ reject(error);
16
+ });
17
+ })
18
+ .catch((error) => {
19
+ log("getUserPreviewFromHeader", "Error:getSessionUser", error);
20
+ reject(error);
21
+ });
22
+ });
23
23
  };
@@ -2,21 +2,21 @@ const getSessionUserFromReqAuthKey = require("./auth/getSessionUserFromReqAuthKe
2
2
  const getUserPreview = require("./getUserPreview");
3
3
 
4
4
  module.exports = async (req, includeType, inlcudeSite, options) => {
5
- return new Promise((resolve, reject) => {
6
- getSessionUserFromReqAuthKey(req)
7
- .then((uid) => {
8
- getUserPreview(uid, includeType, inlcudeSite, options)
9
- .then((user) => {
10
- resolve(user);
11
- })
12
- .catch((error) => {
13
- console.log("failed to get user preview", uid);
14
- reject(error);
15
- });
16
- })
17
- .catch((error) => {
18
- console.log("failed to get session user", authkey);
19
- reject(error);
20
- });
21
- });
5
+ return new Promise((resolve, reject) => {
6
+ getSessionUserFromReqAuthKey(req)
7
+ .then((uid) => {
8
+ getUserPreview(uid, includeType, inlcudeSite, options)
9
+ .then((user) => {
10
+ resolve(user);
11
+ })
12
+ .catch((error) => {
13
+ console.log("failed to get user preview", uid);
14
+ reject(error);
15
+ });
16
+ })
17
+ .catch((error) => {
18
+ console.log("failed to get session user", req);
19
+ reject(error);
20
+ });
21
+ });
22
22
  };
@@ -0,0 +1,465 @@
1
+ /**
2
+ * PC-1441 Epic 1 — Cross-site Publishing Framework primitives (§13).
3
+ *
4
+ * Entity-agnostic helpers for HQ-published content lifecycle. Newsletter is the
5
+ * first entity to adopt this in July 2026 (Stories A–F under PC-1441); Q4+
6
+ * entities (Surveys, Information, Facilities, Services) plug in by depending on
7
+ * the same primitives — `HqSourceId` / `PublishedToSites` / `TemplateId` are
8
+ * universal field conventions, the helpers below don't reference any newsletter-
9
+ * specific table or shape.
10
+ *
11
+ * Story coverage:
12
+ * - **Story A (PC-1443)** — `isHqLocked` (lock guard for community-copy mutations).
13
+ * - **Story B (PC-1444)** — adds `findCopiesByHqSourceId`, `fanOutHqSource`,
14
+ * `propagateHqEdit`, `cascadeHqRetract`, and `incrementTemplateUseCount`.
15
+ *
16
+ * If this file grows beyond ~300 lines or develops sub-themes, refactor to a
17
+ * `hqPublishing/` subdirectory with `index.js` re-exports.
18
+ */
19
+
20
+ const AWS = require("aws-sdk");
21
+ const moment = require("moment");
22
+ const indexQueryRecursive = require("../db/common/indexQueryRecursive");
23
+ const updateRef = require("../db/common/updateRef");
24
+ const editRef = require("../db/common/editRef");
25
+ const { log } = require("./index");
26
+
27
+ // Raw DocumentClient for atomic-increment operations that editRef can't do
28
+ // safely under concurrent stream-handler invocations.
29
+ const dynamoDb = new AWS.DynamoDB.DocumentClient({ convertEmptyValues: true });
30
+
31
+ /**
32
+ * Fields owned by each community copy independently — never overwritten on
33
+ * propagation from the HQ-source row. Other entities can pass a wider list via
34
+ * `propagateHqEdit`'s `protectedFields` param if their schema has more identity
35
+ * columns.
36
+ */
37
+ const DEFAULT_PROTECTED_FIELDS = [
38
+ "Id",
39
+ "RowId",
40
+ "Site",
41
+ "HqSourceId",
42
+ "PublishedToSites", // HQ-only field; copies don't carry the multi-site list
43
+ "UnsentNotification", // each copy's notification state is independent
44
+ "Deleted", // cascadeHqRetract is the dedicated path; propagateHqEdit must not touch this
45
+ "UnixTimestamp", // per-copy fan-out timestamp; HQ-source MODIFY must not rewind copies' feed position
46
+ "UnixTimestampReverse", // ↳ paired with UnixTimestamp; same rationale (used as feed-sort key on community sites)
47
+ ];
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Story A — lock guard
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Returns `true` when the entry is a community copy of an HQ-published post —
55
+ * i.e. its `HqSourceId` is **truthy**.
56
+ *
57
+ * **Falsy test (`Boolean(HqSourceId)`) — NOT `!== null`.** Legacy entries and
58
+ * locally-created entries have `HqSourceId` `undefined`; `undefined !== null`
59
+ * evaluates to `true`, which would wrongly lock every pre-feature post. The
60
+ * falsy check accepts `undefined`, `null`, and `""` as "not locked." See PC-1441
61
+ * scoping doc §3.2 AC2.3 + §6.1 + commit `1123c79` on `docs/enterprise-roadmap-scoping`.
62
+ *
63
+ * Universal — no role-based exception, including masters. HQ users edit the
64
+ * HQ-source row (where `!HqSourceId`, so the lock does not apply); the stream
65
+ * cascades to community copies. This is the API-side gate that pairs with the
66
+ * admin UI lock indicator (PC-1448) — defense-in-depth.
67
+ *
68
+ * @param {object|null|undefined} entry - Any object with an `HqSourceId` field
69
+ * (typically a row fetched from the entity's DB table).
70
+ * @returns {boolean} `true` if the entry is a locked community copy; `false`
71
+ * for legacy / locally-authored / HQ-source rows.
72
+ */
73
+ const isHqLocked = (entry) => Boolean(entry?.HqSourceId);
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Story B — fan-out, propagation, retract cascade, UseCount
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Query the GSI on `HqSourceId` to locate every community copy of a given HQ
81
+ * source row. Returns the full row data (the GSI uses `Projection: ALL` so
82
+ * follow-up reads aren't needed).
83
+ *
84
+ * @param {string} hqSourceId — the `Id` of the HQ-source row.
85
+ * @param {string} tableName — entity's table name (without prefix; helper
86
+ * prepends `process.env.tablePrefix`).
87
+ * @param {string} [indexName="NewsletterEntriesHqSourceIdIndex"] — GSI name.
88
+ * Other entities pass their own index name.
89
+ * @returns {Promise<object[]>} array of copy rows; `[]` if no copies exist
90
+ * (HQ source has no fan-out yet).
91
+ */
92
+ const findCopiesByHqSourceId = async (
93
+ hqSourceId,
94
+ tableName,
95
+ indexName = "NewsletterEntriesHqSourceIdIndex",
96
+ ) => {
97
+ if (!hqSourceId) {
98
+ return [];
99
+ }
100
+ // indexQueryRecursive paginates LastEvaluatedKey internally and returns a
101
+ // flat array — required because a single GSI query response caps at 1 MiB
102
+ // and large-content HQ fan-outs to ~50 client sites can exceed that. With
103
+ // the non-recursive indexQuery, propagateHqEdit + cascadeHqRetract would
104
+ // silently miss copies on page 2+, leaving them stale after edits and
105
+ // visible after retractions.
106
+ const items = await indexQueryRecursive(tableName, {
107
+ IndexName: indexName,
108
+ KeyConditionExpression: "HqSourceId = :sid",
109
+ ExpressionAttributeValues: { ":sid": hqSourceId },
110
+ });
111
+ return items || [];
112
+ };
113
+
114
+ /**
115
+ * Fan out one community-copy row per target site listed in
116
+ * `hqSourceRow.PublishedToSites`. Each copy carries:
117
+ * - **Deterministic RowId** `${targetSiteId}_hqcopy-${hqSourceRow.Id}` so
118
+ * re-fires of the stream INSERT event become idempotent put-with-same-key
119
+ * operations (no duplicates).
120
+ * - `Id` derived from `hqcopy-${hqSourceRow.Id}` (mirrors the deterministic
121
+ * RowId; stable across stream re-fires).
122
+ * - `Site: targetSiteId` and `HqSourceId: hqSourceRow.Id` (the lock-guard
123
+ * anchor).
124
+ * - Full denormalised content from the HQ-source row, MINUS HQ-only fields
125
+ * (`PublishedToSites`) and identity fields the copy owns independently.
126
+ * - Fresh `UnixTimestamp` per copy so each community's feed sees the post as
127
+ * "now-published" rather than the HQ-source's creation time.
128
+ *
129
+ * Caller (the entity's stream handler) is responsible for SKIPPING
130
+ * `publishNotifications` on the HQ-source INSERT — notifications fire natively
131
+ * on each copy's own INSERT stream event downstream (per AC1.4 / U1.2).
132
+ *
133
+ * @param {object} hqSourceRow — the HQ-source row from the stream NewImage.
134
+ * @param {string} tableName — entity's table name.
135
+ * @returns {Promise<object[]>} array of the copy rows written.
136
+ */
137
+ const fanOutHqSource = async (hqSourceRow, tableName) => {
138
+ if (!hqSourceRow || hqSourceRow.Site !== "hq") {
139
+ return [];
140
+ }
141
+ const targets = Array.isArray(hqSourceRow.PublishedToSites)
142
+ ? hqSourceRow.PublishedToSites
143
+ : [];
144
+ if (targets.length === 0) {
145
+ return [];
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) => {
164
+ const copyId = `hqcopy-${hqSourceRow.Id}`;
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).
180
+ const now = moment.utc().valueOf();
181
+ const publishTs = hqSourceRow.UnixTimestamp || now;
182
+
183
+ const writes = targetSites.map((targetSiteId) => {
184
+ // Spread + override pattern: keep all content fields, swap identity
185
+ // fields, drop HQ-only fields. Spread is shallow — nested objects
186
+ // (Author, etc.) share references with the source row, which is fine
187
+ // because we're writing to a separate row and DDB serialises by value.
188
+ const copy = { ...hqSourceRow };
189
+ copy.Id = copyId;
190
+ copy.Site = targetSiteId;
191
+ copy.RowId = `${targetSiteId}_${copyId}`;
192
+ copy.HqSourceId = hqSourceRow.Id;
193
+ copy.UnixTimestamp = publishTs;
194
+ copy.UnixTimestampReverse = Number.MAX_SAFE_INTEGER - publishTs;
195
+ // Each copy's own INSERT fires publishNotifications via the existing
196
+ // pipeline. Setting UnsentNotification: "X" matches the convention
197
+ // used by addNewsletterEntry when caller requested a notification.
198
+ copy.UnsentNotification = hqSourceRow.UnsentNotification || "X";
199
+ // HQ-only fields don't travel:
200
+ delete copy.PublishedToSites;
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;
207
+ return updateRef(tableName, copy).then(() => copy);
208
+ });
209
+ return Promise.all(writes);
210
+ };
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
+
269
+ /**
270
+ * Propagate an HQ-source MODIFY to every community copy via the GSI lookup.
271
+ * Skips the retract case (where `Deleted: true`) — that flows through
272
+ * `cascadeHqRetract` instead.
273
+ *
274
+ * Identity fields the copy owns independently (Id, RowId, Site, HqSourceId,
275
+ * PublishedToSites, UnsentNotification, Deleted) are NEVER overwritten. Other
276
+ * entities can extend the protected list via `options.protectedFields`.
277
+ *
278
+ * Latency target per §3.1 AC1.5: end-to-end within 30s. Each copy gets a
279
+ * separate `editRef` call (get-merge-put); they run in parallel.
280
+ *
281
+ * @param {object} hqSourceRow — NewImage of the HQ-source row from the stream.
282
+ * @param {object} _prevRow — OldImage (currently unused; reserved for future
283
+ * delta-diff optimisation when we want to skip propagation on no-op changes).
284
+ * @param {string} tableName — entity's table name.
285
+ * @param {object} [options]
286
+ * @param {string} [options.indexName="NewsletterEntriesHqSourceIdIndex"]
287
+ * @param {string[]} [options.protectedFields=DEFAULT_PROTECTED_FIELDS]
288
+ * @returns {Promise<number>} count of copies updated.
289
+ */
290
+ const propagateHqEdit = async (
291
+ hqSourceRow,
292
+ _prevRow,
293
+ tableName,
294
+ options = {},
295
+ ) => {
296
+ const protectedFields =
297
+ options.protectedFields || DEFAULT_PROTECTED_FIELDS;
298
+ const indexName =
299
+ options.indexName || "NewsletterEntriesHqSourceIdIndex";
300
+
301
+ if (!hqSourceRow || hqSourceRow.Site !== "hq" || hqSourceRow.Deleted) {
302
+ return 0;
303
+ }
304
+
305
+ const copies = await findCopiesByHqSourceId(
306
+ hqSourceRow.Id,
307
+ tableName,
308
+ indexName,
309
+ );
310
+ if (copies.length === 0) {
311
+ return 0;
312
+ }
313
+
314
+ // Build the propagation payload: everything from the HQ-source row EXCEPT
315
+ // protected identity fields.
316
+ const propagated = { ...hqSourceRow };
317
+ for (const field of protectedFields) {
318
+ delete propagated[field];
319
+ }
320
+ propagated.Changed = moment.utc().unix();
321
+
322
+ // IMPORTANT: pass a fresh shallow copy per call. `editRef` synchronously
323
+ // mutates its `updates` argument (`updates[keyCol] = id` inside the Promise
324
+ // constructor) AFTER taking a `cloneDeep` snapshot. Across parallel
325
+ // `Promise.all(map(...))` invocations, iteration N's `cloneDeep` would
326
+ // capture iteration N-1's `RowId` mutation, then later overwrite copy N's
327
+ // `RowId` during the get-merge-put, writing copy N's content back to copy
328
+ // N-1's key (and leaving copy N's own row untouched). Per-call `{...propagated}`
329
+ // gives each invocation an isolated payload it can mutate safely.
330
+ await Promise.all(
331
+ copies.map((copy) =>
332
+ editRef(tableName, "RowId", copy.RowId, { ...propagated }),
333
+ ),
334
+ );
335
+ return copies.length;
336
+ };
337
+
338
+ /**
339
+ * Cascade `Deleted: true` to every community copy when the HQ-source row is
340
+ * retracted (`Deleted: true` set on the HQ-source MODIFY). Per §12.2, data is
341
+ * preserved at the storage layer — only the `Deleted` flag flips; no rows are
342
+ * removed. Un-retract UI is out of scope this iteration.
343
+ *
344
+ * @param {object} hqSourceRow — NewImage of the retracted HQ-source row.
345
+ * @param {string} tableName — entity's table name.
346
+ * @param {object} [options]
347
+ * @param {string} [options.indexName="NewsletterEntriesHqSourceIdIndex"]
348
+ * @returns {Promise<number>} count of copies marked deleted.
349
+ */
350
+ const cascadeHqRetract = async (hqSourceRow, tableName, options = {}) => {
351
+ const indexName =
352
+ options.indexName || "NewsletterEntriesHqSourceIdIndex";
353
+
354
+ if (!hqSourceRow || hqSourceRow.Site !== "hq" || !hqSourceRow.Deleted) {
355
+ return 0;
356
+ }
357
+
358
+ const copies = await findCopiesByHqSourceId(
359
+ hqSourceRow.Id,
360
+ tableName,
361
+ indexName,
362
+ );
363
+ if (copies.length === 0) {
364
+ return 0;
365
+ }
366
+
367
+ const deletionUpdate = {
368
+ Deleted: true,
369
+ Changed: moment.utc().unix(),
370
+ };
371
+ // Per-call shallow copy — see propagateHqEdit for the editRef-mutation-race
372
+ // rationale. Even though `deletionUpdate` is tiny, the same RowId clobber
373
+ // happens here: under sharing, only the FIRST copy actually receives
374
+ // `Deleted: true`; subsequent writes land at the wrong RowId.
375
+ await Promise.all(
376
+ copies.map((copy) =>
377
+ editRef(tableName, "RowId", copy.RowId, { ...deletionUpdate }),
378
+ ),
379
+ );
380
+ return copies.length;
381
+ };
382
+
383
+ /**
384
+ * Atomically increment `UseCount` on a `contentLibrary` template row when a
385
+ * new entity row is created from that template (`TemplateId !== null`).
386
+ *
387
+ * **Atomic** — uses DDB `UpdateExpression: ADD UseCount :inc` rather than
388
+ * get-merge-put so concurrent stream invocations don't clobber each other's
389
+ * increments. (`editRef`'s get-then-put pattern would race here.)
390
+ *
391
+ * **Graceful degradation while Story C unshipped**: when the `contentLibrary`
392
+ * table doesn't exist yet (C / PC-1445 has not shipped), DDB raises
393
+ * `ResourceNotFoundException`. Helper catches it, logs INFO, and returns
394
+ * `false` — does NOT throw. This lets Story B ship independent of Story C
395
+ * (per PC-1444 AC-B.3 graceful-degradation clause).
396
+ *
397
+ * **Cross-account UseCount aggregation is out of scope** — Pluss Library uses
398
+ * across all clients are NOT counted here (§12.4 deferred).
399
+ *
400
+ * @param {string} templateId — the `Id` of the contentLibrary row.
401
+ * @param {string} contentLibraryTable — entity-agnostic; caller passes the
402
+ * table name (without prefix).
403
+ * @returns {Promise<boolean>} `true` if increment applied; `false` on graceful
404
+ * skip (table not shipped yet, or no template with that Id exists).
405
+ */
406
+ const incrementTemplateUseCount = async (templateId, contentLibraryTable) => {
407
+ if (!templateId || !contentLibraryTable) {
408
+ return false;
409
+ }
410
+ const params = {
411
+ TableName: `${process.env.tablePrefix}${contentLibraryTable}`,
412
+ Key: { Id: templateId },
413
+ UpdateExpression: "ADD UseCount :inc SET LastUsedTimestamp = :now",
414
+ // Guard against minting a phantom row: ADD on a missing key would CREATE
415
+ // it with UseCount=1. Only increment a template that actually exists; a
416
+ // stale/bogus TemplateId is skipped (ConditionalCheckFailed below).
417
+ ConditionExpression: "attribute_exists(Id)",
418
+ ExpressionAttributeValues: {
419
+ ":inc": 1,
420
+ // epoch ms — the contentLibrary timestamp convention (matches
421
+ // UpdatedAt). Was .unix() (seconds); reconciled to .valueOf().
422
+ ":now": moment().utc().valueOf(),
423
+ },
424
+ };
425
+ try {
426
+ await dynamoDb.update(params).promise();
427
+ return true;
428
+ } catch (error) {
429
+ if (error?.code === "ResourceNotFoundException") {
430
+ // Graceful degradation while Story C (contentLibrary table) is
431
+ // unshipped — see AC-B.3. Not an error; the rest of the stream
432
+ // handler should continue.
433
+ log(
434
+ "incrementTemplateUseCount",
435
+ "TableNotFound",
436
+ "contentLibrary table not found — skipped",
437
+ );
438
+ return false;
439
+ }
440
+ if (error?.code === "ConditionalCheckFailedException") {
441
+ // TemplateId references no existing template — skip rather than
442
+ // create a phantom row. Not an error; continue the stream handler.
443
+ log("incrementTemplateUseCount", "NoSuchTemplate", { templateId });
444
+ return false;
445
+ }
446
+ // Anything else is genuinely unexpected — surface it.
447
+ log("incrementTemplateUseCount", "Error", error);
448
+ throw error;
449
+ }
450
+ };
451
+
452
+ module.exports = {
453
+ // Story A
454
+ isHqLocked,
455
+ // Story B
456
+ findCopiesByHqSourceId,
457
+ fanOutHqSource,
458
+ writeCopiesForTargets,
459
+ syncHqTargets,
460
+ propagateHqEdit,
461
+ cascadeHqRetract,
462
+ incrementTemplateUseCount,
463
+ // Exposed for tests and other entities that want to extend the protected list
464
+ DEFAULT_PROTECTED_FIELDS,
465
+ };