abmp-npm 2.0.83 → 2.0.85

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.
@@ -9,12 +9,40 @@ const {
9
9
  ASSOCIATION_EXPIRATION_FIELD,
10
10
  } = require('./association-expiry');
11
11
  const {
12
+ CONFIG_KEYS,
12
13
  GEO_HASH_PRECISION,
13
14
  MAX__MEMBERS_SEARCH_RESULTS,
14
15
  WIX_QUERY_MAX_LIMIT,
15
16
  MEMBERSHIPS_TYPES,
16
17
  } = require('./consts.js');
17
18
  const { wixData } = require('./elevated-modules');
19
+ const { MEMBER_UPDATED_FIELD } = require('./listing-priority');
20
+ const { getSiteConfigs } = require('./utils');
21
+
22
+ // PAC asked for updated listings within 25 miles to rank first, and for the distance to be
23
+ // adjustable as more members fill their listings in - hence the site config rather than a constant.
24
+ const DEFAULT_PRIORITY_RADIUS_MILES = 25;
25
+
26
+ const LISTING_TIERS = {
27
+ UPDATED: 'updated',
28
+ REST: 'rest',
29
+ };
30
+
31
+ /**
32
+ * Falls back to the default rather than throwing: a missing or malformed config value should
33
+ * change the ordering, never break the directory.
34
+ */
35
+ const getPriorityRadiusMiles = async () => {
36
+ try {
37
+ const configured = Number(await getSiteConfigs(CONFIG_KEYS.LISTING_PRIORITY_RADIUS_MILES));
38
+ return Number.isFinite(configured) && configured > 0
39
+ ? configured
40
+ : DEFAULT_PRIORITY_RADIUS_MILES;
41
+ } catch (error) {
42
+ console.error('Could not read the listing priority radius, using the default', error);
43
+ return DEFAULT_PRIORITY_RADIUS_MILES;
44
+ }
45
+ };
18
46
 
19
47
  function buildMembersSearchQuery(data) {
20
48
  console.log('data: ', JSON.stringify(data));
@@ -30,151 +58,183 @@ function buildMembersSearchQuery(data) {
30
58
  filter.latitude = filter.latitude || 0;
31
59
  filter.longitude = filter.longitude || 0;
32
60
  filter.postalcode = filter.postalcode || '';
33
- return {
34
- get: () => {
35
- let query = wixData
36
- .query(COLLECTIONS.MEMBERS_DATA)
37
- .ne('optOut', true)
38
- .ne('action', 'drop')
39
- .ne('memberships.membertype', MEMBERSHIPS_TYPES.PAC_STAFF)
40
- .eq('isVisible', true);
61
+ // Built fresh per call rather than derived from a shared base: the two tier queries must not
62
+ // share any state.
63
+ const buildQuery = tier => {
64
+ let query = wixData
65
+ .query(COLLECTIONS.MEMBERS_DATA)
66
+ .ne('optOut', true)
67
+ .ne('action', 'drop')
68
+ .ne('memberships.membertype', MEMBERSHIPS_TYPES.PAC_STAFF)
69
+ .eq('isVisible', true);
41
70
 
42
- query = query.ge(ASSOCIATION_EXPIRATION_FIELD, getTodayInAssociationTimeZone());
43
- let filterConfig = [
44
- {
45
- filterKey: 'practiceAreas',
46
- queryMethod: 'hasSome',
47
- queryField: 'areasOfPractices',
48
- condition: value => value && value.length > 0,
49
- fallback: {
50
- filterKey: 'practiceAreasSearch',
51
- queryMethod: 'contains',
52
- queryField: 'areasOfPractices',
53
- condition: value => value && value.trim() !== '',
54
- },
55
- },
56
- {
57
- filterKey: 'postalcode',
71
+ query = query.ge(ASSOCIATION_EXPIRATION_FIELD, getTodayInAssociationTimeZone());
72
+ let filterConfig = [
73
+ {
74
+ filterKey: 'practiceAreas',
75
+ queryMethod: 'hasSome',
76
+ queryField: 'areasOfPractices',
77
+ condition: value => value && value.length > 0,
78
+ fallback: {
79
+ filterKey: 'practiceAreasSearch',
58
80
  queryMethod: 'contains',
59
- queryField: 'addresses.postalcode',
81
+ queryField: 'areasOfPractices',
60
82
  condition: value => value && value.trim() !== '',
61
83
  },
62
- {
63
- filterKey: 'state',
64
- queryMethod: 'hasSome',
84
+ },
85
+ {
86
+ filterKey: 'postalcode',
87
+ queryMethod: 'contains',
88
+ queryField: 'addresses.postalcode',
89
+ condition: value => value && value.trim() !== '',
90
+ },
91
+ {
92
+ filterKey: 'state',
93
+ queryMethod: 'hasSome',
94
+ queryField: 'addresses.state',
95
+ condition: value => value && value.length > 0,
96
+ fallback: {
97
+ filterKey: 'stateSearch',
98
+ queryMethod: 'contains',
65
99
  queryField: 'addresses.state',
66
- condition: value => value && value.length > 0,
67
- fallback: {
68
- filterKey: 'stateSearch',
69
- queryMethod: 'contains',
70
- queryField: 'addresses.state',
71
- condition: value => value && value.trim() !== '',
72
- },
100
+ condition: value => value && value.trim() !== '',
73
101
  },
74
- {
75
- filterKey: 'city',
76
- queryMethod: 'hasSome',
102
+ },
103
+ {
104
+ filterKey: 'city',
105
+ queryMethod: 'hasSome',
106
+ queryField: 'addresses.city',
107
+ condition: value => value && value.length > 0,
108
+ fallback: {
109
+ filterKey: 'citySearch',
110
+ queryMethod: 'contains',
77
111
  queryField: 'addresses.city',
78
- condition: value => value && value.length > 0,
79
- fallback: {
80
- filterKey: 'citySearch',
81
- queryMethod: 'contains',
82
- queryField: 'addresses.city',
83
- condition: value => value && value.trim() !== '',
84
- },
112
+ condition: value => value && value.trim() !== '',
85
113
  },
86
- ];
87
- //Ignore state, city and postal code when isSearchingNearby is true
88
- if (isSearchingNearby) {
89
- filterConfig = filterConfig.filter(
90
- config => !['state', 'city', 'postalcode'].includes(config.filterKey)
91
- );
92
- }
93
- const applyFilterToQuery = (query, config, filter) => {
94
- const filterValue = filter[config.filterKey];
95
- if (config.condition(filterValue)) {
96
- return query[config.queryMethod](config.queryField, filterValue);
97
- } else if (config.fallback) {
98
- return applyFilterToQuery(query, config.fallback, filter);
99
- }
100
- return query;
101
- };
102
- // Apply filters using the configuration
103
- filterConfig.forEach(config => {
104
- query = applyFilterToQuery(query, config, filter);
105
- });
106
- if (isUserLocationEnabled && isSearchingNearby) {
107
- const userGeohash = geohash.encode(filter.latitude, filter.longitude, GEO_HASH_PRECISION);
108
- const neighborGeohashes = geohash.neighbors(userGeohash);
109
- const geohashList = [userGeohash, ...neighborGeohashes];
110
- query = query.hasSome('locHash', geohashList);
111
- }
112
- if (filter.searchText.trim() !== '') {
113
- query = query.contains('fullName', filter.searchText);
114
- }
115
- if (!includeStudents) {
116
- query = query.ne('memberships.membertype', MEMBERSHIPS_TYPES.STUDENT);
114
+ },
115
+ ];
116
+ //Ignore state, city and postal code when isSearchingNearby is true
117
+ if (isSearchingNearby) {
118
+ filterConfig = filterConfig.filter(
119
+ config => !['state', 'city', 'postalcode'].includes(config.filterKey)
120
+ );
121
+ }
122
+ const applyFilterToQuery = (query, config, filter) => {
123
+ const filterValue = filter[config.filterKey];
124
+ if (config.condition(filterValue)) {
125
+ return query[config.queryMethod](config.queryField, filterValue);
126
+ } else if (config.fallback) {
127
+ return applyFilterToQuery(query, config.fallback, filter);
117
128
  }
118
129
  return query;
119
- },
120
- run: async query => {
121
- const baseQuery = query.ascending('firstName').fields(...Object.values(MEMBERS_FIELDS));
122
- const getRandomSkip = totalCount => {
123
- let randomSkip = 0;
124
- if (totalCount > MAX__MEMBERS_SEARCH_RESULTS) {
125
- const maxSkip = totalCount - MAX__MEMBERS_SEARCH_RESULTS;
126
- randomSkip = Math.floor(Math.random() * (maxSkip + 1));
127
- }
128
- return randomSkip;
129
- };
130
- const getResult = async query => {
131
- if (isSearchingNearby) {
132
- return fetchAllItemsInParallel(baseQuery);
133
- }
134
- const totalCount = await query.count();
135
- const randomSkip = getRandomSkip(totalCount);
136
- const result = await query
137
- .skip(randomSkip)
138
- .limit(MAX__MEMBERS_SEARCH_RESULTS)
139
- .find({ omitTotalCount: true });
130
+ };
131
+ // Apply filters using the configuration
132
+ filterConfig.forEach(config => {
133
+ query = applyFilterToQuery(query, config, filter);
134
+ });
135
+ if (isUserLocationEnabled && isSearchingNearby) {
136
+ const userGeohash = geohash.encode(filter.latitude, filter.longitude, GEO_HASH_PRECISION);
137
+ const neighborGeohashes = geohash.neighbors(userGeohash);
138
+ const geohashList = [userGeohash, ...neighborGeohashes];
139
+ query = query.hasSome('locHash', geohashList);
140
+ }
141
+ if (filter.searchText.trim() !== '') {
142
+ query = query.contains('fullName', filter.searchText);
143
+ }
144
+ if (!includeStudents) {
145
+ query = query.ne('memberships.membertype', MEMBERSHIPS_TYPES.STUDENT);
146
+ }
147
+ if (tier === LISTING_TIERS.UPDATED) {
148
+ query = query.eq(MEMBER_UPDATED_FIELD, true);
149
+ } else if (tier === LISTING_TIERS.REST) {
150
+ // Matches members whose flag was never written at all, the same way the optOut and action
151
+ // gates above already rely on.
152
+ query = query.ne(MEMBER_UPDATED_FIELD, true);
153
+ }
154
+ return query.ascending('firstName').fields(...Object.values(MEMBERS_FIELDS));
155
+ };
140
156
 
141
- // Shuffle the result items for additional randomization
142
- return {
143
- ...result,
144
- items: shuffleArray(result.items),
145
- };
146
- };
157
+ /**
158
+ * Takes a random window of `limit` rows from a tier. The collection is never loaded in full -
159
+ * the count tells us how far we may skip, exactly as the untiered search did.
160
+ */
161
+ const takeRandomWindow = async (query, totalCount, limit) => {
162
+ if (limit <= 0 || totalCount === 0) return [];
163
+ const maxSkip = Math.max(0, totalCount - limit);
164
+ const skip = maxSkip > 0 ? Math.floor(Math.random() * (maxSkip + 1)) : 0;
165
+ const result = await query.skip(skip).limit(limit).find({ omitTotalCount: true });
166
+ return result.items;
167
+ };
168
+
169
+ /**
170
+ * Typed search: updated listings first, then the rest, each shuffled within its own tier so the
171
+ * tier boundary survives the randomisation. Two counts and two windows, run together, so this
172
+ * costs the same round trips as the single untiered query it replaces.
173
+ */
174
+ const fetchTieredWindow = async () => {
175
+ const updatedQuery = buildQuery(LISTING_TIERS.UPDATED);
176
+ const restQuery = buildQuery(LISTING_TIERS.REST);
177
+
178
+ const [updatedCount, restCount] = await Promise.all([updatedQuery.count(), restQuery.count()]);
179
+
180
+ // Derived from the count rather than from the first window's length, so both windows can be
181
+ // fetched in parallel.
182
+ const updatedLimit = Math.min(updatedCount, MAX__MEMBERS_SEARCH_RESULTS);
183
+ const [updatedItems, restItems] = await Promise.all([
184
+ takeRandomWindow(updatedQuery, updatedCount, updatedLimit),
185
+ takeRandomWindow(restQuery, restCount, MAX__MEMBERS_SEARCH_RESULTS - updatedLimit),
186
+ ]);
147
187
 
148
- const result = await getResult(baseQuery);
149
- if (isUserLocationEnabled) {
150
- const withDistances = result.items.map(item => ({
151
- ...item,
152
- distance: calculateDistance(
153
- {
154
- latitude: filter.latitude,
155
- longitude: filter.longitude,
156
- },
157
- findMainAddress(item.addressDisplayOption, item.addresses, {
158
- requireValidCoordinates: true,
159
- })
160
- ),
161
- }));
162
- const resultWithDistances = {
163
- ...result,
164
- items: withDistances,
165
- };
166
- if (isSearchingNearby) {
167
- return {
168
- ...resultWithDistances,
169
- items: withDistances
170
- .filter(item => item.distance !== null)
171
- .sort((a, b) => a.distance - b.distance)
172
- .slice(0, MAX__MEMBERS_SEARCH_RESULTS),
173
- };
174
- }
175
- return resultWithDistances;
188
+ return {
189
+ items: [...shuffleArray(updatedItems), ...shuffleArray(restItems)],
190
+ totalCount: updatedCount + restCount,
191
+ };
192
+ };
193
+
194
+ return {
195
+ get: () => buildQuery(),
196
+ run: async () => {
197
+ const result = isSearchingNearby
198
+ ? await fetchAllItemsInParallel(buildQuery())
199
+ : await fetchTieredWindow();
200
+
201
+ if (!isUserLocationEnabled) {
202
+ return result;
203
+ }
204
+
205
+ const withDistances = result.items.map(item => ({
206
+ ...item,
207
+ distance: calculateDistance(
208
+ {
209
+ latitude: filter.latitude,
210
+ longitude: filter.longitude,
211
+ },
212
+ findMainAddress(item.addressDisplayOption, item.addresses, {
213
+ requireValidCoordinates: true,
214
+ })
215
+ ),
216
+ }));
217
+
218
+ if (!isSearchingNearby) {
219
+ return { ...result, items: withDistances };
176
220
  }
177
- return result;
221
+
222
+ // "Near me": updated listings inside the radius first, shuffled among themselves, then
223
+ // everything else nearest-first. Every row and its distance is already in memory here.
224
+ const radiusMiles = await getPriorityRadiusMiles();
225
+ const located = withDistances.filter(item => item.distance !== null);
226
+ const isPrioritised = item =>
227
+ item[MEMBER_UPDATED_FIELD] === true && item.distance <= radiusMiles;
228
+
229
+ const prioritised = shuffleArray(located.filter(isPrioritised));
230
+ const nearest = located
231
+ .filter(item => !isPrioritised(item))
232
+ .sort((a, b) => a.distance - b.distance);
233
+
234
+ return {
235
+ ...result,
236
+ items: [...prioritised, ...nearest].slice(0, MAX__MEMBERS_SEARCH_RESULTS),
237
+ };
178
238
  },
179
239
  };
180
240
  }
@@ -184,9 +244,15 @@ async function fetchAllItemsInParallel(query) {
184
244
  const batchSize = WIX_QUERY_MAX_LIMIT;
185
245
  const allItems = [];
186
246
 
187
- const firstResult = await query.skip(0).limit(batchSize).find();
247
+ // @wix/data leaves totalPages undefined unless the count is requested explicitly, so reading it
248
+ // off the first page silently stopped this at 1,000 rows. Count first; count() is already what
249
+ // the typed search relies on.
250
+ const [totalCount, firstResult] = await Promise.all([
251
+ query.count(),
252
+ query.skip(0).limit(batchSize).find(),
253
+ ]);
188
254
 
189
- const totalBatches = firstResult.totalPages;
255
+ const totalBatches = Math.ceil(totalCount / batchSize);
190
256
  allItems.push(...firstResult.items);
191
257
 
192
258
  if (totalBatches > 1) {
package/backend/consts.js CHANGED
@@ -17,6 +17,7 @@ const CONFIG_KEYS = {
17
17
  MEMBERS_EXTERNAL_PORTAL_URL: 'MEMBERS_EXTERNAL_PORTAL_URL',
18
18
  DEFAULT_PROFILE_IMAGE: 'DEFAULT_PROFILE_IMAGE',
19
19
  QA_ALLOW_ANY_MEMBER: 'QA_ALLOW_ANY_MEMBER',
20
+ LISTING_PRIORITY_RADIUS_MILES: 'LISTING_PRIORITY_RADIUS_MILES',
20
21
  };
21
22
 
22
23
  const MAX__MEMBERS_SEARCH_RESULTS = 120;
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
+ };
@@ -6,6 +6,7 @@ const { MEMBERSHIPS_TYPES, LOGIN_REFUSAL_REASONS } = require('./consts');
6
6
  const { createSiteContact } = require('./contacts-methods');
7
7
  const { MEMBER_ACTIONS } = require('./daily-pull/consts');
8
8
  const { wixData } = require('./elevated-modules');
9
+ const { MEMBER_UPDATED_FIELD } = require('./listing-priority');
9
10
  const { updateMemberContactInfo } = require('./member-contact-orchestration');
10
11
  const { createSiteMember, getCurrentMember } = require('./members-area-methods');
11
12
  const {
@@ -424,6 +425,9 @@ async function saveRegistrationData(data, id) {
424
425
  const mergedData = {
425
426
  ...existingMemberData,
426
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,
427
431
  };
428
432
 
429
433
  if (data.addresses && Array.isArray(data.addresses)) {
@@ -15,10 +15,9 @@ const getNonCompiledFiltersOptions = async () => {
15
15
  ]);
16
16
  return { completeStateList, areasOfPracticesList, stateCityMapList };
17
17
  };
18
- const filterProfiles = async data => {
18
+ const filterProfiles = data => {
19
19
  const membersSearchQuery = buildMembersSearchQuery({ ...data, includeStudents: false });
20
- const query = await membersSearchQuery.get();
21
- return membersSearchQuery.run(query);
20
+ return membersSearchQuery.run();
22
21
  };
23
22
 
24
23
  async function getAreasOfPracticeList() {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abmp-npm",
3
- "version": "2.0.83",
3
+ "version": "2.0.85",
4
4
  "main": "index.js",
5
5
  "files": [
6
6
  "index.js",
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 = {