abmp-npm 10.3.18 → 10.3.20

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.
@@ -119,8 +119,6 @@ const getTodayInAssociationTimeZone = (now = new Date()) => {
119
119
  }
120
120
  };
121
121
 
122
- // Mirrors the directory query's `.ge()` for a record already in hand, so search, the profile page
123
- // and the members area cannot disagree. Missing or unreadable is not current, as in the query.
124
122
  const isAssociationExpirationCurrent = (member, now) => {
125
123
  const stored = member?.[ASSOCIATION_EXPIRATION_FIELD];
126
124
  const expiration = stored instanceof Date ? stored : stored ? new Date(stored) : null;
@@ -39,9 +39,6 @@ function buildMembersSearchQuery(data) {
39
39
  .ne('memberships.membertype', MEMBERSHIPS_TYPES.PAC_STAFF)
40
40
  .eq('isVisible', true);
41
41
 
42
- // Filter in the query, not after it: count() and skip() in run() then page over the
43
- // filtered set. Do not publish this to a site whose backfill has not run - members with no
44
- // date are excluded, and before the backfill that is all of them.
45
42
  query = query.ge(ASSOCIATION_EXPIRATION_FIELD, getTodayInAssociationTimeZone());
46
43
  let filterConfig = [
47
44
  {
package/backend/consts.js CHANGED
@@ -43,8 +43,6 @@ const LOGIN_EMAIL_SYNC_STATUS = {
43
43
  SKIPPED: 'skipped', // member has no wixMemberId, nothing to change
44
44
  };
45
45
 
46
- // Thrown by the data layer, recognised by the login layer, which turns it into the ordinary error
47
- // response rather than a 500.
48
46
  const LOGIN_REFUSAL_REASONS = {
49
47
  ASSOCIATION_MEMBERSHIP_EXPIRED: 'ASSOCIATION_MEMBERSHIP_EXPIRED',
50
48
  };
@@ -199,8 +199,6 @@ async function createCoreMemberData(inputMemberData, existingDbMember, currentPa
199
199
  memberships: inputMemberData.memberships,
200
200
  pageNumber: currentPageNumber,
201
201
  isVisible: inputMemberData.action !== MEMBER_ACTIONS.DROP,
202
- // Belongs here, not in getNewMemberOnlyFields: that returns {} for existing members, so the
203
- // date would never refresh on renewal and a member who paid would stay hidden. null = hidden.
204
202
  [ASSOCIATION_EXPIRATION_FIELD]: inputMemberData[ASSOCIATION_EXPIRATION_FIELD] ?? null,
205
203
 
206
204
  // Handle Member emails
@@ -130,8 +130,6 @@ async function synchronizeSinglePage(taskObject) {
130
130
  }
131
131
  return isUpdatedMember(member);
132
132
  });
133
- // Narrow each member to this site's association: licenses filtered to it, and its expiration
134
- // lifted out of the memberships array into a scalar the directory query can filter on.
135
133
  const toSyncMembersWithFilteredLicenses = toSyncMembers.map(member => ({
136
134
  ...filterLicensesByAssociation(member, siteAssociation),
137
135
  [ASSOCIATION_EXPIRATION_FIELD]: resolveAssociationExpiration(member, siteAssociation),
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
+ };
@@ -80,8 +80,6 @@ async function validateMemberToken(memberIdInput) {
80
80
  return invalidTokenResponse;
81
81
  }
82
82
 
83
- // Ends an already-open session once the association lapses. Without this, a member logged in
84
- // before their expiry date keeps editing a listing the directory no longer shows.
85
83
  if (!isAssociationExpirationCurrent(memberData)) {
86
84
  console.log(
87
85
  `[validateMemberToken] association membership expired for memberId ${memberData.memberId}`
@@ -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)) {
@@ -697,8 +701,6 @@ async function prepareMemberForSSOLogin(data) {
697
701
  if (!memberData) {
698
702
  throw new Error(`Member data not found for memberId ${memberId}`);
699
703
  }
700
- // Before ensureWixMemberAndContactExist, which creates the Wix member and contact when they are
701
- // missing - refusing later would still provision an account for someone who cannot use it.
702
704
  if (!isAssociationExpirationCurrent(memberData)) {
703
705
  console.log(
704
706
  `[prepareMemberForSSOLogin] refusing login, association membership expired for memberId ${memberId}`
@@ -114,8 +114,6 @@ const getMemberProfileData = async (slug, siteAssociation) => {
114
114
  return null;
115
115
  }
116
116
 
117
- // Gated here rather than in getMemberBySlug: that also backs URL uniqueness during the sync,
118
- // where an expired member's slug must still count as taken or a new member could claim it.
119
117
  if (!isAssociationExpirationCurrent(member)) {
120
118
  console.log(`[getMemberProfileData] Association membership expired for slug: ${slug}`);
121
119
  return null;
@@ -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": "10.3.18",
3
+ "version": "10.3.20",
4
4
  "main": "index.js",
5
5
  "files": [
6
6
  "index.js",
@@ -166,14 +166,10 @@ async function personalDetailsOnReady({
166
166
 
167
167
  try {
168
168
  const {
169
- memberData: validatedMember,
169
+ memberData: { isStudent: _isStudent, ...memberDataResponse },
170
170
  isValid: isValidResponse,
171
171
  membersExternalPortalUrl: _membersExternalPortalUrl,
172
172
  } = await validateMemberToken(memberTokenId);
173
- // validateMemberToken returns memberData: null for every rejection, expired and dropped
174
- // included. Destructuring that directly threw, so the catch below showed the "something is
175
- // broken" screen instead of the unauthorized one built for exactly this.
176
- const { isStudent: _isStudent, ...memberDataResponse } = validatedMember ?? {};
177
173
  memberData = memberDataResponse;
178
174
  isValid = isValidResponse;
179
175
  isStudent = _isStudent;
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 = {