abmp-npm 10.3.8 → 10.3.9

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.
@@ -0,0 +1,49 @@
1
+ const { LOGIN_EMAIL_SYNC_STATUS } = require('../consts');
2
+ const { summarizeLoginEmailOutcomes } = require('../daily-pull/utils');
3
+
4
+ const { UPDATED, FAILED, SKIPPED } = LOGIN_EMAIL_SYNC_STATUS;
5
+
6
+ describe('summarizeLoginEmailOutcomes', () => {
7
+ test('collects only FAILED outcomes as failures and failed ids', () => {
8
+ const outcomes = [
9
+ { memberId: 1, wixMemberId: 'w1', desiredEmail: 'a@x.com', status: UPDATED },
10
+ {
11
+ memberId: 2,
12
+ wixMemberId: 'w2',
13
+ desiredEmail: 'b@x.com',
14
+ status: FAILED,
15
+ error: 'duplicate',
16
+ },
17
+ { memberId: 3, status: SKIPPED, desiredEmail: 'c@x.com' },
18
+ ];
19
+
20
+ const { failedMemberIds, failures } = summarizeLoginEmailOutcomes(outcomes);
21
+
22
+ expect([...failedMemberIds]).toEqual([2]);
23
+ expect(failures).toEqual([
24
+ { memberId: 2, wixMemberId: 'w2', desiredEmail: 'b@x.com', error: 'duplicate' },
25
+ ]);
26
+ });
27
+
28
+ test('UPDATED and SKIPPED never produce failures (CMS email is left to advance/no-op)', () => {
29
+ const outcomes = [
30
+ { memberId: 1, status: UPDATED, desiredEmail: 'a@x.com' },
31
+ { memberId: 2, status: SKIPPED, desiredEmail: 'b@x.com' },
32
+ ];
33
+ const { failedMemberIds, failures } = summarizeLoginEmailOutcomes(outcomes);
34
+ expect(failedMemberIds.size).toBe(0);
35
+ expect(failures).toEqual([]);
36
+ });
37
+
38
+ test('defaults a missing error message', () => {
39
+ const { failures } = summarizeLoginEmailOutcomes([
40
+ { memberId: 9, wixMemberId: 'w9', desiredEmail: 'z@x.com', status: FAILED },
41
+ ]);
42
+ expect(failures[0].error).toBe('unknown error');
43
+ });
44
+
45
+ test('handles empty / missing input', () => {
46
+ expect(summarizeLoginEmailOutcomes([]).failures).toEqual([]);
47
+ expect(summarizeLoginEmailOutcomes().failures).toEqual([]);
48
+ });
49
+ });
@@ -0,0 +1,129 @@
1
+ jest.mock('../elevated-modules', () => ({
2
+ wixData: { query: jest.fn() },
3
+ }));
4
+
5
+ const { wixData } = require('../elevated-modules');
6
+ const { findMembersByIds } = require('../members-data-methods');
7
+ const { withTransientErrorRetry, isTransientNetworkError } = require('../utils');
8
+
9
+ const makeQueryResult = items => ({ items, hasNext: () => false });
10
+
11
+ const mockQueryReturning = find => {
12
+ const query = {
13
+ hasSome: jest.fn().mockReturnThis(),
14
+ limit: jest.fn().mockReturnThis(),
15
+ find,
16
+ };
17
+ wixData.query.mockReturnValue(query);
18
+ return query;
19
+ };
20
+
21
+ describe('isTransientNetworkError', () => {
22
+ test('matches undici "fetch failed" and common network error codes', () => {
23
+ expect(isTransientNetworkError(new Error('fetch failed'))).toBe(true);
24
+ expect(isTransientNetworkError({ message: 'request failed', code: 'ECONNRESET' })).toBe(true);
25
+ expect(
26
+ isTransientNetworkError({ message: 'fetch failed', cause: { code: 'UND_ERR_SOCKET' } })
27
+ ).toBe(true);
28
+ });
29
+
30
+ test('does not match application errors', () => {
31
+ expect(isTransientNetworkError(new Error('Multiple members found with memberId 1'))).toBe(
32
+ false
33
+ );
34
+ expect(isTransientNetworkError(new Error('WDE0025: validation failed'))).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe('withTransientErrorRetry', () => {
39
+ test('retries transient failures and resolves with the eventual result', async () => {
40
+ const operation = jest
41
+ .fn()
42
+ .mockRejectedValueOnce(new Error('fetch failed'))
43
+ .mockResolvedValueOnce('ok');
44
+
45
+ await expect(withTransientErrorRetry(operation, { baseDelayMs: 1 })).resolves.toBe('ok');
46
+ expect(operation).toHaveBeenCalledTimes(2);
47
+ });
48
+
49
+ test('rethrows immediately on non-transient errors', async () => {
50
+ const operation = jest.fn().mockRejectedValue(new Error('validation failed'));
51
+
52
+ await expect(withTransientErrorRetry(operation, { baseDelayMs: 1 })).rejects.toThrow(
53
+ 'validation failed'
54
+ );
55
+ expect(operation).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ test('gives up after the configured number of retries', async () => {
59
+ const operation = jest.fn().mockRejectedValue(new Error('fetch failed'));
60
+
61
+ await expect(
62
+ withTransientErrorRetry(operation, { retries: 2, baseDelayMs: 1 })
63
+ ).rejects.toThrow('fetch failed');
64
+ expect(operation).toHaveBeenCalledTimes(3);
65
+ });
66
+ });
67
+
68
+ describe('findMembersByIds', () => {
69
+ beforeEach(() => {
70
+ wixData.query.mockReset();
71
+ });
72
+
73
+ test('returns a map of String(memberId) to member record', async () => {
74
+ mockQueryReturning(
75
+ jest.fn().mockResolvedValue(
76
+ makeQueryResult([
77
+ { _id: 'a', memberId: 1 },
78
+ { _id: 'b', memberId: 2 },
79
+ ])
80
+ )
81
+ );
82
+
83
+ const membersById = await findMembersByIds([1, 2, 3]);
84
+
85
+ expect(membersById.get('1')).toEqual({ _id: 'a', memberId: 1 });
86
+ expect(membersById.get('2')).toEqual({ _id: 'b', memberId: 2 });
87
+ expect(membersById.has('3')).toBe(false);
88
+ });
89
+
90
+ test('deduplicates input IDs and skips null/undefined before querying', async () => {
91
+ const query = mockQueryReturning(jest.fn().mockResolvedValue(makeQueryResult([])));
92
+
93
+ await findMembersByIds([1, 1, null, undefined, 2]);
94
+
95
+ expect(query.hasSome).toHaveBeenCalledWith('memberId', [1, 2]);
96
+ });
97
+
98
+ test('returns an empty map without querying when there are no IDs', async () => {
99
+ const membersById = await findMembersByIds([]);
100
+
101
+ expect(membersById.size).toBe(0);
102
+ expect(wixData.query).not.toHaveBeenCalled();
103
+ });
104
+
105
+ test('chunks large ID lists into multiple queries', async () => {
106
+ const query = mockQueryReturning(jest.fn().mockResolvedValue(makeQueryResult([])));
107
+ const ids = Array.from({ length: 250 }, (_, i) => i + 1);
108
+
109
+ await findMembersByIds(ids);
110
+
111
+ expect(query.hasSome).toHaveBeenCalledTimes(3);
112
+ expect(query.hasSome.mock.calls.map(call => call[1].length)).toEqual([100, 100, 50]);
113
+ });
114
+
115
+ test('throws when multiple records share the same memberId', async () => {
116
+ mockQueryReturning(
117
+ jest.fn().mockResolvedValue(
118
+ makeQueryResult([
119
+ { _id: 'a', memberId: 1 },
120
+ { _id: 'b', memberId: 1 },
121
+ ])
122
+ )
123
+ );
124
+
125
+ await expect(findMembersByIds([1])).rejects.toThrow(
126
+ 'Multiple members found with memberId(s): [1]'
127
+ );
128
+ });
129
+ });
package/backend/consts.js CHANGED
@@ -34,6 +34,15 @@ const MEMBERSHIPS_TYPES = {
34
34
  PAC_STAFF: 'PAC STAFF',
35
35
  };
36
36
 
37
+ /**
38
+ * Possible outcomes of attempting to change a Wix member's login email during the sync.
39
+ */
40
+ const LOGIN_EMAIL_SYNC_STATUS = {
41
+ UPDATED: 'updated', // Wix login email successfully changed to the desired email
42
+ FAILED: 'failed', // change failed -> keep the CMS login email unchanged and report for manual handling
43
+ SKIPPED: 'skipped', // member has no wixMemberId, nothing to change
44
+ };
45
+
37
46
  module.exports = {
38
47
  CONFIG_KEYS,
39
48
  MAX__MEMBERS_SEARCH_RESULTS,
@@ -45,4 +54,5 @@ module.exports = {
45
54
  MEMBERSHIPS_TYPES,
46
55
  SSO_TOKEN_AUTH_API_URL,
47
56
  BACKUP_API_URL,
57
+ LOGIN_EMAIL_SYNC_STATUS,
48
58
  };
@@ -28,6 +28,9 @@ async function createSiteContact(contactData) {
28
28
  },
29
29
  };
30
30
  console.log('[createSiteContact]contactInfo', JSON.stringify(contactInfo, null, 2));
31
+ // Intentionally NOT passing allowDuplicates: Wix CRM enforces email uniqueness
32
+ // case-insensitively, which is the guard we want against two different members sharing an
33
+ // email. Same-member cases never reach here — they collapse to a single entity upstream.
31
34
  const createContactResponse = await elevatedCreateContact(contactInfo);
32
35
  console.log(
33
36
  '[createSiteContact]createContactResponse',
@@ -1,8 +1,13 @@
1
- const { bulkSaveMembers, getMemberBySlug } = require('../members-data-methods');
1
+ const { bulkSaveMembers, getMemberBySlug, findMembersByIds } = require('../members-data-methods');
2
2
  const { extractUrlCounter } = require('../utils');
3
3
 
4
4
  const { generateUpdatedMemberData } = require('./process-member-methods');
5
- const { changeWixMembersEmails, incrementUrlCounter, extractBaseUrl } = require('./utils');
5
+ const {
6
+ changeWixMembersEmails,
7
+ summarizeLoginEmailOutcomes,
8
+ incrementUrlCounter,
9
+ extractBaseUrl,
10
+ } = require('./utils');
6
11
 
7
12
  /**
8
13
  * Ensures unique URLs within a batch of members by deduplicating URLs
@@ -127,10 +132,18 @@ const bulkProcessAndSaveMemberData = async ({ memberDataList, currentPageNumber
127
132
  const startTime = Date.now();
128
133
 
129
134
  try {
135
+ // Prefetch all existing members for this page in a few chunked queries instead of
136
+ // one query per member — thousands of parallel lookups made the whole page fail
137
+ // whenever a single one hit a transient network error.
138
+ const existingMembersById = await findMembersByIds(
139
+ memberDataList.map(memberData => memberData?.memberid)
140
+ );
141
+
130
142
  const processedMemberDataPromises = memberDataList.map(memberData =>
131
143
  generateUpdatedMemberData({
132
144
  inputMemberData: memberData,
133
145
  currentPageNumber,
146
+ existingDbMember: existingMembersById.get(String(memberData?.memberid)) ?? null,
134
147
  })
135
148
  );
136
149
 
@@ -151,28 +164,55 @@ const bulkProcessAndSaveMemberData = async ({ memberDataList, currentPageNumber
151
164
  // Ensure unique URLs within the batch to prevent duplicates (also checks DB for cross-page conflicts)
152
165
  const uniqueUrlsNewToDBMembersList = await ensureUniqueUrlsInBatch(newMembers);
153
166
  const uniqueUrlsMembersData = [...uniqueUrlsNewToDBMembersList, ...existingMembers];
154
- const toChangeWixMembersEmails = [];
167
+
168
+ // Change Wix login emails FIRST so we know which succeeded before saving the CMS records.
169
+ const toChangeWixMembersEmails = uniqueUrlsMembersData.filter(
170
+ member => member.wixMemberId && member.isLoginEmailChanged
171
+ );
172
+ let failedLoginEmailIds = new Set();
173
+ let loginEmailFailures = [];
174
+ if (toChangeWixMembersEmails.length > 0) {
175
+ const outcomes = await changeWixMembersEmails(toChangeWixMembersEmails);
176
+ ({ failedMemberIds: failedLoginEmailIds, failures: loginEmailFailures } =
177
+ summarizeLoginEmailOutcomes(outcomes));
178
+ }
179
+
155
180
  const toSaveMembersData = uniqueUrlsMembersData.map(member => {
156
- const { isLoginEmailChanged, isNewToDb: _isNewToDb, ...restMemberData } = member;
157
- if (member.wixMemberId && isLoginEmailChanged) {
158
- toChangeWixMembersEmails.push(member);
181
+ // Transient flags only drive the sync above; never persist them.
182
+ const {
183
+ isLoginEmailChanged: _isLoginEmailChanged,
184
+ isNewToDb: _isNewToDb,
185
+ previousLoginEmail,
186
+ ...restMemberData
187
+ } = member;
188
+ // If the Wix login-email change failed, keep the CMS login email unchanged (revert to the
189
+ // previous value) so the CMS stays consistent with Wix; the failure is reported below for
190
+ // manual handling.
191
+ if (failedLoginEmailIds.has(member.memberId) && previousLoginEmail !== undefined) {
192
+ return { ...restMemberData, email: previousLoginEmail };
159
193
  }
160
- return restMemberData; //we don't want to store the isLoginEmailChanged in the database, it's just a flag to know if we need to change the login email in Members area
194
+ return restMemberData;
161
195
  });
162
196
  const saveResult = await bulkSaveMembers(toSaveMembersData);
163
- // Change login emails for users who was dropped but now are back to system as new members and have different loginEmail for users with action DROP
164
- if (toChangeWixMembersEmails.length > 0) {
165
- await changeWixMembersEmails(toChangeWixMembersEmails);
166
- }
167
197
  const totalFailed = memberDataList.length - validMemberData.length;
168
198
  const processingTime = Date.now() - startTime;
169
199
 
200
+ if (loginEmailFailures.length > 0) {
201
+ console.error(
202
+ `[loginEmailSync] ${loginEmailFailures.length} login-email change(s) failed on page ${currentPageNumber}; CMS login email left unchanged for memberIds: [${loginEmailFailures
203
+ .map(failure => failure.memberId)
204
+ .join(', ')}]`
205
+ );
206
+ }
207
+
170
208
  return {
171
209
  ...saveResult,
172
210
  totalProcessed: memberDataList.length,
173
211
  totalSaved: validMemberData.length,
174
212
  totalFailed: totalFailed,
175
213
  processingTime: processingTime,
214
+ loginEmailFailedCount: loginEmailFailures.length,
215
+ loginEmailFailures,
176
216
  };
177
217
  } catch (error) {
178
218
  throw new Error(`Bulk operation failed: ${error.message}`);
@@ -58,16 +58,26 @@ const ensureUniqueUrl = async ({ url, memberId, fullName }) => {
58
58
  * @param {Object} options - The options object
59
59
  * @param {Object} options.inputMemberData - Raw member data from API
60
60
  * @param {number} options.currentPageNumber - Current page number being processed
61
+ * @param {Object|null} [options.existingDbMember] - Prefetched DB member (null if none exists).
62
+ * Pass it when processing members in bulk to avoid one DB query per member; when omitted,
63
+ * the member is looked up individually.
61
64
  * @returns {Promise<Object|null>} - Complete updated member data or null if validation fails
62
65
  */
63
- async function generateUpdatedMemberData({ inputMemberData, currentPageNumber }) {
66
+ async function generateUpdatedMemberData({
67
+ inputMemberData,
68
+ currentPageNumber,
69
+ existingDbMember: prefetchedDbMember,
70
+ }) {
64
71
  if (!validateCoreMemberData(inputMemberData)) {
65
72
  throw new Error(
66
73
  'Invalid member data: memberid, email (valid string), and memberships (array) are required'
67
74
  );
68
75
  }
69
76
 
70
- const existingDbMember = await findMemberById(inputMemberData.memberid);
77
+ const existingDbMember =
78
+ prefetchedDbMember === undefined
79
+ ? await findMemberById(inputMemberData.memberid)
80
+ : prefetchedDbMember;
71
81
 
72
82
  const updatedMemberData = await createCoreMemberData(
73
83
  inputMemberData,
@@ -165,10 +175,12 @@ async function createCoreMemberData(inputMemberData, existingDbMember, currentPa
165
175
  newEmail &&
166
176
  existingDbMember.email !== newEmail;
167
177
  if (isMemberReinstatedWithNewEmail) {
168
- // If exists in DB, and email was changed means this user was dropped before that's why it exists in DB, then only update loginEmail not contactFormEmail
178
+ // If exists in DB, and email was changed means this user was dropped before that's why it exists in DB, then only update loginEmail not contactFormEmail.
179
+ // previousLoginEmail lets the caller keep the old email if the Wix login-email change fails.
169
180
  return {
170
181
  email: newEmail,
171
182
  isLoginEmailChanged: true,
183
+ previousLoginEmail: existingDbMember.email,
172
184
  };
173
185
  }
174
186
  //If exists in DB but not reinstated with new email, then don't update emails
@@ -1,3 +1,4 @@
1
+ const { LOGIN_EMAIL_SYNC_STATUS } = require('../consts');
1
2
  const { updateWixMemberLoginEmail } = require('../members-area-methods');
2
3
  const { extractUrlCounter } = require('../utils');
3
4
 
@@ -7,13 +8,49 @@ const isUpdatedMember = member => member.action !== MEMBER_ACTIONS.NONE;
7
8
  const isSiteAssociatedMember = (member, siteAssociation) =>
8
9
  member.memberships.some(membership => membership.association === siteAssociation);
9
10
 
11
+ /**
12
+ * Attempts to change Wix login emails for the given members and returns one structured
13
+ * outcome per member (see updateWixMemberLoginEmail). Never throws for an individual member.
14
+ * @param {Array} toChangeWixMembersEmails
15
+ * @returns {Promise<Array>} outcomes
16
+ */
10
17
  const changeWixMembersEmails = async toChangeWixMembersEmails => {
11
18
  console.log(
12
- `Changing login emails for ${toChangeWixMembersEmails.length} members with ids: [${toChangeWixMembersEmails.map(member => member.memberId).join(', ')}]`
19
+ `[loginEmailSync] changing login emails for ${toChangeWixMembersEmails.length} members with ids: [${toChangeWixMembersEmails.map(member => member.memberId).join(', ')}]`
13
20
  );
14
- return await Promise.all(
15
- toChangeWixMembersEmails.map(member => updateWixMemberLoginEmail(member, {}))
21
+ const outcomes = await Promise.all(
22
+ toChangeWixMembersEmails.map(member => updateWixMemberLoginEmail(member))
16
23
  );
24
+ const summary = outcomes.reduce((acc, outcome) => {
25
+ acc[outcome.status] = (acc[outcome.status] || 0) + 1;
26
+ return acc;
27
+ }, {});
28
+ console.log(`[loginEmailSync] results summary: ${JSON.stringify(summary)}`);
29
+ return outcomes;
30
+ };
31
+
32
+ /**
33
+ * Summarizes login-email sync outcomes for manual handling via the task result.
34
+ * Pure function so it can be unit-tested without Wix.
35
+ * @param {Array} outcomes - from updateWixMemberLoginEmail / changeWixMembersEmails
36
+ * @returns {{ failedMemberIds: Set, failures: Array }} set of failed memberIds (whose CMS login
37
+ * email must be left unchanged) and the failure records to surface in the task result
38
+ */
39
+ const summarizeLoginEmailOutcomes = (outcomes = []) => {
40
+ const failedMemberIds = new Set();
41
+ const failures = [];
42
+ outcomes.forEach(outcome => {
43
+ if (outcome.status === LOGIN_EMAIL_SYNC_STATUS.FAILED) {
44
+ failedMemberIds.add(outcome.memberId);
45
+ failures.push({
46
+ memberId: outcome.memberId,
47
+ wixMemberId: outcome.wixMemberId,
48
+ desiredEmail: outcome.desiredEmail,
49
+ error: outcome.error || 'unknown error',
50
+ });
51
+ }
52
+ });
53
+ return { failedMemberIds, failures };
17
54
  };
18
55
 
19
56
  const extractBaseUrl = url => {
@@ -105,6 +142,7 @@ module.exports = {
105
142
  isUpdatedMember,
106
143
  isSiteAssociatedMember,
107
144
  changeWixMembersEmails,
145
+ summarizeLoginEmailOutcomes,
108
146
  validateCoreMemberData,
109
147
  containsNonEnglish,
110
148
  createFullName,
package/backend/jobs.js CHANGED
@@ -97,6 +97,20 @@ async function scheduleFixUrlsWithSpacesTask() {
97
97
  }
98
98
  }
99
99
 
100
+ async function scheduleNormalizeMemberEmailsTask() {
101
+ try {
102
+ console.log('scheduleNormalizeMemberEmails started!');
103
+ return await taskManager().schedule({
104
+ name: TASKS_NAMES.scheduleNormalizeMemberEmails,
105
+ data: {},
106
+ type: 'scheduled',
107
+ });
108
+ } catch (error) {
109
+ console.error(`Failed to scheduleNormalizeMemberEmails: ${error.message}`);
110
+ throw new Error(`Failed to scheduleNormalizeMemberEmails: ${error.message}`);
111
+ }
112
+ }
113
+
100
114
  async function runDailyPullExecutionCheck() {
101
115
  try {
102
116
  console.log('runDailyPullExecutionCheck started!');
@@ -126,5 +140,6 @@ module.exports = {
126
140
  scheduleCreateContactsFromMembersTask,
127
141
  scheduleFixPrimaryAddressForMembersTask,
128
142
  scheduleFixUrlsWithSpacesTask,
143
+ scheduleNormalizeMemberEmailsTask,
129
144
  runDailyPullExecutionCheck,
130
145
  };
@@ -2,6 +2,8 @@
2
2
  * Orchestrates syncing between Wix Members and CRM Contacts.
3
3
  * Returns member data to save; caller does a single updateMember to avoid double-write.
4
4
  */
5
+ const { emailsMatch } = require('../public/Utils/sharedUtils');
6
+
5
7
  const { createSiteContact, updateContactInfo, deleteSiteContact } = require('./contacts-methods');
6
8
 
7
9
  /**
@@ -18,7 +20,10 @@ async function updateContactEmail(newContactEmail, existingMemberData) {
18
20
 
19
21
  const { wixContactId, wixMemberId, email: loginEmail } = existingMemberData;
20
22
  const isSingleEntity = wixContactId === wixMemberId;
21
- const contactEmailDiffersFromLogin = loginEmail !== newContactEmail;
23
+ // Compare case-insensitively: Wix CRM enforces email uniqueness without regard to case,
24
+ // so a contact email that equals the login email (e.g. different casing) must collapse to
25
+ // a single entity rather than attempt a duplicate contact write.
26
+ const contactEmailDiffersFromLogin = !emailsMatch(loginEmail, newContactEmail);
22
27
 
23
28
  if (!contactEmailDiffersFromLogin) {
24
29
  if (isSingleEntity) {
@@ -1,8 +1,13 @@
1
1
  const { auth } = require('@wix/essentials');
2
2
  const { members, authentication } = require('@wix/members');
3
+
4
+ const { LOGIN_EMAIL_SYNC_STATUS } = require('./consts');
5
+
3
6
  const elevatedCreateMember = auth.elevate(members.createMember);
4
7
  const elevatedChangeLoginEmail = auth.elevate(authentication.changeLoginEmail);
5
8
 
9
+ const LOG = '[loginEmailSync]';
10
+
6
11
  function prepareMemberData(partner) {
7
12
  const options = {
8
13
  member: {
@@ -37,48 +42,42 @@ const getCurrentMember = async () => {
37
42
  };
38
43
 
39
44
  /**
40
- * Updates Wix member login email if the member has a wixMemberId (registered Wix member)
41
- * @param {Object} member - Member object with wixMemberId and email
42
- * @param {Object} result - Result object to track Wix member updates
45
+ * Attempts to change a Wix member's login email to `member.email` and reports a structured
46
+ * outcome instead of swallowing failures. Never throws.
47
+ *
48
+ * Outcomes:
49
+ * - SKIPPED: member has no wixMemberId, nothing to change.
50
+ * - UPDATED: Wix login email changed successfully.
51
+ * - FAILED: change failed. The caller keeps the CMS login email unchanged (so it stays
52
+ * consistent with Wix) and reports the member in the task result for manual handling.
53
+ *
54
+ * @param {Object} member - Member with { memberId, wixMemberId, email }
55
+ * @returns {Promise<Object>} outcome
43
56
  */
44
- async function updateWixMemberLoginEmail(member, result = {}) {
57
+ async function updateWixMemberLoginEmail(member) {
58
+ const desiredEmail = member.email;
59
+ const base = { memberId: member.memberId, wixMemberId: member.wixMemberId, desiredEmail };
60
+
45
61
  if (!member.wixMemberId) {
46
- console.log(`Member ${member.memberId} has no wixMemberId - skipping Wix login email update`);
47
- return;
62
+ console.log(`${LOG} member ${member.memberId} has no wixMemberId - skipping`);
63
+ return { ...base, status: LOGIN_EMAIL_SYNC_STATUS.SKIPPED };
48
64
  }
49
65
 
50
- try {
51
- console.log(
52
- `Updating Wix login email for member ${member.memberId} (wixMemberId: ${member.wixMemberId})`
53
- );
54
-
55
- const updatedWixMember = await elevatedChangeLoginEmail(member.wixMemberId, member.email);
66
+ console.log(
67
+ `${LOG} attempting login-email change for member ${member.memberId} (wixMemberId: ${member.wixMemberId}) -> ${desiredEmail}`
68
+ );
56
69
 
70
+ try {
71
+ const updatedWixMember = await elevatedChangeLoginEmail(member.wixMemberId, desiredEmail);
57
72
  console.log(
58
- `✅ Successfully updated Wix login email for member ${member.memberId}: ${updatedWixMember.loginEmail}`
73
+ `${LOG} updated member ${member.memberId} (wixMemberId: ${member.wixMemberId}) -> ${updatedWixMember.loginEmail}`
59
74
  );
60
-
61
- if (!result.wixMemberUpdates) {
62
- result.wixMemberUpdates = { successful: 0, failed: 0 };
63
- }
64
- result.wixMemberUpdates.successful++;
75
+ return { ...base, status: LOGIN_EMAIL_SYNC_STATUS.UPDATED };
65
76
  } catch (error) {
66
- console.error(`❌ Failed to update Wix login email for member ${member.memberId}:`, error);
67
-
68
- if (!result.wixMemberUpdates) {
69
- result.wixMemberUpdates = { successful: 0, failed: 0 };
70
- }
71
- result.wixMemberUpdates.failed++;
72
-
73
- if (!result.wixMemberErrors) {
74
- result.wixMemberErrors = [];
75
- }
76
- result.wixMemberErrors.push({
77
- memberId: member.memberId,
78
- wixMemberId: member.wixMemberId,
79
- email: member.email,
80
- error: error.message,
81
- });
77
+ console.error(
78
+ `${LOG} ❌ login-email change failed for member ${member.memberId} (wixMemberId: ${member.wixMemberId}) -> ${desiredEmail}: ${error.message}`
79
+ );
80
+ return { ...base, status: LOGIN_EMAIL_SYNC_STATUS.FAILED, error: error.message };
82
81
  }
83
82
  }
84
83
 
@@ -1,5 +1,5 @@
1
1
  const { COLLECTIONS } = require('../public/consts');
2
- const { isWixHostedImage } = require('../public/Utils/sharedUtils');
2
+ const { isWixHostedImage, emailsMatch, normalizeEmail } = require('../public/Utils/sharedUtils');
3
3
 
4
4
  const { MEMBERSHIPS_TYPES } = require('./consts');
5
5
  const { createSiteContact } = require('./contacts-methods');
@@ -15,6 +15,7 @@ const {
15
15
  generateGeoHash,
16
16
  searchAllItems,
17
17
  runIf,
18
+ withTransientErrorRetry,
18
19
  } = require('./utils');
19
20
 
20
21
  /**
@@ -35,7 +36,42 @@ async function findMemberByWixDataId(memberId) {
35
36
  }
36
37
 
37
38
  const hasDifferentEmails = memberData =>
38
- memberData.contactFormEmail && memberData.contactFormEmail !== memberData.email;
39
+ Boolean(memberData.contactFormEmail) &&
40
+ !emailsMatch(memberData.contactFormEmail, memberData.email);
41
+
42
+ /**
43
+ * Returns a shallow copy of a member record with its email fields normalized
44
+ * (lowercased + trimmed). Wix CRM and our uniqueness checks treat emails
45
+ * case-insensitively, so we persist them in canonical form to keep `.eq` lookups
46
+ * reliable. Only rewrites string values that are actually present.
47
+ * @param {Object} memberData
48
+ * @returns {Object}
49
+ */
50
+ const normalizeMemberEmailFields = memberData => {
51
+ if (!memberData || typeof memberData !== 'object') return memberData;
52
+ const normalized = { ...memberData };
53
+ if (typeof normalized.email === 'string') {
54
+ normalized.email = normalizeEmail(normalized.email);
55
+ }
56
+ if (typeof normalized.contactFormEmail === 'string') {
57
+ normalized.contactFormEmail = normalizeEmail(normalized.contactFormEmail);
58
+ }
59
+ return normalized;
60
+ };
61
+
62
+ /**
63
+ * Whether a member's stored email fields are not already in canonical form
64
+ * (lowercased + trimmed) and therefore need the normalization backfill.
65
+ * @param {Object} member
66
+ * @returns {boolean}
67
+ */
68
+ const memberNeedsEmailNormalization = member =>
69
+ (typeof member.email === 'string' &&
70
+ member.email.length > 0 &&
71
+ member.email !== normalizeEmail(member.email)) ||
72
+ (typeof member.contactFormEmail === 'string' &&
73
+ member.contactFormEmail.length > 0 &&
74
+ member.contactFormEmail !== normalizeEmail(member.contactFormEmail));
39
75
 
40
76
  async function createContactAndMemberIfNew(memberData) {
41
77
  if (!memberData) {
@@ -97,8 +133,14 @@ async function bulkSaveMembers(memberDataList, collectionName = COLLECTIONS.MEMB
97
133
  }
98
134
 
99
135
  try {
136
+ // Normalize email fields only for the members collection; other collections passed here
137
+ // (e.g. staging copies) don't have these fields and must be saved untouched.
138
+ const listToSave =
139
+ collectionName === COLLECTIONS.MEMBERS_DATA
140
+ ? memberDataList.map(normalizeMemberEmailFields)
141
+ : memberDataList;
100
142
  // bulkSave all with batches of 1000 items as this is the Velo limit for bulkSave
101
- const batches = chunkArray(memberDataList, 1000);
143
+ const batches = chunkArray(listToSave, 1000);
102
144
  return await Promise.all(batches.map(batch => wixData.bulkSave(collectionName, batch)));
103
145
  } catch (error) {
104
146
  console.error('Error bulk saving members:', error);
@@ -117,11 +159,9 @@ async function findMemberById(memberId) {
117
159
  }
118
160
 
119
161
  try {
120
- const queryResult = await wixData
121
- .query(COLLECTIONS.MEMBERS_DATA)
122
- .eq('memberId', memberId)
123
- .limit(2)
124
- .find();
162
+ const queryResult = await withTransientErrorRetry(() =>
163
+ wixData.query(COLLECTIONS.MEMBERS_DATA).eq('memberId', memberId).limit(2).find()
164
+ );
125
165
  if (queryResult.items.length > 1) {
126
166
  throw new Error(
127
167
  `Multiple members found with memberId ${memberId} members _ids are : [${queryResult.items.map(member => member._id).join(', ')}]`
@@ -134,6 +174,51 @@ async function findMemberById(memberId) {
134
174
  }
135
175
  }
136
176
 
177
+ /**
178
+ * Retrieves existing members for a list of member IDs in bulk.
179
+ * Uses chunked `hasSome` queries so a full page of members costs a handful of
180
+ * requests instead of one query per member (the per-member fan-out made a whole
181
+ * page fail whenever a single query hit a transient "fetch failed").
182
+ * @param {Array<string|number>} memberIds - Member IDs to look up
183
+ * @returns {Promise<Map<string, Object>>} - Map of String(memberId) to member record (missing IDs are absent)
184
+ */
185
+ async function findMembersByIds(memberIds) {
186
+ const uniqueIds = [...new Set((memberIds || []).filter(id => id !== undefined && id !== null))];
187
+ const membersById = new Map();
188
+ if (uniqueIds.length === 0) {
189
+ return membersById;
190
+ }
191
+
192
+ try {
193
+ const idChunks = chunkArray(uniqueIds, 100);
194
+ const chunkResults = await Promise.all(
195
+ idChunks.map(idsChunk =>
196
+ withTransientErrorRetry(() =>
197
+ queryAllItems(
198
+ wixData.query(COLLECTIONS.MEMBERS_DATA).hasSome('memberId', idsChunk).limit(1000)
199
+ )
200
+ )
201
+ )
202
+ );
203
+
204
+ const duplicateIds = new Set();
205
+ chunkResults.flat().forEach(member => {
206
+ const key = String(member.memberId);
207
+ if (membersById.has(key)) {
208
+ duplicateIds.add(key);
209
+ }
210
+ membersById.set(key, member);
211
+ });
212
+ if (duplicateIds.size > 0) {
213
+ throw new Error(`Multiple members found with memberId(s): [${[...duplicateIds].join(', ')}]`);
214
+ }
215
+ return membersById;
216
+ } catch (error) {
217
+ console.error('Error finding members by IDs:', error);
218
+ throw new Error(`Failed to retrieve member data: ${error.message}`);
219
+ }
220
+ }
221
+
137
222
  /**
138
223
  * Method to get member by slug with flexible filtering options
139
224
  * @param {Object} options - Query options
@@ -244,24 +329,48 @@ const getAllEmptyAboutYouMembers = async () => {
244
329
  */
245
330
  async function updateMember(memberToUpdate) {
246
331
  try {
247
- const updatedMember = await wixData.update(COLLECTIONS.MEMBERS_DATA, memberToUpdate);
332
+ const updatedMember = await wixData.update(
333
+ COLLECTIONS.MEMBERS_DATA,
334
+ normalizeMemberEmailFields(memberToUpdate)
335
+ );
248
336
  return updatedMember;
249
337
  } catch (error) {
250
338
  throw new Error(`Failed to update member data: ${error.message}`);
251
339
  }
252
340
  }
341
+ /**
342
+ * Whether the given email is already used by a DIFFERENT member (case-insensitive).
343
+ * Returns false when the email is free or belongs to the same member, so a member can
344
+ * always keep/normalize their own email.
345
+ * @param {string} email
346
+ * @param {string|number} memberId - The member requesting the change
347
+ * @returns {Promise<boolean>}
348
+ */
253
349
  async function isEmailAlreadyUsed(email, memberId) {
254
350
  const member = await getMemberByContactEmail(email);
255
- return member !== null && member.memberId !== memberId;
351
+ return member !== null && String(member.memberId) !== String(memberId);
256
352
  }
353
+ /**
354
+ * Finds the member that owns an email (in either the login or contact-form field),
355
+ * matching case-insensitively. Emails are normalized (lowercased + trimmed) on write and
356
+ * backfilled by the email-normalization migration, so stored values are canonical: a single
357
+ * `.eq` against the normalized email is an exact, case-insensitive lookup.
358
+ * Throws if two DIFFERENT members share the email (a data-integrity violation).
359
+ * @param {string} email
360
+ * @returns {Promise<Object|null>}
361
+ */
257
362
  async function getMemberByContactEmail(email) {
363
+ const normalized = normalizeEmail(email);
364
+ if (!normalized) return null;
365
+
258
366
  const members = await wixData
259
367
  .query(COLLECTIONS.MEMBERS_DATA)
260
- .eq('contactFormEmail', email)
261
- .or(wixData.query(COLLECTIONS.MEMBERS_DATA).eq('email', email))
368
+ .eq('contactFormEmail', normalized)
369
+ .or(wixData.query(COLLECTIONS.MEMBERS_DATA).eq('email', normalized))
262
370
  .limit(2)
263
371
  .find()
264
372
  .then(res => res.items);
373
+
265
374
  if (members.length > 1) {
266
375
  throw new Error(
267
376
  `[getMemberByContactEmail] Multiple members found with same loginemail or contactFormEmail ${email} membersIds are : [${members.map(member => member.memberId).join(', ')}]`
@@ -451,6 +560,29 @@ const getAllMembersWithoutContactFormEmail = async () => {
451
560
  }
452
561
  };
453
562
 
563
+ /**
564
+ * Gets all members whose email or contactFormEmail is stored with non-canonical casing
565
+ * (or surrounding whitespace) and therefore needs the normalization backfill.
566
+ * Wix Data cannot compare a field to its own lowercase form, so we fetch members that have
567
+ * an email set and filter in memory.
568
+ * @returns {Promise<Array>} - Array of member data
569
+ */
570
+ const getAllMembersNeedingEmailNormalization = async () => {
571
+ try {
572
+ const membersQuery = wixData
573
+ .query(COLLECTIONS.MEMBERS_DATA)
574
+ .isNotEmpty('email')
575
+ .or(wixData.query(COLLECTIONS.MEMBERS_DATA).isNotEmpty('contactFormEmail'))
576
+ .limit(1000);
577
+
578
+ const allItems = await queryAllItems(membersQuery);
579
+ return allItems.filter(memberNeedsEmailNormalization);
580
+ } catch (error) {
581
+ console.error('Error getting members needing email normalization:', error);
582
+ throw new Error(`Failed to get members needing email normalization: ${error.message}`);
583
+ }
584
+ };
585
+
454
586
  /* Gets all updated login emails from the updated emails database
455
587
  * @returns {Promise<Array>} - Array of updated email data
456
588
  */
@@ -488,9 +620,13 @@ const getMembersByIds = async memberIds => {
488
620
 
489
621
  const getMemberByEmail = async email => {
490
622
  try {
623
+ // Login emails are normalized on write, so match against the normalized value (see
624
+ // getMemberByContactEmail) — an exact `.eq` is a case-insensitive lookup.
625
+ const normalized = normalizeEmail(email);
626
+ if (!normalized) return null;
491
627
  const members = await wixData
492
628
  .query(COLLECTIONS.MEMBERS_DATA)
493
- .eq('email', email)
629
+ .eq('email', normalized)
494
630
  .limit(2)
495
631
  .find()
496
632
  .then(res => res.items);
@@ -631,6 +767,7 @@ module.exports = {
631
767
  saveRegistrationData,
632
768
  bulkSaveMembers,
633
769
  findMemberById,
770
+ findMembersByIds,
634
771
  getMemberBySlug,
635
772
  getCMSMemberByWixMemberId,
636
773
  getAllEmptyAboutYouMembers,
@@ -638,6 +775,8 @@ module.exports = {
638
775
  getAllMembersWithExternalImages,
639
776
  getMembersWithWixUrl,
640
777
  getAllMembersWithoutContactFormEmail,
778
+ getAllMembersNeedingEmailNormalization,
779
+ memberNeedsEmailNormalization,
641
780
  getAllUpdatedLoginEmails,
642
781
  getMembersByIds,
643
782
  getMemberByEmail,
@@ -22,6 +22,8 @@ const TASKS_NAMES = {
22
22
  fixPrimaryAddressChunk: 'fixPrimaryAddressChunk',
23
23
  scheduleFixUrlsWithSpaces: 'scheduleFixUrlsWithSpaces',
24
24
  fixUrlsWithSpacesChunk: 'fixUrlsWithSpacesChunk',
25
+ scheduleNormalizeMemberEmails: 'scheduleNormalizeMemberEmails',
26
+ normalizeMemberEmailsChunk: 'normalizeMemberEmailsChunk',
25
27
  dailyPullExecutionCheck: 'dailyPullExecutionCheck',
26
28
  };
27
29
 
@@ -0,0 +1,134 @@
1
+ const { taskManager } = require('psdev-task-manager');
2
+
3
+ const {
4
+ bulkSaveMembers,
5
+ getMembersByIds,
6
+ getAllMembersNeedingEmailNormalization,
7
+ memberNeedsEmailNormalization,
8
+ } = 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: schedules tasks to normalize (lowercase + trim) the email and
17
+ * contactFormEmail fields of existing members so stored values match the normalize-on-write
18
+ * behavior, keeping case-insensitive uniqueness lookups reliable.
19
+ */
20
+ async function scheduleNormalizeMemberEmails() {
21
+ console.log('=== Scheduling Member Email Normalization ===');
22
+
23
+ try {
24
+ const members = await getAllMembersNeedingEmailNormalization();
25
+ console.log(`Fetched ${members.length} members needing email normalization`);
26
+
27
+ const memberIds = [
28
+ ...new Set(
29
+ members
30
+ .map(member => Number(member.memberId))
31
+ .filter(memberId => Number.isFinite(memberId) && memberId > 0)
32
+ ),
33
+ ];
34
+ console.log(`Members to normalize: ${memberIds.length}`);
35
+
36
+ if (memberIds.length === 0) {
37
+ console.log('No members need email normalization');
38
+ return {
39
+ success: true,
40
+ message: 'No members need email normalization',
41
+ totalMembers: 0,
42
+ tasksScheduled: 0,
43
+ };
44
+ }
45
+
46
+ const chunks = chunkArray(memberIds, CHUNK_SIZE);
47
+ for (let i = 0; i < chunks.length; i++) {
48
+ const chunk = chunks[i];
49
+ const task = {
50
+ name: TASKS_NAMES.normalizeMemberEmailsChunk,
51
+ data: {
52
+ memberIds: chunk,
53
+ chunkIndex: i,
54
+ totalChunks: chunks.length,
55
+ },
56
+ type: 'scheduled',
57
+ };
58
+ await taskManager().schedule(task);
59
+ console.log(`Scheduled task ${i + 1}/${chunks.length} (${chunk.length} members)`);
60
+ }
61
+
62
+ const result = {
63
+ success: true,
64
+ message: `Scheduled ${chunks.length} tasks for ${memberIds.length} members`,
65
+ totalMembers: memberIds.length,
66
+ tasksScheduled: chunks.length,
67
+ };
68
+
69
+ console.log('=== Scheduling Complete ===');
70
+ console.log(JSON.stringify(result, null, 2));
71
+
72
+ return result;
73
+ } catch (error) {
74
+ console.error('Error scheduling member email normalization:', error);
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Processes a chunk of members, normalizing their email fields.
81
+ * bulkSaveMembers normalizes on write, so we only need to select the members that still
82
+ * have non-canonical values and save them.
83
+ */
84
+ async function normalizeMemberEmailsChunk(data) {
85
+ const { memberIds, chunkIndex, totalChunks } = data;
86
+ console.log(
87
+ `Processing email normalization chunk ${chunkIndex + 1}/${totalChunks} (${memberIds.length} members)`
88
+ );
89
+
90
+ const result = {
91
+ successful: 0,
92
+ failed: 0,
93
+ skipped: 0,
94
+ errors: [],
95
+ failedIds: [],
96
+ };
97
+
98
+ try {
99
+ const members = await getMembersByIds(memberIds);
100
+ console.log(`Loaded ${members.length} members for this chunk`);
101
+
102
+ const membersToUpdate = members.filter(memberNeedsEmailNormalization);
103
+ result.skipped = members.length - membersToUpdate.length;
104
+
105
+ if (membersToUpdate.length === 0) {
106
+ console.log('No members need updating in this batch');
107
+ return result;
108
+ }
109
+
110
+ try {
111
+ await bulkSaveMembers(membersToUpdate);
112
+ result.successful += membersToUpdate.length;
113
+ console.log(`✅ Successfully normalized ${membersToUpdate.length} members`);
114
+ } catch (error) {
115
+ console.error('❌ Error bulk saving members:', error);
116
+ result.failed += membersToUpdate.length;
117
+ result.failedIds.push(...membersToUpdate.map(member => member.memberId));
118
+ result.errors.push({
119
+ error: error.message,
120
+ memberCount: membersToUpdate.length,
121
+ });
122
+ }
123
+
124
+ return result;
125
+ } catch (error) {
126
+ console.error(`Error processing email normalization chunk ${chunkIndex}:`, error);
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ module.exports = {
132
+ scheduleNormalizeMemberEmails,
133
+ normalizeMemberEmailsChunk,
134
+ };
@@ -10,6 +10,10 @@ const {
10
10
  } = require('./address-primary-methods');
11
11
  const { TASKS_NAMES } = require('./consts');
12
12
  const { dailyPullExecutionCheck } = require('./daily-pull-check-methods');
13
+ const {
14
+ scheduleNormalizeMemberEmails,
15
+ normalizeMemberEmailsChunk,
16
+ } = require('./email-normalize-methods');
13
17
  const {
14
18
  scheduleTaskForEmptyAboutYouMembers,
15
19
  convertAboutYouHtmlToRichContent,
@@ -203,6 +207,20 @@ const TASKS = {
203
207
  shouldSkipCheck: () => false,
204
208
  estimatedDurationSec: 80,
205
209
  },
210
+ [TASKS_NAMES.scheduleNormalizeMemberEmails]: {
211
+ name: TASKS_NAMES.scheduleNormalizeMemberEmails,
212
+ getIdentifier: () => 'SHOULD_NEVER_SKIP',
213
+ process: scheduleNormalizeMemberEmails,
214
+ shouldSkipCheck: () => false,
215
+ estimatedDurationSec: 80,
216
+ },
217
+ [TASKS_NAMES.normalizeMemberEmailsChunk]: {
218
+ name: TASKS_NAMES.normalizeMemberEmailsChunk,
219
+ getIdentifier: task => task.data,
220
+ process: normalizeMemberEmailsChunk,
221
+ shouldSkipCheck: () => false,
222
+ estimatedDurationSec: 80,
223
+ },
206
224
  [TASKS_NAMES.dailyPullExecutionCheck]: {
207
225
  name: TASKS_NAMES.dailyPullExecutionCheck,
208
226
  getIdentifier: task => task.data,
@@ -1,9 +1,9 @@
1
1
  const { taskManager } = require('psdev-task-manager');
2
2
 
3
3
  const { COLLECTIONS } = require('../../public/consts');
4
- const { COMPILED_FILTERS_FIELDS, CONFIG_KEYS } = require('../consts');
4
+ const { COMPILED_FILTERS_FIELDS, CONFIG_KEYS, LOGIN_EMAIL_SYNC_STATUS } = require('../consts');
5
+ const { changeWixMembersEmails, summarizeLoginEmailOutcomes } = require('../daily-pull/utils');
5
6
  const { wixData } = require('../elevated-modules');
6
- const { updateWixMemberLoginEmail } = require('../members-area-methods');
7
7
  const {
8
8
  getAllEmptyAboutYouMembers,
9
9
  getAllMembersWithExternalImages,
@@ -481,6 +481,7 @@ const syncMemberLoginEmails = async data => {
481
481
  membersToUpdate.push({
482
482
  ...member,
483
483
  email: newEmail,
484
+ previousLoginEmail: member.email,
484
485
  });
485
486
  }
486
487
 
@@ -497,14 +498,35 @@ const syncMemberLoginEmails = async data => {
497
498
 
498
499
  for (const chunk of updateChunks) {
499
500
  try {
500
- await bulkSaveMembers(chunk);
501
-
502
- for (const member of chunk) {
503
- await updateWixMemberLoginEmail(member, result);
504
- }
505
-
506
- result.successful += chunk.length;
507
- console.log(`✅ Successfully updated ${chunkIndex} ${chunk.length} members`);
501
+ // Change Wix login emails first; only advance the CMS login email for the ones that
502
+ // succeeded. Failures keep their previous email (CMS stays consistent with Wix) and are
503
+ // reported in result.errors for manual handling.
504
+ const outcomes = await changeWixMembersEmails(chunk);
505
+ const { failedMemberIds } = summarizeLoginEmailOutcomes(outcomes);
506
+ const toSave = chunk.map(member => {
507
+ const { previousLoginEmail, ...restMember } = member;
508
+ if (failedMemberIds.has(member.memberId) && previousLoginEmail !== undefined) {
509
+ return { ...restMember, email: previousLoginEmail };
510
+ }
511
+ return restMember;
512
+ });
513
+ await bulkSaveMembers(toSave);
514
+
515
+ outcomes.forEach(outcome => {
516
+ if (outcome.status === LOGIN_EMAIL_SYNC_STATUS.UPDATED) {
517
+ result.successful++;
518
+ } else if (outcome.status === LOGIN_EMAIL_SYNC_STATUS.FAILED) {
519
+ result.failed++;
520
+ result.errors.push({
521
+ memberId: outcome.memberId,
522
+ wixMemberId: outcome.wixMemberId,
523
+ desiredEmail: outcome.desiredEmail,
524
+ error: outcome.error,
525
+ });
526
+ } else {
527
+ result.skipped++;
528
+ }
529
+ });
508
530
  } catch (error) {
509
531
  console.error(`❌ Error updating chunk ${chunkIndex}:`, error);
510
532
  result.failed += chunk.length;
@@ -515,14 +537,8 @@ const syncMemberLoginEmails = async data => {
515
537
  });
516
538
  }
517
539
  }
518
- // Log comprehensive results including Wix member updates
519
- const wixStats = result.wixMemberUpdates || { successful: 0, failed: 0 };
520
- console.log(`Login Emails sync task completed:`);
521
- console.log(
522
- ` - Member data updates: ${result.successful} successful, ${result.failed} failed, ${result.skipped} skipped`
523
- );
524
540
  console.log(
525
- ` - Wix member login emails: ${wixStats.successful} successful, ${wixStats.failed} failed`
541
+ `Login Emails sync task completed: ${result.successful} synced, ${result.failed} failed, ${result.skipped} skipped`
526
542
  );
527
543
 
528
544
  return result;
package/backend/utils.js CHANGED
@@ -210,6 +210,46 @@ function formatDateOnly(dateStr) {
210
210
 
211
211
  const runIf = (condition, asyncFn) => (condition ? asyncFn() : Promise.resolve(null));
212
212
 
213
+ /**
214
+ * Whether an error looks like a transient network failure that is safe to retry,
215
+ * e.g. undici's generic "fetch failed" thrown by the Wix SDKs, connection resets
216
+ * or DNS hiccups under heavy load.
217
+ * @param {Error} error
218
+ * @returns {boolean}
219
+ */
220
+ const isTransientNetworkError = error => {
221
+ const message = `${error?.message || ''} ${error?.cause?.message || ''} ${error?.code || ''} ${error?.cause?.code || ''}`;
222
+ return /fetch failed|ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|EPIPE|UND_ERR|socket hang up|network error/i.test(
223
+ message
224
+ );
225
+ };
226
+
227
+ /**
228
+ * Runs an async operation, retrying with exponential backoff when it fails with a
229
+ * transient network error. Non-transient errors are rethrown immediately.
230
+ * @param {Function} operation - Async function to run
231
+ * @param {Object} [options]
232
+ * @param {number} [options.retries=2] - Maximum number of retries after the first attempt
233
+ * @param {number} [options.baseDelayMs=500] - Delay before the first retry, doubled each retry
234
+ * @returns {Promise<any>} - The operation's resolved value
235
+ */
236
+ const withTransientErrorRetry = async (operation, { retries = 2, baseDelayMs = 500 } = {}) => {
237
+ for (let attempt = 0; ; attempt++) {
238
+ try {
239
+ return await operation();
240
+ } catch (error) {
241
+ if (attempt >= retries || !isTransientNetworkError(error)) {
242
+ throw error;
243
+ }
244
+ const delayMs = baseDelayMs * 2 ** attempt;
245
+ console.warn(
246
+ `Transient network error (attempt ${attempt + 1}/${retries + 1}): ${error.message}. Retrying in ${delayMs}ms`
247
+ );
248
+ await new Promise(resolve => setTimeout(resolve, delayMs));
249
+ }
250
+ }
251
+ };
252
+
213
253
  module.exports = {
214
254
  getSiteConfigs,
215
255
  retrieveAllItems,
@@ -232,4 +272,6 @@ module.exports = {
232
272
  isPAC_STAFF,
233
273
  searchAllItems,
234
274
  runIf,
275
+ isTransientNetworkError,
276
+ withTransientErrorRetry,
235
277
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abmp-npm",
3
- "version": "10.3.8",
3
+ "version": "10.3.9",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "check-cycles": "madge --circular .",
@@ -49,6 +49,7 @@
49
49
  "axios": "^1.13.1",
50
50
  "crypto": "^1.0.1",
51
51
  "csv-parser": "^3.0.0",
52
+ "debug": "^4.4.3",
52
53
  "jwt-js-decode": "^1.9.0",
53
54
  "lodash": "^4.17.21",
54
55
  "ngeohash": "^0.6.3",
@@ -195,6 +195,28 @@ function normalizeExternalUrl(url) {
195
195
  return `https://${trimmed}`;
196
196
  }
197
197
 
198
+ /**
199
+ * Normalizes an email for comparison (lowercased + trimmed).
200
+ * Wix CRM treats emails as case-insensitive, so comparisons must too.
201
+ * @param {string} email
202
+ * @returns {string}
203
+ */
204
+ function normalizeEmail(email) {
205
+ return typeof email === 'string' ? email.trim().toLowerCase() : '';
206
+ }
207
+
208
+ /**
209
+ * Case-insensitive email equality. Two empty/missing emails are not considered a match.
210
+ * @param {string} a
211
+ * @param {string} b
212
+ * @returns {boolean}
213
+ */
214
+ function emailsMatch(a, b) {
215
+ const normalizedA = normalizeEmail(a);
216
+ const normalizedB = normalizeEmail(b);
217
+ return Boolean(normalizedA) && normalizedA === normalizedB;
218
+ }
219
+
198
220
  module.exports = {
199
221
  checkAddressIsVisible,
200
222
  formatPracticeAreasForDisplay,
@@ -208,4 +230,6 @@ module.exports = {
208
230
  formatAddress,
209
231
  isWixHostedImage,
210
232
  normalizeExternalUrl,
233
+ normalizeEmail,
234
+ emailsMatch,
211
235
  };