@plusscommunities/pluss-core-aws 2.0.25-auth.0 → 2.0.25-beta.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.
Files changed (90) 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/scheduledActions/deleteActionQueue.js +1 -1
  30. package/db/scheduledActions/getActionQueueByEntityId.js +10 -10
  31. package/db/scheduledActions/getActionQueueByEntityKey.js +9 -9
  32. package/db/scheduledActions/getActionQueueById.js +9 -9
  33. package/db/scheduledActions/getActionQueueByTriggerAt.js +14 -14
  34. package/db/scheduledActions/updateActionQueue.js +29 -29
  35. package/db/strings/getString.js +20 -20
  36. package/db/strings/logUpdate.js +18 -18
  37. package/db/templates/getTemplateById.js +1 -1
  38. package/db/templates/getTemplatesList.js +10 -10
  39. package/db/templates/updateTemplate.js +9 -9
  40. package/db/users/getRole.js +1 -1
  41. package/db/users/getUser.js +9 -9
  42. package/db/users/getUserByEmail.js +17 -17
  43. package/helper/audience/filterByAudienceType.js +27 -27
  44. package/helper/audience/filterOnAudienceType.js +26 -26
  45. package/helper/audience/getAudience.js +187 -187
  46. package/helper/audience/getMatchingAudienceTypes.js +21 -21
  47. package/helper/audience/getMatchingAudienceTypesFromPreview.js +60 -60
  48. package/helper/audience/getMatchingTags.js +15 -15
  49. package/helper/audience/isValidAudience.js +20 -20
  50. package/helper/auth/checkTokenBlacklist.js +17 -17
  51. package/helper/auth/getApiKeyFromReq.js +2 -2
  52. package/helper/auth/getSessionUser.js +70 -85
  53. package/helper/auth/getSessionUserFromReq.js +2 -2
  54. package/helper/auth/getSessionUserFromReqAuthKey.js +11 -11
  55. package/helper/auth/validateApiKey.js +32 -32
  56. package/helper/auth/validateMasterAuth.js +174 -174
  57. package/helper/auth/validateSiteAccess.js +12 -12
  58. package/helper/auth/validateSiteSetting.js +7 -7
  59. package/helper/auth/validateUserLoggedIn.js +19 -19
  60. package/helper/createGuid.js +5 -5
  61. package/helper/generateJsonResponse.js +27 -27
  62. package/helper/getUserPreview.js +57 -57
  63. package/helper/getUserPreviewFromHeader.js +17 -17
  64. package/helper/getUserPreviewFromReq.js +17 -17
  65. package/helper/hqPublishing.js +337 -0
  66. package/helper/index.js +28 -28
  67. package/helper/notifySiteConfigs.js +132 -0
  68. package/helper/opengraph/getOpenGraph.js +12 -12
  69. package/helper/rates/checkRateLimit.js +38 -38
  70. package/helper/requestToSource.js +10 -10
  71. package/helper/sendEmail.js +120 -120
  72. package/helper/templates/replacePlaceHolders.js +29 -29
  73. package/helper/time/getLocalTimestamp.js +18 -18
  74. package/helper/time/getSiteTimezone.js +11 -11
  75. package/helper/triggerAutomatedAction.js +25 -25
  76. package/helper/userToUserPreview.js +23 -23
  77. package/helper/users/getUserTypesByPermission.js +24 -24
  78. package/helper/users/getUsersByPermission.js +20 -20
  79. package/notification/prepNotification.js +144 -144
  80. package/notification/sendNotifications.js +166 -166
  81. package/package.json +35 -40
  82. package/templates/supportTicketEmails.js +8 -8
  83. package/helper/auth/context/AuthenticationContext.js +0 -50
  84. package/helper/auth/context/AuthenticationStrategy.js +0 -20
  85. package/helper/auth/context/auth0/Strategy.js +0 -12
  86. package/helper/auth/context/auth0/functions/decodeAccessToken.js +0 -102
  87. package/helper/auth/context/auth0/functions/getSessionUser.js +0 -21
  88. package/helper/auth/context/boltonclarke/Strategy.js +0 -10
  89. package/helper/auth/context/cognito/Strategy.js +0 -12
  90. 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,337 @@
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 indexQuery = require("../db/common/indexQuery");
23
+ const updateRef = require("../db/common/updateRef");
24
+ const editRef = require("../db/common/editRef");
25
+
26
+ // Raw DocumentClient for atomic-increment operations that editRef can't do
27
+ // safely under concurrent stream-handler invocations.
28
+ const dynamoDb = new AWS.DynamoDB.DocumentClient({ convertEmptyValues: true });
29
+
30
+ /**
31
+ * Fields owned by each community copy independently — never overwritten on
32
+ * propagation from the HQ-source row. Other entities can pass a wider list via
33
+ * `propagateHqEdit`'s `protectedFields` param if their schema has more identity
34
+ * columns.
35
+ */
36
+ const DEFAULT_PROTECTED_FIELDS = [
37
+ "Id",
38
+ "RowId",
39
+ "Site",
40
+ "HqSourceId",
41
+ "PublishedToSites", // HQ-only field; copies don't carry the multi-site list
42
+ "UnsentNotification", // each copy's notification state is independent
43
+ "Deleted", // cascadeHqRetract is the dedicated path; propagateHqEdit must not touch this
44
+ ];
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Story A — lock guard
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Returns `true` when the entry is a community copy of an HQ-published post —
52
+ * i.e. its `HqSourceId` is **truthy**.
53
+ *
54
+ * **Falsy test (`Boolean(HqSourceId)`) — NOT `!== null`.** Legacy entries and
55
+ * locally-created entries have `HqSourceId` `undefined`; `undefined !== null`
56
+ * evaluates to `true`, which would wrongly lock every pre-feature post. The
57
+ * falsy check accepts `undefined`, `null`, and `""` as "not locked." See PC-1441
58
+ * scoping doc §3.2 AC2.3 + §6.1 + commit `1123c79` on `docs/enterprise-roadmap-scoping`.
59
+ *
60
+ * Universal — no role-based exception, including masters. HQ users edit the
61
+ * HQ-source row (where `!HqSourceId`, so the lock does not apply); the stream
62
+ * cascades to community copies. This is the API-side gate that pairs with the
63
+ * admin UI lock indicator (PC-1448) — defense-in-depth.
64
+ *
65
+ * @param {object|null|undefined} entry - Any object with an `HqSourceId` field
66
+ * (typically a row fetched from the entity's DB table).
67
+ * @returns {boolean} `true` if the entry is a locked community copy; `false`
68
+ * for legacy / locally-authored / HQ-source rows.
69
+ */
70
+ const isHqLocked = (entry) => Boolean(entry?.HqSourceId);
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Story B — fan-out, propagation, retract cascade, UseCount
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Query the GSI on `HqSourceId` to locate every community copy of a given HQ
78
+ * source row. Returns the full row data (the GSI uses `Projection: ALL` so
79
+ * follow-up reads aren't needed).
80
+ *
81
+ * @param {string} hqSourceId — the `Id` of the HQ-source row.
82
+ * @param {string} tableName — entity's table name (without prefix; helper
83
+ * prepends `process.env.tablePrefix`).
84
+ * @param {string} [indexName="NewsletterEntriesHqSourceIdIndex"] — GSI name.
85
+ * Other entities pass their own index name.
86
+ * @returns {Promise<object[]>} array of copy rows; `[]` if no copies exist
87
+ * (HQ source has no fan-out yet).
88
+ */
89
+ const findCopiesByHqSourceId = async (
90
+ hqSourceId,
91
+ tableName,
92
+ indexName = "NewsletterEntriesHqSourceIdIndex",
93
+ ) => {
94
+ if (!hqSourceId) {
95
+ return [];
96
+ }
97
+ const result = await indexQuery(tableName, {
98
+ IndexName: indexName,
99
+ KeyConditionExpression: "HqSourceId = :sid",
100
+ ExpressionAttributeValues: { ":sid": hqSourceId },
101
+ });
102
+ return result?.Items || [];
103
+ };
104
+
105
+ /**
106
+ * Fan out one community-copy row per target site listed in
107
+ * `hqSourceRow.PublishedToSites`. Each copy carries:
108
+ * - **Deterministic RowId** `${targetSiteId}_hqcopy-${hqSourceRow.Id}` so
109
+ * re-fires of the stream INSERT event become idempotent put-with-same-key
110
+ * operations (no duplicates).
111
+ * - `Id` derived from `hqcopy-${hqSourceRow.Id}` (mirrors the deterministic
112
+ * RowId; stable across stream re-fires).
113
+ * - `Site: targetSiteId` and `HqSourceId: hqSourceRow.Id` (the lock-guard
114
+ * anchor).
115
+ * - Full denormalised content from the HQ-source row, MINUS HQ-only fields
116
+ * (`PublishedToSites`) and identity fields the copy owns independently.
117
+ * - Fresh `UnixTimestamp` per copy so each community's feed sees the post as
118
+ * "now-published" rather than the HQ-source's creation time.
119
+ *
120
+ * Caller (the entity's stream handler) is responsible for SKIPPING
121
+ * `publishNotifications` on the HQ-source INSERT — notifications fire natively
122
+ * on each copy's own INSERT stream event downstream (per AC1.4 / U1.2).
123
+ *
124
+ * @param {object} hqSourceRow — the HQ-source row from the stream NewImage.
125
+ * @param {string} tableName — entity's table name.
126
+ * @returns {Promise<object[]>} array of the copy rows written.
127
+ */
128
+ const fanOutHqSource = async (hqSourceRow, tableName) => {
129
+ if (!hqSourceRow || hqSourceRow.Site !== "hq") {
130
+ return [];
131
+ }
132
+ const targets = Array.isArray(hqSourceRow.PublishedToSites)
133
+ ? hqSourceRow.PublishedToSites
134
+ : [];
135
+ if (targets.length === 0) {
136
+ return [];
137
+ }
138
+ const copyId = `hqcopy-${hqSourceRow.Id}`;
139
+ const now = moment.utc().unix();
140
+
141
+ const writes = targets.map((targetSiteId) => {
142
+ // Spread + override pattern: keep all content fields, swap identity
143
+ // fields, drop HQ-only fields. Spread is shallow — nested objects
144
+ // (Author, etc.) share references with the source row, which is fine
145
+ // because we're writing to a separate row and DDB serialises by value.
146
+ const copy = { ...hqSourceRow };
147
+ copy.Id = copyId;
148
+ copy.Site = targetSiteId;
149
+ copy.RowId = `${targetSiteId}_${copyId}`;
150
+ copy.HqSourceId = hqSourceRow.Id;
151
+ copy.UnixTimestamp = now;
152
+ copy.UnixTimestampReverse = Number.MAX_SAFE_INTEGER - now;
153
+ // Each copy's own INSERT fires publishNotifications via the existing
154
+ // pipeline. Setting UnsentNotification: "X" matches the convention
155
+ // used by addNewsletterEntry when caller requested a notification.
156
+ copy.UnsentNotification = hqSourceRow.UnsentNotification || "X";
157
+ // HQ-only fields don't travel:
158
+ delete copy.PublishedToSites;
159
+ delete copy.Deleted; // copies start undeleted; retract cascade is separate
160
+ return updateRef(tableName, copy).then(() => copy);
161
+ });
162
+ return Promise.all(writes);
163
+ };
164
+
165
+ /**
166
+ * Propagate an HQ-source MODIFY to every community copy via the GSI lookup.
167
+ * Skips the retract case (where `Deleted: true`) — that flows through
168
+ * `cascadeHqRetract` instead.
169
+ *
170
+ * Identity fields the copy owns independently (Id, RowId, Site, HqSourceId,
171
+ * PublishedToSites, UnsentNotification, Deleted) are NEVER overwritten. Other
172
+ * entities can extend the protected list via `options.protectedFields`.
173
+ *
174
+ * Latency target per §3.1 AC1.5: end-to-end within 30s. Each copy gets a
175
+ * separate `editRef` call (get-merge-put); they run in parallel.
176
+ *
177
+ * @param {object} hqSourceRow — NewImage of the HQ-source row from the stream.
178
+ * @param {object} _prevRow — OldImage (currently unused; reserved for future
179
+ * delta-diff optimisation when we want to skip propagation on no-op changes).
180
+ * @param {string} tableName — entity's table name.
181
+ * @param {object} [options]
182
+ * @param {string} [options.indexName="NewsletterEntriesHqSourceIdIndex"]
183
+ * @param {string[]} [options.protectedFields=DEFAULT_PROTECTED_FIELDS]
184
+ * @returns {Promise<number>} count of copies updated.
185
+ */
186
+ const propagateHqEdit = async (
187
+ hqSourceRow,
188
+ _prevRow,
189
+ tableName,
190
+ options = {},
191
+ ) => {
192
+ const protectedFields =
193
+ options.protectedFields || DEFAULT_PROTECTED_FIELDS;
194
+ const indexName =
195
+ options.indexName || "NewsletterEntriesHqSourceIdIndex";
196
+
197
+ if (!hqSourceRow || hqSourceRow.Site !== "hq" || hqSourceRow.Deleted) {
198
+ return 0;
199
+ }
200
+
201
+ const copies = await findCopiesByHqSourceId(
202
+ hqSourceRow.Id,
203
+ tableName,
204
+ indexName,
205
+ );
206
+ if (copies.length === 0) {
207
+ return 0;
208
+ }
209
+
210
+ // Build the propagation payload: everything from the HQ-source row EXCEPT
211
+ // protected identity fields.
212
+ const propagated = { ...hqSourceRow };
213
+ for (const field of protectedFields) {
214
+ delete propagated[field];
215
+ }
216
+ propagated.Changed = moment.utc().unix();
217
+
218
+ await Promise.all(
219
+ copies.map((copy) =>
220
+ editRef(tableName, "RowId", copy.RowId, propagated),
221
+ ),
222
+ );
223
+ return copies.length;
224
+ };
225
+
226
+ /**
227
+ * Cascade `Deleted: true` to every community copy when the HQ-source row is
228
+ * retracted (`Deleted: true` set on the HQ-source MODIFY). Per §12.2, data is
229
+ * preserved at the storage layer — only the `Deleted` flag flips; no rows are
230
+ * removed. Un-retract UI is out of scope this iteration.
231
+ *
232
+ * @param {object} hqSourceRow — NewImage of the retracted HQ-source row.
233
+ * @param {string} tableName — entity's table name.
234
+ * @param {object} [options]
235
+ * @param {string} [options.indexName="NewsletterEntriesHqSourceIdIndex"]
236
+ * @returns {Promise<number>} count of copies marked deleted.
237
+ */
238
+ const cascadeHqRetract = async (hqSourceRow, tableName, options = {}) => {
239
+ const indexName =
240
+ options.indexName || "NewsletterEntriesHqSourceIdIndex";
241
+
242
+ if (!hqSourceRow || hqSourceRow.Site !== "hq" || !hqSourceRow.Deleted) {
243
+ return 0;
244
+ }
245
+
246
+ const copies = await findCopiesByHqSourceId(
247
+ hqSourceRow.Id,
248
+ tableName,
249
+ indexName,
250
+ );
251
+ if (copies.length === 0) {
252
+ return 0;
253
+ }
254
+
255
+ const deletionUpdate = {
256
+ Deleted: true,
257
+ Changed: moment.utc().unix(),
258
+ };
259
+ await Promise.all(
260
+ copies.map((copy) =>
261
+ editRef(tableName, "RowId", copy.RowId, deletionUpdate),
262
+ ),
263
+ );
264
+ return copies.length;
265
+ };
266
+
267
+ /**
268
+ * Atomically increment `UseCount` on a `contentLibrary` template row when a
269
+ * new entity row is created from that template (`TemplateId !== null`).
270
+ *
271
+ * **Atomic** — uses DDB `UpdateExpression: ADD UseCount :inc` rather than
272
+ * get-merge-put so concurrent stream invocations don't clobber each other's
273
+ * increments. (`editRef`'s get-then-put pattern would race here.)
274
+ *
275
+ * **Graceful degradation while Story C unshipped**: when the `contentLibrary`
276
+ * table doesn't exist yet (C / PC-1445 has not shipped), DDB raises
277
+ * `ResourceNotFoundException`. Helper catches it, logs INFO, and returns
278
+ * `false` — does NOT throw. This lets Story B ship independent of Story C
279
+ * (per PC-1444 AC-B.3 graceful-degradation clause).
280
+ *
281
+ * **Cross-account UseCount aggregation is out of scope** — Pluss Library uses
282
+ * across all clients are NOT counted here (§12.4 deferred).
283
+ *
284
+ * @param {string} templateId — the `Id` of the contentLibrary row.
285
+ * @param {string} contentLibraryTable — entity-agnostic; caller passes the
286
+ * table name (without prefix).
287
+ * @returns {Promise<boolean>} `true` if increment applied; `false` if table
288
+ * doesn't exist (graceful skip).
289
+ */
290
+ const incrementTemplateUseCount = async (templateId, contentLibraryTable) => {
291
+ if (!templateId || !contentLibraryTable) {
292
+ return false;
293
+ }
294
+ const params = {
295
+ TableName: `${process.env.tablePrefix}${contentLibraryTable}`,
296
+ Key: { Id: templateId },
297
+ UpdateExpression:
298
+ "ADD UseCount :inc SET LastUsedTimestamp = :now",
299
+ ExpressionAttributeValues: {
300
+ ":inc": 1,
301
+ ":now": moment.utc().unix(),
302
+ },
303
+ };
304
+ try {
305
+ await dynamoDb.update(params).promise();
306
+ return true;
307
+ } catch (error) {
308
+ if (error?.code === "ResourceNotFoundException") {
309
+ // Graceful degradation while Story C (contentLibrary table) is
310
+ // unshipped — see AC-B.3. Not an error; the rest of the stream
311
+ // handler should continue.
312
+ console.log(
313
+ "incrementTemplateUseCount: contentLibrary table not found — skipped (Story C not yet shipped)",
314
+ );
315
+ return false;
316
+ }
317
+ // Anything else is genuinely unexpected — surface it.
318
+ console.error(
319
+ "incrementTemplateUseCount: unexpected error",
320
+ error,
321
+ );
322
+ throw error;
323
+ }
324
+ };
325
+
326
+ module.exports = {
327
+ // Story A
328
+ isHqLocked,
329
+ // Story B
330
+ findCopiesByHqSourceId,
331
+ fanOutHqSource,
332
+ propagateHqEdit,
333
+ cascadeHqRetract,
334
+ incrementTemplateUseCount,
335
+ // Exposed for tests and other entities that want to extend the protected list
336
+ DEFAULT_PROTECTED_FIELDS,
337
+ };
package/helper/index.js CHANGED
@@ -3,28 +3,28 @@ const uuid = require("uuid");
3
3
  const { encode, decode } = require("html-entities");
4
4
 
5
5
  exports.thisOrDefault = (val, def) => {
6
- if (val) {
7
- return val;
8
- }
9
- return def;
6
+ if (val) {
7
+ return val;
8
+ }
9
+ return def;
10
10
  };
11
11
 
12
12
  exports.getRowId = (site, id) => {
13
- if (_.isEmpty(site)) {
14
- return id;
15
- }
16
- return `${site}_${id}`;
13
+ if (_.isEmpty(site)) {
14
+ return id;
15
+ }
16
+ return `${site}_${id}`;
17
17
  };
18
18
 
19
19
  exports.getMultiRowId = (cols) => {
20
- let result = "";
21
- cols.forEach((col) => {
22
- if (!_.isEmpty(result)) {
23
- result += "_";
24
- }
25
- result += col;
26
- });
27
- return result;
20
+ let result = "";
21
+ cols.forEach((col) => {
22
+ if (!_.isEmpty(result)) {
23
+ result += "_";
24
+ }
25
+ result += col;
26
+ });
27
+ return result;
28
28
  };
29
29
 
30
30
  // exports.getExtension = (filename) => {
@@ -36,7 +36,7 @@ exports.getMultiRowId = (cols) => {
36
36
  // };
37
37
 
38
38
  exports.getBody = (event) => {
39
- return typeof event.body === "string" ? JSON.parse(event.body) : event.body;
39
+ return typeof event.body === "string" ? JSON.parse(event.body) : event.body;
40
40
  };
41
41
 
42
42
  // exports.generatePassword = () => {
@@ -47,20 +47,20 @@ exports.getBody = (event) => {
47
47
  // };
48
48
 
49
49
  exports.log = (action, logKey, data, logId) => {
50
- if (!logId) {
51
- logId = this.generateLogId();
52
- }
53
- console.log(
54
- `[${action}]:[${logId}]:[${logKey}]`,
55
- typeof data === "string" || typeof data === "number"
56
- ? data
57
- : JSON.stringify(data)
58
- );
59
- return logId;
50
+ if (!logId) {
51
+ logId = this.generateLogId();
52
+ }
53
+ console.log(
54
+ `[${action}]:[${logId}]:[${logKey}]`,
55
+ typeof data === "string" || typeof data === "number"
56
+ ? data
57
+ : JSON.stringify(data),
58
+ );
59
+ return logId;
60
60
  };
61
61
 
62
62
  exports.generateLogId = () => {
63
- return uuid.v4().substring(30);
63
+ return uuid.v4().substring(30);
64
64
  };
65
65
 
66
66
  exports.encodeHtml = (html) => encode(html);