@plusscommunities/pluss-core-aws 2.0.26-auth.0 → 2.0.26
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.
- package/aws/getDefaultEmailAddress.js +21 -21
- package/aws/getEmailService.js +16 -16
- package/aws/getEmailServiceInfo.js +26 -26
- package/aws/sendEmail.js +31 -31
- package/config.js +1 -1
- package/db/activity/publishActivity.js +22 -22
- package/db/analytics/checkActivityExists.js +15 -15
- package/db/analytics/logAnalyticsActivity.js +104 -40
- package/db/analytics/scheduleOldAggregation.js +14 -14
- package/db/auth/getSiteSetting.js +12 -12
- package/db/auth/getSiteUserTypes.js +16 -16
- package/db/auth/getUserAuth.js +13 -13
- package/db/automatedactions/getActionBySiteTrigger.js +9 -9
- package/db/common/deleteRef.js +21 -21
- package/db/common/editRef.js +36 -36
- package/db/common/getRef.js +23 -23
- package/db/common/getTableCount.js +18 -18
- package/db/common/indexQuery.js +17 -17
- package/db/common/indexQueryRecursive.js +20 -20
- package/db/common/scanRef.js +18 -18
- package/db/common/scanRefRecursive.js +20 -20
- package/db/common/updateAttribute.js +27 -27
- package/db/common/updateRef.js +20 -20
- package/db/linkedUsers/getLinkedBy.js +21 -21
- package/db/linkedUsers/getLinkedTo.js +21 -21
- package/db/notifications/deleteNotificationsByEntity.js +21 -21
- package/db/notifications/getNotificationSetting.js +14 -14
- package/db/notifications/publishNotifications.js +39 -39
- package/db/postgres/getKnex.js +214 -0
- package/db/postgres/safeError.js +40 -0
- package/db/property/Property.js +128 -0
- package/db/property/PropertyRepository.js +86 -0
- package/db/scheduledActions/deleteActionQueue.js +1 -1
- package/db/scheduledActions/getActionQueueByEntityId.js +10 -10
- package/db/scheduledActions/getActionQueueByEntityKey.js +9 -9
- package/db/scheduledActions/getActionQueueById.js +9 -9
- package/db/scheduledActions/getActionQueueByTriggerAt.js +14 -14
- package/db/scheduledActions/updateActionQueue.js +29 -29
- package/db/strings/getString.js +20 -20
- package/db/strings/logUpdate.js +18 -18
- package/db/templates/getTemplateById.js +1 -1
- package/db/templates/getTemplatesList.js +10 -10
- package/db/templates/updateTemplate.js +9 -9
- package/db/users/getRole.js +1 -1
- package/db/users/getUser.js +9 -9
- package/db/users/getUserByEmail.js +17 -17
- package/helper/audience/filterByAudienceType.js +27 -27
- package/helper/audience/filterOnAudienceType.js +26 -26
- package/helper/audience/getAudience.js +187 -187
- package/helper/audience/getMatchingAudienceTypes.js +21 -21
- package/helper/audience/getMatchingAudienceTypesFromPreview.js +60 -60
- package/helper/audience/getMatchingTags.js +15 -15
- package/helper/audience/isValidAudience.js +20 -20
- package/helper/auth/checkTokenBlacklist.js +17 -17
- package/helper/auth/getApiKeyFromReq.js +2 -2
- package/helper/auth/getSessionUser.js +70 -85
- package/helper/auth/getSessionUserFromReq.js +2 -2
- package/helper/auth/getSessionUserFromReqAuthKey.js +11 -11
- package/helper/auth/validateApiKey.js +32 -32
- package/helper/auth/validateMasterAuth.js +174 -174
- package/helper/auth/validateSiteAccess.js +12 -12
- package/helper/auth/validateSiteSetting.js +7 -7
- package/helper/auth/validateUserLoggedIn.js +19 -19
- package/helper/createGuid.js +5 -5
- package/helper/generateJsonResponse.js +27 -27
- package/helper/getUserPreview.js +57 -57
- package/helper/getUserPreviewFromHeader.js +17 -17
- package/helper/getUserPreviewFromReq.js +17 -17
- package/helper/hqPublishing.js +465 -0
- package/helper/index.js +28 -28
- package/helper/notifySiteConfigs.js +67 -52
- package/helper/opengraph/getOpenGraph.js +12 -12
- package/helper/rates/checkRateLimit.js +38 -38
- package/helper/requestToSource.js +10 -10
- package/helper/sendEmail.js +120 -120
- package/helper/templates/replacePlaceHolders.js +29 -29
- package/helper/time/getLocalTimestamp.js +18 -18
- package/helper/time/getSiteTimezone.js +11 -11
- package/helper/triggerAutomatedAction.js +25 -25
- package/helper/userToUserPreview.js +23 -23
- package/helper/users/getUserTypesByPermission.js +24 -24
- package/helper/users/getUsersByPermission.js +20 -20
- package/notification/prepNotification.js +144 -144
- package/notification/sendNotifications.js +166 -166
- package/package.json +37 -40
- package/templates/supportTicketEmails.js +8 -8
- package/helper/auth/context/AuthenticationContext.js +0 -50
- package/helper/auth/context/AuthenticationStrategy.js +0 -20
- package/helper/auth/context/auth0/Strategy.js +0 -12
- package/helper/auth/context/auth0/functions/decodeAccessToken.js +0 -102
- package/helper/auth/context/auth0/functions/getSessionUser.js +0 -21
- package/helper/auth/context/boltonclarke/Strategy.js +0 -10
- package/helper/auth/context/cognito/Strategy.js +0 -12
- package/helper/auth/context/cognito/functions/getSessionUser.js +0 -76
package/helper/getUserPreview.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
};
|