abmp-npm 2.0.82 → 2.0.84
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/backend/association-expiry.js +10 -0
- package/backend/cms-data-methods.js +6 -0
- package/backend/consts.js +5 -0
- package/backend/daily-pull/process-member-methods.js +2 -0
- package/backend/daily-pull/sync-to-cms-methods.js +8 -3
- package/backend/jobs.js +22 -0
- package/backend/listing-priority.js +66 -0
- package/backend/login/sso-methods.js +17 -2
- package/backend/members-data-methods.js +12 -1
- package/backend/routers/utils.js +6 -0
- package/backend/tasks/consts.js +2 -0
- package/backend/tasks/member-updated-backfill-methods.js +158 -0
- package/backend/tasks/tasks-configs.js +21 -0
- package/package.json +1 -1
- package/public/consts.js +2 -0
|
@@ -119,6 +119,15 @@ const getTodayInAssociationTimeZone = (now = new Date()) => {
|
|
|
119
119
|
}
|
|
120
120
|
};
|
|
121
121
|
|
|
122
|
+
const isAssociationExpirationCurrent = (member, now) => {
|
|
123
|
+
const stored = member?.[ASSOCIATION_EXPIRATION_FIELD];
|
|
124
|
+
const expiration = stored instanceof Date ? stored : stored ? new Date(stored) : null;
|
|
125
|
+
|
|
126
|
+
if (!expiration || Number.isNaN(expiration.getTime())) return false;
|
|
127
|
+
|
|
128
|
+
return expiration.getTime() >= getTodayInAssociationTimeZone(now).getTime();
|
|
129
|
+
};
|
|
130
|
+
|
|
122
131
|
module.exports = {
|
|
123
132
|
parseExpirationToUtcDate,
|
|
124
133
|
classifyAssociationExpiration,
|
|
@@ -126,6 +135,7 @@ module.exports = {
|
|
|
126
135
|
summarizeExpirationOutcomes,
|
|
127
136
|
memberNeedsAssociationExpirationBackfill,
|
|
128
137
|
getTodayInAssociationTimeZone,
|
|
138
|
+
isAssociationExpirationCurrent,
|
|
129
139
|
ASSOCIATION_EXPIRATION_FIELD,
|
|
130
140
|
ASSOCIATION_TIME_ZONE,
|
|
131
141
|
EXPIRATION_OUTCOMES,
|
|
@@ -4,6 +4,10 @@ const { COLLECTIONS, MEMBERS_FIELDS } = require('../public/consts.js');
|
|
|
4
4
|
const { findMainAddress } = require('../public/Utils/sharedUtils.js');
|
|
5
5
|
const { calculateDistance, shuffleArray } = require('../public/Utils/sharedUtils.js');
|
|
6
6
|
|
|
7
|
+
const {
|
|
8
|
+
getTodayInAssociationTimeZone,
|
|
9
|
+
ASSOCIATION_EXPIRATION_FIELD,
|
|
10
|
+
} = require('./association-expiry');
|
|
7
11
|
const {
|
|
8
12
|
GEO_HASH_PRECISION,
|
|
9
13
|
MAX__MEMBERS_SEARCH_RESULTS,
|
|
@@ -34,6 +38,8 @@ function buildMembersSearchQuery(data) {
|
|
|
34
38
|
.ne('action', 'drop')
|
|
35
39
|
.ne('memberships.membertype', MEMBERSHIPS_TYPES.PAC_STAFF)
|
|
36
40
|
.eq('isVisible', true);
|
|
41
|
+
|
|
42
|
+
query = query.ge(ASSOCIATION_EXPIRATION_FIELD, getTodayInAssociationTimeZone());
|
|
37
43
|
let filterConfig = [
|
|
38
44
|
{
|
|
39
45
|
filterKey: 'practiceAreas',
|
package/backend/consts.js
CHANGED
|
@@ -43,6 +43,10 @@ const LOGIN_EMAIL_SYNC_STATUS = {
|
|
|
43
43
|
SKIPPED: 'skipped', // member has no wixMemberId, nothing to change
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
const LOGIN_REFUSAL_REASONS = {
|
|
47
|
+
ASSOCIATION_MEMBERSHIP_EXPIRED: 'ASSOCIATION_MEMBERSHIP_EXPIRED',
|
|
48
|
+
};
|
|
49
|
+
|
|
46
50
|
module.exports = {
|
|
47
51
|
CONFIG_KEYS,
|
|
48
52
|
MAX__MEMBERS_SEARCH_RESULTS,
|
|
@@ -55,4 +59,5 @@ module.exports = {
|
|
|
55
59
|
SSO_TOKEN_AUTH_API_URL,
|
|
56
60
|
BACKUP_API_URL,
|
|
57
61
|
LOGIN_EMAIL_SYNC_STATUS,
|
|
62
|
+
LOGIN_REFUSAL_REASONS,
|
|
58
63
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { ADDRESS_STATUS_TYPES } = require('../../public/consts');
|
|
2
|
+
const { ASSOCIATION_EXPIRATION_FIELD } = require('../association-expiry');
|
|
2
3
|
const { findMemberById, getMemberBySlug } = require('../members-data-methods');
|
|
3
4
|
const { isValidArray, generateGeoHash } = require('../utils');
|
|
4
5
|
|
|
@@ -198,6 +199,7 @@ async function createCoreMemberData(inputMemberData, existingDbMember, currentPa
|
|
|
198
199
|
memberships: inputMemberData.memberships,
|
|
199
200
|
pageNumber: currentPageNumber,
|
|
200
201
|
isVisible: inputMemberData.action !== MEMBER_ACTIONS.DROP,
|
|
202
|
+
[ASSOCIATION_EXPIRATION_FIELD]: inputMemberData[ASSOCIATION_EXPIRATION_FIELD] ?? null,
|
|
201
203
|
|
|
202
204
|
// Handle Member emails
|
|
203
205
|
...getMemberEmails(),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
const { taskManager } = require('psdev-task-manager');
|
|
2
2
|
|
|
3
|
+
const {
|
|
4
|
+
resolveAssociationExpiration,
|
|
5
|
+
ASSOCIATION_EXPIRATION_FIELD,
|
|
6
|
+
} = require('../association-expiry');
|
|
3
7
|
const { CONFIG_KEYS } = require('../consts');
|
|
4
8
|
const { fetchPACMembers } = require('../pac-api-methods');
|
|
5
9
|
const { TASKS_NAMES } = require('../tasks/consts');
|
|
@@ -126,9 +130,10 @@ async function synchronizeSinglePage(taskObject) {
|
|
|
126
130
|
}
|
|
127
131
|
return isUpdatedMember(member);
|
|
128
132
|
});
|
|
129
|
-
const toSyncMembersWithFilteredLicenses = toSyncMembers.map(member =>
|
|
130
|
-
filterLicensesByAssociation(member, siteAssociation)
|
|
131
|
-
|
|
133
|
+
const toSyncMembersWithFilteredLicenses = toSyncMembers.map(member => ({
|
|
134
|
+
...filterLicensesByAssociation(member, siteAssociation),
|
|
135
|
+
[ASSOCIATION_EXPIRATION_FIELD]: resolveAssociationExpiration(member, siteAssociation),
|
|
136
|
+
}));
|
|
132
137
|
if (toSyncMembers.length === 0) {
|
|
133
138
|
return {
|
|
134
139
|
success: true,
|
package/backend/jobs.js
CHANGED
|
@@ -154,6 +154,27 @@ async function scheduleAssociationExpiryBackfillTask(options = {}) {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* One-off backfill of memberUpdated. Run with `{ dryRun: true }` first: it reports the tier split
|
|
159
|
+
* across the whole collection without writing anything.
|
|
160
|
+
* @param {Object} [options]
|
|
161
|
+
* @param {boolean} [options.dryRun]
|
|
162
|
+
*/
|
|
163
|
+
async function scheduleMemberUpdatedBackfillTask(options = {}) {
|
|
164
|
+
try {
|
|
165
|
+
const { dryRun = false } = options || {};
|
|
166
|
+
console.log(`scheduleMemberUpdatedBackfill started! dryRun=${dryRun}`);
|
|
167
|
+
return await taskManager().schedule({
|
|
168
|
+
name: TASKS_NAMES.scheduleMemberUpdatedBackfill,
|
|
169
|
+
data: { dryRun },
|
|
170
|
+
type: 'scheduled',
|
|
171
|
+
});
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error(`Failed to scheduleMemberUpdatedBackfill: ${error.message}`);
|
|
174
|
+
throw new Error(`Failed to scheduleMemberUpdatedBackfill: ${error.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
157
178
|
async function updateSiteMapS3() {
|
|
158
179
|
try {
|
|
159
180
|
return await taskManager().schedule({
|
|
@@ -176,5 +197,6 @@ module.exports = {
|
|
|
176
197
|
scheduleNormalizeMemberEmailsTask,
|
|
177
198
|
scheduleSetAddressesToCityStateTask,
|
|
178
199
|
scheduleAssociationExpiryBackfillTask,
|
|
200
|
+
scheduleMemberUpdatedBackfillTask,
|
|
179
201
|
runDailyPullExecutionCheck,
|
|
180
202
|
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// What makes a listing "updated", shared by the backfill and the search ordering so they cannot
|
|
2
|
+
// drift on the definition. Deliberately free of Wix imports.
|
|
3
|
+
//
|
|
4
|
+
// The flag is the durable answer: once a member saves their form it is set and stays set. The
|
|
5
|
+
// content check below is only how we approximate that for members who saved before the flag
|
|
6
|
+
// existed, which is why the backfill sets it true and never back to false.
|
|
7
|
+
|
|
8
|
+
const MEMBER_UPDATED_FIELD = 'memberUpdated';
|
|
9
|
+
|
|
10
|
+
// The five fields the PAC migration had no counterpart for, so content in any of them can only
|
|
11
|
+
// have been entered by the member. profileImage and businessName hold strings; gallery,
|
|
12
|
+
// bannerImages and testimonial hold arrays.
|
|
13
|
+
const MEMBER_ENTERED_FIELDS = [
|
|
14
|
+
'profileImage',
|
|
15
|
+
'gallery',
|
|
16
|
+
'bannerImages',
|
|
17
|
+
'testimonial',
|
|
18
|
+
'businessName',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const hasContent = value => {
|
|
22
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
23
|
+
if (typeof value === 'string') return value.trim() !== '';
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const hasMemberEnteredContent = (member = {}) =>
|
|
28
|
+
MEMBER_ENTERED_FIELDS.some(field => hasContent(member?.[field]));
|
|
29
|
+
|
|
30
|
+
const isMemberUpdated = member => member?.[MEMBER_UPDATED_FIELD] === true;
|
|
31
|
+
|
|
32
|
+
const memberNeedsUpdatedFlagBackfill = member =>
|
|
33
|
+
hasMemberEnteredContent(member) && !isMemberUpdated(member);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Counts for the dry run, over every member rather than only those needing a write, so a re-run
|
|
37
|
+
* still reports the true tier split.
|
|
38
|
+
* @param {Array} members
|
|
39
|
+
* @returns {{total: number, tierOne: number, tierTwo: number, alreadyFlagged: number, needingBackfill: number}}
|
|
40
|
+
*/
|
|
41
|
+
const summarizeUpdatedOutcomes = (members = []) => {
|
|
42
|
+
const summary = {
|
|
43
|
+
total: members.length,
|
|
44
|
+
tierOne: 0,
|
|
45
|
+
tierTwo: 0,
|
|
46
|
+
alreadyFlagged: 0,
|
|
47
|
+
needingBackfill: 0,
|
|
48
|
+
};
|
|
49
|
+
members.forEach(member => {
|
|
50
|
+
const flagged = isMemberUpdated(member);
|
|
51
|
+
const willBeTierOne = flagged || hasMemberEnteredContent(member);
|
|
52
|
+
willBeTierOne ? (summary.tierOne += 1) : (summary.tierTwo += 1);
|
|
53
|
+
if (flagged) summary.alreadyFlagged += 1;
|
|
54
|
+
if (memberNeedsUpdatedFlagBackfill(member)) summary.needingBackfill += 1;
|
|
55
|
+
});
|
|
56
|
+
return summary;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
MEMBER_UPDATED_FIELD,
|
|
61
|
+
MEMBER_ENTERED_FIELDS,
|
|
62
|
+
hasMemberEnteredContent,
|
|
63
|
+
isMemberUpdated,
|
|
64
|
+
memberNeedsUpdatedFlagBackfill,
|
|
65
|
+
summarizeUpdatedOutcomes,
|
|
66
|
+
};
|
|
@@ -3,7 +3,8 @@ const { createHmac } = require('crypto');
|
|
|
3
3
|
const axios = require('axios');
|
|
4
4
|
const { decode } = require('jwt-js-decode');
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const { isAssociationExpirationCurrent } = require('../association-expiry');
|
|
7
|
+
const { CONFIG_KEYS, SSO_TOKEN_AUTH_API_URL, LOGIN_REFUSAL_REASONS } = require('../consts');
|
|
7
8
|
const { MEMBER_ACTIONS } = require('../daily-pull/consts');
|
|
8
9
|
const { getCurrentMember } = require('../members-area-methods');
|
|
9
10
|
const { getCMSMemberByWixMemberId, prepareMemberForSSOLogin } = require('../members-data-methods');
|
|
@@ -79,6 +80,13 @@ async function validateMemberToken(memberIdInput) {
|
|
|
79
80
|
return invalidTokenResponse;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
if (!isAssociationExpirationCurrent(memberData)) {
|
|
84
|
+
console.log(
|
|
85
|
+
`[validateMemberToken] association membership expired for memberId ${memberData.memberId}`
|
|
86
|
+
);
|
|
87
|
+
return invalidTokenResponse;
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
// Add computed properties
|
|
83
91
|
memberData.addressDisplayOption = getAddressDisplayOptions(memberData);
|
|
84
92
|
console.log('memberData', memberData);
|
|
@@ -134,7 +142,14 @@ const authenticateSSOToken = async ({ token }) => {
|
|
|
134
142
|
if (isValidToken) {
|
|
135
143
|
const jwt = decode(responseToken);
|
|
136
144
|
const payload = jwt.payload;
|
|
137
|
-
|
|
145
|
+
let memberData;
|
|
146
|
+
try {
|
|
147
|
+
memberData = await prepareMemberForSSOLogin(payload);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.message !== LOGIN_REFUSAL_REASONS.ASSOCIATION_MEMBERSHIP_EXPIRED) throw error;
|
|
150
|
+
console.log('[authenticateSSOToken] refusing login, association membership expired');
|
|
151
|
+
return { type: 'error', memberId: '', sessionToken: '' };
|
|
152
|
+
}
|
|
138
153
|
console.log('memberDataCollectionId', memberData._id);
|
|
139
154
|
const sessionToken = await generateMemberSessionToken(memberData.email);
|
|
140
155
|
const authObj = {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
const { COLLECTIONS } = require('../public/consts');
|
|
2
2
|
const { isWixHostedImage, emailsMatch, normalizeEmail } = require('../public/Utils/sharedUtils');
|
|
3
3
|
|
|
4
|
-
const {
|
|
4
|
+
const { isAssociationExpirationCurrent } = require('./association-expiry');
|
|
5
|
+
const { MEMBERSHIPS_TYPES, LOGIN_REFUSAL_REASONS } = require('./consts');
|
|
5
6
|
const { createSiteContact } = require('./contacts-methods');
|
|
6
7
|
const { MEMBER_ACTIONS } = require('./daily-pull/consts');
|
|
7
8
|
const { wixData } = require('./elevated-modules');
|
|
9
|
+
const { MEMBER_UPDATED_FIELD } = require('./listing-priority');
|
|
8
10
|
const { updateMemberContactInfo } = require('./member-contact-orchestration');
|
|
9
11
|
const { createSiteMember, getCurrentMember } = require('./members-area-methods');
|
|
10
12
|
const {
|
|
@@ -423,6 +425,9 @@ async function saveRegistrationData(data, id) {
|
|
|
423
425
|
const mergedData = {
|
|
424
426
|
...existingMemberData,
|
|
425
427
|
...data,
|
|
428
|
+
// Every member-facing save funnels through here, and PAC asked for any save to count.
|
|
429
|
+
// Never set back to false: it records that the member has been in, not what they left behind.
|
|
430
|
+
[MEMBER_UPDATED_FIELD]: true,
|
|
426
431
|
};
|
|
427
432
|
|
|
428
433
|
if (data.addresses && Array.isArray(data.addresses)) {
|
|
@@ -696,6 +701,12 @@ async function prepareMemberForSSOLogin(data) {
|
|
|
696
701
|
if (!memberData) {
|
|
697
702
|
throw new Error(`Member data not found for memberId ${memberId}`);
|
|
698
703
|
}
|
|
704
|
+
if (!isAssociationExpirationCurrent(memberData)) {
|
|
705
|
+
console.log(
|
|
706
|
+
`[prepareMemberForSSOLogin] refusing login, association membership expired for memberId ${memberId}`
|
|
707
|
+
);
|
|
708
|
+
throw new Error(LOGIN_REFUSAL_REASONS.ASSOCIATION_MEMBERSHIP_EXPIRED);
|
|
709
|
+
}
|
|
699
710
|
console.log('memberData', memberData);
|
|
700
711
|
return await ensureWixMemberAndContactExist(memberData);
|
|
701
712
|
} catch (error) {
|
package/backend/routers/utils.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { getMainAddress } = require('../../public/Utils/sharedUtils');
|
|
2
|
+
const { isAssociationExpirationCurrent } = require('../association-expiry');
|
|
2
3
|
const { getMemberBySlug } = require('../members-data-methods');
|
|
3
4
|
const {
|
|
4
5
|
getMoreAddressesToDisplay,
|
|
@@ -113,6 +114,11 @@ const getMemberProfileData = async (slug, siteAssociation) => {
|
|
|
113
114
|
return null;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
if (!isAssociationExpirationCurrent(member)) {
|
|
118
|
+
console.log(`[getMemberProfileData] Association membership expired for slug: ${slug}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
116
122
|
return transformMemberToProfileData(member, siteAssociation);
|
|
117
123
|
} catch (error) {
|
|
118
124
|
const errorMessage = `Error in getMemberProfileData for slug: ${slug} : ${error.message}`;
|
package/backend/tasks/consts.js
CHANGED
|
@@ -29,6 +29,8 @@ const TASKS_NAMES = {
|
|
|
29
29
|
dailyPullExecutionCheck: 'dailyPullExecutionCheck',
|
|
30
30
|
scheduleAssociationExpiryBackfill: 'scheduleAssociationExpiryBackfill',
|
|
31
31
|
associationExpiryBackfillChunk: 'associationExpiryBackfillChunk',
|
|
32
|
+
scheduleMemberUpdatedBackfill: 'scheduleMemberUpdatedBackfill',
|
|
33
|
+
memberUpdatedBackfillChunk: 'memberUpdatedBackfillChunk',
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
module.exports = {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const { taskManager } = require('psdev-task-manager');
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
MEMBER_UPDATED_FIELD,
|
|
5
|
+
memberNeedsUpdatedFlagBackfill,
|
|
6
|
+
summarizeUpdatedOutcomes,
|
|
7
|
+
} = require('../listing-priority');
|
|
8
|
+
const { bulkSaveMembers, getMembersByIds, getAllMembers } = require('../members-data-methods');
|
|
9
|
+
const { chunkArray } = require('../utils');
|
|
10
|
+
|
|
11
|
+
const { TASKS_NAMES } = require('./consts');
|
|
12
|
+
|
|
13
|
+
const CHUNK_SIZE = 1000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One-off backfill of memberUpdated for members who filled their listing out before the flag
|
|
17
|
+
* existed. Sets the flag true and never false: it records that the member has been in, so a
|
|
18
|
+
* listing that was filled and later emptied keeps it.
|
|
19
|
+
* @param {Object} [data]
|
|
20
|
+
* @param {boolean} [data.dryRun] count without writing
|
|
21
|
+
*/
|
|
22
|
+
async function scheduleMemberUpdatedBackfill(data = {}) {
|
|
23
|
+
// process() receives whatever getIdentifier returns. A sentinel string there would read as
|
|
24
|
+
// dryRun: false and write to every member instead of counting them.
|
|
25
|
+
if (data === null || typeof data !== 'object') {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`scheduleMemberUpdatedBackfill expected its task data object but received ${typeof data}. ` +
|
|
28
|
+
'Check getIdentifier for this task in tasks-configs.js: it must be `task => task.data`.'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dryRun = data.dryRun === true;
|
|
33
|
+
console.log(`=== Scheduling Member Updated Backfill${dryRun ? ' (DRY RUN)' : ''} ===`);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const members = await getAllMembers();
|
|
37
|
+
console.log(`Fetched ${members.length} members`);
|
|
38
|
+
|
|
39
|
+
// Over every member, not just those needing a write, so a re-run still reports the true split.
|
|
40
|
+
const outcomes = summarizeUpdatedOutcomes(members);
|
|
41
|
+
const tierOneShare = members.length
|
|
42
|
+
? ((outcomes.tierOne / members.length) * 100).toFixed(1)
|
|
43
|
+
: '0.0';
|
|
44
|
+
|
|
45
|
+
console.log(`Outcome breakdown: ${JSON.stringify(outcomes)}`);
|
|
46
|
+
console.log(`Will rank first: ${outcomes.tierOne} of ${members.length} (${tierOneShare}%)`);
|
|
47
|
+
|
|
48
|
+
const memberIds = [
|
|
49
|
+
...new Set(
|
|
50
|
+
members
|
|
51
|
+
.filter(memberNeedsUpdatedFlagBackfill)
|
|
52
|
+
.map(member => Number(member.memberId))
|
|
53
|
+
.filter(memberId => Number.isFinite(memberId) && memberId > 0)
|
|
54
|
+
),
|
|
55
|
+
];
|
|
56
|
+
console.log(`Members whose flag is not yet set: ${memberIds.length}`);
|
|
57
|
+
|
|
58
|
+
const summary = {
|
|
59
|
+
success: true,
|
|
60
|
+
dryRun,
|
|
61
|
+
totalMembers: members.length,
|
|
62
|
+
outcomes,
|
|
63
|
+
tierOneShare: `${tierOneShare}%`,
|
|
64
|
+
membersNeedingUpdate: memberIds.length,
|
|
65
|
+
tasksScheduled: 0,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (dryRun) {
|
|
69
|
+
summary.message = `Dry run: nothing written. ${outcomes.tierOne} of ${members.length} members (${tierOneShare}%) would rank first`;
|
|
70
|
+
console.log('=== Dry Run Complete, nothing written ===');
|
|
71
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
72
|
+
return summary;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (memberIds.length === 0) {
|
|
76
|
+
summary.message = 'Every member who has entered content is already flagged';
|
|
77
|
+
console.log(summary.message);
|
|
78
|
+
return summary;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const chunks = chunkArray(memberIds, CHUNK_SIZE);
|
|
82
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
83
|
+
await taskManager().schedule({
|
|
84
|
+
name: TASKS_NAMES.memberUpdatedBackfillChunk,
|
|
85
|
+
data: { memberIds: chunks[i], chunkIndex: i, totalChunks: chunks.length },
|
|
86
|
+
type: 'scheduled',
|
|
87
|
+
});
|
|
88
|
+
console.log(`Scheduled task ${i + 1}/${chunks.length} (${chunks[i].length} members)`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
summary.tasksScheduled = chunks.length;
|
|
92
|
+
summary.message = `Scheduled ${chunks.length} tasks for ${memberIds.length} members`;
|
|
93
|
+
|
|
94
|
+
console.log('=== Scheduling Complete ===');
|
|
95
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
96
|
+
|
|
97
|
+
return summary;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error('Error scheduling member updated backfill:', error);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Members are reloaded and re-checked rather than trusting the queued data: a chunk can run long
|
|
106
|
+
* after it was scheduled, and the member may have saved their form in between.
|
|
107
|
+
*/
|
|
108
|
+
async function memberUpdatedBackfillChunk(data) {
|
|
109
|
+
const { memberIds, chunkIndex, totalChunks } = data;
|
|
110
|
+
console.log(
|
|
111
|
+
`Processing member updated chunk ${chunkIndex + 1}/${totalChunks} (${memberIds.length} members)`
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const result = {
|
|
115
|
+
successful: 0,
|
|
116
|
+
failed: 0,
|
|
117
|
+
skipped: 0,
|
|
118
|
+
errors: [],
|
|
119
|
+
failedIds: [],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const members = await getMembersByIds(memberIds);
|
|
124
|
+
console.log(`Loaded ${members.length} members for this chunk`);
|
|
125
|
+
|
|
126
|
+
const membersToUpdate = members
|
|
127
|
+
.filter(memberNeedsUpdatedFlagBackfill)
|
|
128
|
+
.map(member => ({ ...member, [MEMBER_UPDATED_FIELD]: true }));
|
|
129
|
+
|
|
130
|
+
result.skipped = members.length - membersToUpdate.length;
|
|
131
|
+
|
|
132
|
+
if (membersToUpdate.length === 0) {
|
|
133
|
+
console.log('No members need updating in this batch');
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await bulkSaveMembers(membersToUpdate);
|
|
139
|
+
result.successful += membersToUpdate.length;
|
|
140
|
+
console.log(`✅ Successfully backfilled ${membersToUpdate.length} members`);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error('❌ Error bulk saving members:', error);
|
|
143
|
+
result.failed += membersToUpdate.length;
|
|
144
|
+
result.failedIds.push(...membersToUpdate.map(member => member.memberId));
|
|
145
|
+
result.errors.push({ error: error.message, memberCount: membersToUpdate.length });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error(`Error processing member updated chunk ${chunkIndex}:`, error);
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
scheduleMemberUpdatedBackfill,
|
|
157
|
+
memberUpdatedBackfillChunk,
|
|
158
|
+
};
|
|
@@ -22,6 +22,10 @@ const {
|
|
|
22
22
|
scheduleNormalizeMemberEmails,
|
|
23
23
|
normalizeMemberEmailsChunk,
|
|
24
24
|
} = require('./email-normalize-methods');
|
|
25
|
+
const {
|
|
26
|
+
scheduleMemberUpdatedBackfill,
|
|
27
|
+
memberUpdatedBackfillChunk,
|
|
28
|
+
} = require('./member-updated-backfill-methods');
|
|
25
29
|
const {
|
|
26
30
|
scheduleTaskForEmptyAboutYouMembers,
|
|
27
31
|
convertAboutYouHtmlToRichContent,
|
|
@@ -251,6 +255,23 @@ const TASKS = {
|
|
|
251
255
|
shouldSkipCheck: () => false,
|
|
252
256
|
estimatedDurationSec: 120,
|
|
253
257
|
},
|
|
258
|
+
[TASKS_NAMES.scheduleMemberUpdatedBackfill]: {
|
|
259
|
+
name: TASKS_NAMES.scheduleMemberUpdatedBackfill,
|
|
260
|
+
// Must pass task.data through - process() receives this, and the backfill needs its dryRun.
|
|
261
|
+
getIdentifier: task => task.data,
|
|
262
|
+
process: scheduleMemberUpdatedBackfill,
|
|
263
|
+
shouldSkipCheck: () => false,
|
|
264
|
+
estimatedDurationSec: 120,
|
|
265
|
+
},
|
|
266
|
+
[TASKS_NAMES.memberUpdatedBackfillChunk]: {
|
|
267
|
+
name: TASKS_NAMES.memberUpdatedBackfillChunk,
|
|
268
|
+
getIdentifier: task => task.data,
|
|
269
|
+
process: memberUpdatedBackfillChunk,
|
|
270
|
+
shouldSkipCheck: () => false,
|
|
271
|
+
// A packing budget, not a timeout. Same shape of write as the expiry chunks, which measured
|
|
272
|
+
// at ~6.5s, so 10 leaves the manager room in each 240s tick.
|
|
273
|
+
estimatedDurationSec: 10,
|
|
274
|
+
},
|
|
254
275
|
[TASKS_NAMES.associationExpiryBackfillChunk]: {
|
|
255
276
|
name: TASKS_NAMES.associationExpiryBackfillChunk,
|
|
256
277
|
getIdentifier: task => task.data,
|
package/package.json
CHANGED
package/public/consts.js
CHANGED
|
@@ -76,6 +76,8 @@ const MEMBERS_FIELDS = {
|
|
|
76
76
|
memberships: 'memberships',
|
|
77
77
|
showWebsite: 'showWebsite',
|
|
78
78
|
addressDisplayOption: 'addressDisplayOption',
|
|
79
|
+
// Search projects only these fields, so the tier is invisible to the ordering code without it.
|
|
80
|
+
memberUpdated: 'memberUpdated',
|
|
79
81
|
};
|
|
80
82
|
|
|
81
83
|
const LIGHTBOX_NAMES = {
|