abmp-npm 2.0.67 → 2.0.69

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
+ });
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',
@@ -2,7 +2,12 @@ const { bulkSaveMembers, getMemberBySlug } = 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
@@ -151,28 +156,55 @@ const bulkProcessAndSaveMemberData = async ({ memberDataList, currentPageNumber
151
156
  // Ensure unique URLs within the batch to prevent duplicates (also checks DB for cross-page conflicts)
152
157
  const uniqueUrlsNewToDBMembersList = await ensureUniqueUrlsInBatch(newMembers);
153
158
  const uniqueUrlsMembersData = [...uniqueUrlsNewToDBMembersList, ...existingMembers];
154
- const toChangeWixMembersEmails = [];
159
+
160
+ // Change Wix login emails FIRST so we know which succeeded before saving the CMS records.
161
+ const toChangeWixMembersEmails = uniqueUrlsMembersData.filter(
162
+ member => member.wixMemberId && member.isLoginEmailChanged
163
+ );
164
+ let failedLoginEmailIds = new Set();
165
+ let loginEmailFailures = [];
166
+ if (toChangeWixMembersEmails.length > 0) {
167
+ const outcomes = await changeWixMembersEmails(toChangeWixMembersEmails);
168
+ ({ failedMemberIds: failedLoginEmailIds, failures: loginEmailFailures } =
169
+ summarizeLoginEmailOutcomes(outcomes));
170
+ }
171
+
155
172
  const toSaveMembersData = uniqueUrlsMembersData.map(member => {
156
- const { isLoginEmailChanged, isNewToDb: _isNewToDb, ...restMemberData } = member;
157
- if (member.wixMemberId && isLoginEmailChanged) {
158
- toChangeWixMembersEmails.push(member);
173
+ // Transient flags only drive the sync above; never persist them.
174
+ const {
175
+ isLoginEmailChanged: _isLoginEmailChanged,
176
+ isNewToDb: _isNewToDb,
177
+ previousLoginEmail,
178
+ ...restMemberData
179
+ } = member;
180
+ // If the Wix login-email change failed, keep the CMS login email unchanged (revert to the
181
+ // previous value) so the CMS stays consistent with Wix; the failure is reported below for
182
+ // manual handling.
183
+ if (failedLoginEmailIds.has(member.memberId) && previousLoginEmail !== undefined) {
184
+ return { ...restMemberData, email: previousLoginEmail };
159
185
  }
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
186
+ return restMemberData;
161
187
  });
162
188
  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
189
  const totalFailed = memberDataList.length - validMemberData.length;
168
190
  const processingTime = Date.now() - startTime;
169
191
 
192
+ if (loginEmailFailures.length > 0) {
193
+ console.error(
194
+ `[loginEmailSync] ${loginEmailFailures.length} login-email change(s) failed on page ${currentPageNumber}; CMS login email left unchanged for memberIds: [${loginEmailFailures
195
+ .map(failure => failure.memberId)
196
+ .join(', ')}]`
197
+ );
198
+ }
199
+
170
200
  return {
171
201
  ...saveResult,
172
202
  totalProcessed: memberDataList.length,
173
203
  totalSaved: validMemberData.length,
174
204
  totalFailed: totalFailed,
175
205
  processingTime: processingTime,
206
+ loginEmailFailedCount: loginEmailFailures.length,
207
+ loginEmailFailures,
176
208
  };
177
209
  } catch (error) {
178
210
  throw new Error(`Bulk operation failed: ${error.message}`);
@@ -165,10 +165,12 @@ async function createCoreMemberData(inputMemberData, existingDbMember, currentPa
165
165
  newEmail &&
166
166
  existingDbMember.email !== newEmail;
167
167
  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
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.
169
+ // previousLoginEmail lets the caller keep the old email if the Wix login-email change fails.
169
170
  return {
170
171
  email: newEmail,
171
172
  isLoginEmailChanged: true,
173
+ previousLoginEmail: existingDbMember.email,
172
174
  };
173
175
  }
174
176
  //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,
@@ -1,3 +1,5 @@
1
+ const axios = require('axios');
2
+
1
3
  const { COLLECTIONS } = require('../../public/consts');
2
4
  const { clearCollection } = require('../cms-data-methods');
3
5
  const { CONFIG_KEYS } = require('../consts');
@@ -10,13 +12,9 @@ const getInterests = async () => {
10
12
  getSiteConfigs(CONFIG_KEYS.INTERESTS_API_URL),
11
13
  getHeaders(),
12
14
  ]);
13
- const fetchOptions = {
14
- method: 'get',
15
- headers: headers,
16
- };
17
15
  try {
18
- const response = await fetch(url, fetchOptions);
19
- return await response.json();
16
+ const response = await axios.get(url, { headers });
17
+ return response.data;
20
18
  } catch (e) {
21
19
  console.error('Error getting interests:', e);
22
20
  throw e;
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
  };
@@ -0,0 +1,27 @@
1
+ const { auth } = require('@wix/essentials');
2
+ const { authentication } = require('@wix/identity');
3
+
4
+ const elevatedSignOn = auth.elevate(authentication.signOn);
5
+
6
+ /**
7
+ * Creates a Wix member session token for SSO / QA login using @wix/identity signOn (elevated).
8
+ * @param {string} email - Member login email
9
+ * @returns {Promise<string>} Session token for authentication.applySessionToken on the client
10
+ */
11
+ async function generateMemberSessionToken(email) {
12
+ const trimmedEmail = (email || '').trim();
13
+ if (!trimmedEmail) {
14
+ throw new Error('Email is required to generate a session token');
15
+ }
16
+
17
+ const response = await elevatedSignOn({ email: trimmedEmail });
18
+ const sessionToken = response?.sessionToken;
19
+ if (!sessionToken) {
20
+ throw new Error('Failed to generate session token: empty response from signOn');
21
+ }
22
+ return sessionToken;
23
+ }
24
+
25
+ module.exports = {
26
+ generateMemberSessionToken,
27
+ };
@@ -1,7 +1,8 @@
1
- const { createLoginMethods } = require('./login-methods-factory');
2
- const { validateMemberToken } = require('./sso-methods');
1
+ const { loginQAMember } = require('./qa-login-methods');
2
+ const { validateMemberToken, authenticateSSOToken } = require('./sso-methods');
3
3
 
4
4
  module.exports = {
5
- createLoginMethods,
5
+ loginQAMember,
6
6
  validateMemberToken,
7
+ authenticateSSOToken,
7
8
  };
@@ -2,6 +2,8 @@ const { CONFIG_KEYS } = require('../consts');
2
2
  const { prepareMemberForQALogin, getQAUsers } = require('../members-data-methods');
3
3
  const { getSecret, getSiteConfigs } = require('../utils');
4
4
 
5
+ const { generateMemberSessionToken } = require('./generate-member-session-token');
6
+
5
7
  const validateQAUser = async userEmail => {
6
8
  const qaUsers = await getQAUsers();
7
9
  const matchingUserEmail = qaUsers.find(user => user.email === userEmail)?.email;
@@ -16,10 +18,9 @@ const validateQAUser = async userEmail => {
16
18
  * @param {Object} params - The parameters for the login
17
19
  * @param {string} params.userEmail - The email of the user to login
18
20
  * @param {string} params.secret - The secret of the user to login
19
- * @param {Function} generateSessionToken - a dependency of the method, injected by the createLoginMethods function
20
21
  * @returns {Promise<Object>} The result of the login
21
22
  */
22
- const loginQAMember = async ({ userEmail, secret }, generateSessionToken) => {
23
+ const loginQAMember = async ({ userEmail, secret }) => {
23
24
  try {
24
25
  const [qaSecret, allowAnyMember] = await Promise.all([
25
26
  getSecret('ABMP_QA_SECRET'),
@@ -36,7 +37,7 @@ const loginQAMember = async ({ userEmail, secret }, generateSessionToken) => {
36
37
  }
37
38
 
38
39
  const memberData = await prepareMemberForQALogin(userEmail);
39
- const token = await generateSessionToken(memberData.email, qaSecret);
40
+ const token = await generateMemberSessionToken(memberData.email);
40
41
  return {
41
42
  success: true,
42
43
  token,
@@ -1,5 +1,6 @@
1
1
  const { createHmac } = require('crypto');
2
2
 
3
+ const axios = require('axios');
3
4
  const { decode } = require('jwt-js-decode');
4
5
 
5
6
  const { CONFIG_KEYS, SSO_TOKEN_AUTH_API_URL } = require('../consts');
@@ -14,6 +15,8 @@ const {
14
15
  getSecret,
15
16
  } = require('../utils');
16
17
 
18
+ const { generateMemberSessionToken } = require('./generate-member-session-token');
19
+
17
20
  /**
18
21
  * Validates member token and retrieves member data
19
22
  * @param {string} memberIdInput - The member ID to validate
@@ -91,17 +94,16 @@ async function checkAndFetchSSO(token) {
91
94
  const SSO_TOKEN_AUTH_API_KEY = await getSecret('SSO_TOKEN_AUTH_API_KEY');
92
95
  const signature = createHmac('sha256', SSO_TOKEN_AUTH_API_KEY).update(token).digest('hex');
93
96
  const professionalassistcorpUrl = `${SSO_TOKEN_AUTH_API_URL}/eweb/SSOToken.ashx?token=${token}&Partner=Wix&Signature=${signature}`;
94
- const options = {
95
- method: 'get',
96
- };
97
97
  try {
98
- const httpResponse = await fetch(professionalassistcorpUrl, options);
98
+ const httpResponse = await axios.get(professionalassistcorpUrl, {
99
+ transformResponse: [d => d],
100
+ validateStatus: () => true,
101
+ });
99
102
  console.log('httpResponse status', httpResponse.status);
100
- if (!httpResponse.ok) {
103
+ if (httpResponse.status < 200 || httpResponse.status >= 300) {
101
104
  throw new Error('Fetch did not succeed with status: ' + httpResponse.status);
102
105
  }
103
- const responseToken = await httpResponse.text();
104
- return responseToken;
106
+ return httpResponse.data;
105
107
  } catch (error) {
106
108
  console.error('Error in checkAndFetchSSO', error);
107
109
  return null;
@@ -112,10 +114,9 @@ async function checkAndFetchSSO(token) {
112
114
  * Authenticate an SSO token
113
115
  * @param {Object} params - The parameters for the authentication
114
116
  * @param {string} params.token - The token to authenticate
115
- * @param {Function} generateSessionToken - a dependency of the method, injected by the createLoginMethods function
116
117
  * @returns {Promise<Object>} The result of the authentication
117
118
  */
118
- const authenticateSSOToken = async ({ token }, generateSessionToken) => {
119
+ const authenticateSSOToken = async ({ token }) => {
119
120
  const responseToken = await checkAndFetchSSO(token);
120
121
  const isValidToken = Boolean(
121
122
  responseToken && typeof responseToken === 'string' && responseToken?.trim()
@@ -135,7 +136,7 @@ const authenticateSSOToken = async ({ token }, generateSessionToken) => {
135
136
  const payload = jwt.payload;
136
137
  const memberData = await prepareMemberForSSOLogin(payload);
137
138
  console.log('memberDataCollectionId', memberData._id);
138
- const sessionToken = await generateSessionToken(memberData.email);
139
+ const sessionToken = await generateMemberSessionToken(memberData.email);
139
140
  const authObj = {
140
141
  type: 'success',
141
142
  memberId: memberData._id,
@@ -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');
@@ -35,7 +35,42 @@ async function findMemberByWixDataId(memberId) {
35
35
  }
36
36
 
37
37
  const hasDifferentEmails = memberData =>
38
- memberData.contactFormEmail && memberData.contactFormEmail !== memberData.email;
38
+ Boolean(memberData.contactFormEmail) &&
39
+ !emailsMatch(memberData.contactFormEmail, memberData.email);
40
+
41
+ /**
42
+ * Returns a shallow copy of a member record with its email fields normalized
43
+ * (lowercased + trimmed). Wix CRM and our uniqueness checks treat emails
44
+ * case-insensitively, so we persist them in canonical form to keep `.eq` lookups
45
+ * reliable. Only rewrites string values that are actually present.
46
+ * @param {Object} memberData
47
+ * @returns {Object}
48
+ */
49
+ const normalizeMemberEmailFields = memberData => {
50
+ if (!memberData || typeof memberData !== 'object') return memberData;
51
+ const normalized = { ...memberData };
52
+ if (typeof normalized.email === 'string') {
53
+ normalized.email = normalizeEmail(normalized.email);
54
+ }
55
+ if (typeof normalized.contactFormEmail === 'string') {
56
+ normalized.contactFormEmail = normalizeEmail(normalized.contactFormEmail);
57
+ }
58
+ return normalized;
59
+ };
60
+
61
+ /**
62
+ * Whether a member's stored email fields are not already in canonical form
63
+ * (lowercased + trimmed) and therefore need the normalization backfill.
64
+ * @param {Object} member
65
+ * @returns {boolean}
66
+ */
67
+ const memberNeedsEmailNormalization = member =>
68
+ (typeof member.email === 'string' &&
69
+ member.email.length > 0 &&
70
+ member.email !== normalizeEmail(member.email)) ||
71
+ (typeof member.contactFormEmail === 'string' &&
72
+ member.contactFormEmail.length > 0 &&
73
+ member.contactFormEmail !== normalizeEmail(member.contactFormEmail));
39
74
 
40
75
  async function createContactAndMemberIfNew(memberData) {
41
76
  if (!memberData) {
@@ -97,8 +132,14 @@ async function bulkSaveMembers(memberDataList, collectionName = COLLECTIONS.MEMB
97
132
  }
98
133
 
99
134
  try {
135
+ // Normalize email fields only for the members collection; other collections passed here
136
+ // (e.g. staging copies) don't have these fields and must be saved untouched.
137
+ const listToSave =
138
+ collectionName === COLLECTIONS.MEMBERS_DATA
139
+ ? memberDataList.map(normalizeMemberEmailFields)
140
+ : memberDataList;
100
141
  // bulkSave all with batches of 1000 items as this is the Velo limit for bulkSave
101
- const batches = chunkArray(memberDataList, 1000);
142
+ const batches = chunkArray(listToSave, 1000);
102
143
  return await Promise.all(batches.map(batch => wixData.bulkSave(collectionName, batch)));
103
144
  } catch (error) {
104
145
  console.error('Error bulk saving members:', error);
@@ -244,24 +285,48 @@ const getAllEmptyAboutYouMembers = async () => {
244
285
  */
245
286
  async function updateMember(memberToUpdate) {
246
287
  try {
247
- const updatedMember = await wixData.update(COLLECTIONS.MEMBERS_DATA, memberToUpdate);
288
+ const updatedMember = await wixData.update(
289
+ COLLECTIONS.MEMBERS_DATA,
290
+ normalizeMemberEmailFields(memberToUpdate)
291
+ );
248
292
  return updatedMember;
249
293
  } catch (error) {
250
294
  throw new Error(`Failed to update member data: ${error.message}`);
251
295
  }
252
296
  }
297
+ /**
298
+ * Whether the given email is already used by a DIFFERENT member (case-insensitive).
299
+ * Returns false when the email is free or belongs to the same member, so a member can
300
+ * always keep/normalize their own email.
301
+ * @param {string} email
302
+ * @param {string|number} memberId - The member requesting the change
303
+ * @returns {Promise<boolean>}
304
+ */
253
305
  async function isEmailAlreadyUsed(email, memberId) {
254
306
  const member = await getMemberByContactEmail(email);
255
- return member !== null && member.memberId !== memberId;
307
+ return member !== null && String(member.memberId) !== String(memberId);
256
308
  }
309
+ /**
310
+ * Finds the member that owns an email (in either the login or contact-form field),
311
+ * matching case-insensitively. Emails are normalized (lowercased + trimmed) on write and
312
+ * backfilled by the email-normalization migration, so stored values are canonical: a single
313
+ * `.eq` against the normalized email is an exact, case-insensitive lookup.
314
+ * Throws if two DIFFERENT members share the email (a data-integrity violation).
315
+ * @param {string} email
316
+ * @returns {Promise<Object|null>}
317
+ */
257
318
  async function getMemberByContactEmail(email) {
319
+ const normalized = normalizeEmail(email);
320
+ if (!normalized) return null;
321
+
258
322
  const members = await wixData
259
323
  .query(COLLECTIONS.MEMBERS_DATA)
260
- .eq('contactFormEmail', email)
261
- .or(wixData.query(COLLECTIONS.MEMBERS_DATA).eq('email', email))
324
+ .eq('contactFormEmail', normalized)
325
+ .or(wixData.query(COLLECTIONS.MEMBERS_DATA).eq('email', normalized))
262
326
  .limit(2)
263
327
  .find()
264
328
  .then(res => res.items);
329
+
265
330
  if (members.length > 1) {
266
331
  throw new Error(
267
332
  `[getMemberByContactEmail] Multiple members found with same loginemail or contactFormEmail ${email} membersIds are : [${members.map(member => member.memberId).join(', ')}]`
@@ -451,6 +516,29 @@ const getAllMembersWithoutContactFormEmail = async () => {
451
516
  }
452
517
  };
453
518
 
519
+ /**
520
+ * Gets all members whose email or contactFormEmail is stored with non-canonical casing
521
+ * (or surrounding whitespace) and therefore needs the normalization backfill.
522
+ * Wix Data cannot compare a field to its own lowercase form, so we fetch members that have
523
+ * an email set and filter in memory.
524
+ * @returns {Promise<Array>} - Array of member data
525
+ */
526
+ const getAllMembersNeedingEmailNormalization = async () => {
527
+ try {
528
+ const membersQuery = wixData
529
+ .query(COLLECTIONS.MEMBERS_DATA)
530
+ .isNotEmpty('email')
531
+ .or(wixData.query(COLLECTIONS.MEMBERS_DATA).isNotEmpty('contactFormEmail'))
532
+ .limit(1000);
533
+
534
+ const allItems = await queryAllItems(membersQuery);
535
+ return allItems.filter(memberNeedsEmailNormalization);
536
+ } catch (error) {
537
+ console.error('Error getting members needing email normalization:', error);
538
+ throw new Error(`Failed to get members needing email normalization: ${error.message}`);
539
+ }
540
+ };
541
+
454
542
  /* Gets all updated login emails from the updated emails database
455
543
  * @returns {Promise<Array>} - Array of updated email data
456
544
  */
@@ -488,9 +576,13 @@ const getMembersByIds = async memberIds => {
488
576
 
489
577
  const getMemberByEmail = async email => {
490
578
  try {
579
+ // Login emails are normalized on write, so match against the normalized value (see
580
+ // getMemberByContactEmail) — an exact `.eq` is a case-insensitive lookup.
581
+ const normalized = normalizeEmail(email);
582
+ if (!normalized) return null;
491
583
  const members = await wixData
492
584
  .query(COLLECTIONS.MEMBERS_DATA)
493
- .eq('email', email)
585
+ .eq('email', normalized)
494
586
  .limit(2)
495
587
  .find()
496
588
  .then(res => res.items);
@@ -638,6 +730,8 @@ module.exports = {
638
730
  getAllMembersWithExternalImages,
639
731
  getMembersWithWixUrl,
640
732
  getAllMembersWithoutContactFormEmail,
733
+ getAllMembersNeedingEmailNormalization,
734
+ memberNeedsEmailNormalization,
641
735
  getAllUpdatedLoginEmails,
642
736
  getMembersByIds,
643
737
  getMemberByEmail,
@@ -1,3 +1,5 @@
1
+ const axios = require('axios');
2
+
1
3
  const { PAC_API_URL, TEST_PAC_API_URL, BACKUP_API_URL } = require('./consts');
2
4
  const { getSecret } = require('./utils');
3
5
 
@@ -31,24 +33,22 @@ const fetchPACMembers = async ({ page, action, backupDate, isTestEnvironment })
31
33
  const url = `${baseUrl}/Members?${new URLSearchParams(queryParams).toString()}`;
32
34
  console.log(`Fetching PAC members from: ${url}`);
33
35
  const headers = await getHeaders();
34
- const fetchOptions = {
35
- method: 'get',
36
- headers: headers,
37
- };
38
- const response = await fetch(url, fetchOptions);
39
- const responseType = response.headers.get('content-type');
36
+ const response = await axios.get(url, {
37
+ headers,
38
+ validateStatus: () => true,
39
+ });
40
+ const responseType = response.headers['content-type'] || '';
40
41
  if (!responseType.includes('application/json')) {
41
42
  const errorMessage = `[fetchPACMembers] got invalid responseType: ${responseType} for page ${page} and actionFilter ${action}`;
42
43
  console.error(errorMessage);
43
44
  throw new Error(errorMessage);
44
45
  }
45
- if (response.ok) {
46
- return response.json();
47
- } else {
48
- const errorMessage = `[fetchPACMembers] failed with status ${response.status} for page ${page} and actionFilter ${action}`;
49
- console.error(errorMessage);
50
- throw new Error(errorMessage);
46
+ if (response.status >= 200 && response.status < 300) {
47
+ return response.data;
51
48
  }
49
+ const errorMessage = `[fetchPACMembers] failed with status ${response.status} for page ${page} and actionFilter ${action}`;
50
+ console.error(errorMessage);
51
+ throw new Error(errorMessage);
52
52
  };
53
53
 
54
54
  module.exports = { fetchPACMembers, getHeaders }; //TODO: remove getHeaders from exported methods once npm movement finishes
@@ -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,
@@ -103,23 +103,23 @@ async function updateMemberRichContent(memberId) {
103
103
  content: htmlString,
104
104
  });
105
105
 
106
- const requestOptions = {
107
- method: 'post',
108
- headers: {
109
- 'Content-Type': 'application/json',
110
- Cookie: 'XSRF-TOKEN=1753949844|p--a7HsuVjR4',
111
- Authorization: 'Bearer ' + (await getServerlessAuth()),
112
- },
113
- body: raw,
106
+ const requestHeaders = {
107
+ 'Content-Type': 'application/json',
108
+ Cookie: 'XSRF-TOKEN=1753949844|p--a7HsuVjR4',
109
+ Authorization: 'Bearer ' + (await getServerlessAuth()),
114
110
  };
115
111
 
116
112
  try {
117
- const response = await fetch(
113
+ const response = await axios.post(
118
114
  'https://www.wixapis.com/data-sync/v1/abmp-content-converter',
119
- requestOptions
115
+ raw,
116
+ {
117
+ headers: requestHeaders,
118
+ validateStatus: () => true,
119
+ }
120
120
  );
121
- if (response.ok) {
122
- const data = await response.json();
121
+ if (response.status >= 200 && response.status < 300) {
122
+ const data = response.data;
123
123
  const updatedMember = {
124
124
  ...member,
125
125
  aboutYourSelf: data.richContent.richContent,
@@ -358,9 +358,13 @@ async function uploadMembersSitemap({ members, tokens, destinationFileName, site
358
358
 
359
359
  const url = `https://${host}${pathName}`;
360
360
  console.log('url', url);
361
- const res = await fetch(url, { method, headers: reqOpts.headers, body });
362
- if (!res.ok) {
363
- const respText = await res.text();
361
+ const res = await axios.put(url, body, {
362
+ headers: reqOpts.headers,
363
+ transformResponse: [d => d],
364
+ validateStatus: () => true,
365
+ });
366
+ if (res.status < 200 || res.status >= 300) {
367
+ const respText = res.data;
364
368
  console.log('Response body', respText);
365
369
  throw new Error(`S3 PUT failed ${res.status} ${res.statusText}: ${respText}`);
366
370
  }
@@ -391,13 +395,13 @@ async function stsPost(body, baseAccessKeyId, baseSecretAccessKey) {
391
395
  accessKeyId: baseAccessKeyId,
392
396
  secretAccessKey: baseSecretAccessKey,
393
397
  });
394
- const res = await fetch(`https://${host}${path}`, {
395
- method,
398
+ const res = await axios.post(`https://${host}${path}`, body, {
396
399
  headers: reqOpts.headers,
397
- body,
400
+ transformResponse: [d => d],
401
+ validateStatus: () => true,
398
402
  });
399
- const text = await res.text();
400
- if (!res.ok) throw new Error(`STS ${res.status}: ${text}`);
403
+ const text = res.data;
404
+ if (res.status < 200 || res.status >= 300) throw new Error(`STS ${res.status}: ${text}`);
401
405
 
402
406
  const accessKeyId = parseXmlVal(text, 'AccessKeyId');
403
407
  const secretAccessKey = parseXmlVal(text, 'SecretAccessKey');
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abmp-npm",
3
- "version": "2.0.67",
3
+ "version": "2.0.69",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "check-cycles": "madge --circular .",
@@ -35,8 +35,8 @@
35
35
  "@wix/automations": "^1.0.261",
36
36
  "@wix/crm": "^1.0.1061",
37
37
  "@wix/data": "^1.0.349",
38
- "@wix/essentials": "^0.1.28",
39
- "@wix/identity": "^1.0.178",
38
+ "@wix/essentials": "^1.0.7",
39
+ "@wix/identity": "^1.0.195",
40
40
  "@wix/media": "^1.0.213",
41
41
  "@wix/members": "^1.0.365",
42
42
  "@wix/secrets": "^1.0.62",
@@ -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
  };
@@ -1,24 +0,0 @@
1
- const { loginQAMember } = require('./qa-login-methods');
2
- const { authenticateSSOToken } = require('./sso-methods');
3
-
4
- /**
5
- * Creates login methods with injected generateSessionToken dependency
6
- * @param {Function} generateSessionToken - The Velo generateSessionToken function to inject
7
- * @returns {Object} Object containing loginQAMember and authenticateSSOToken methods
8
- */
9
- const createLoginMethods = generateSessionToken => {
10
- //There is no generateSessionToken SDK version, and the signOn of @wix/identity returns 403 error regardless that the permissions are valid
11
- //Therefore, as a workaround we need to inject the Velo version of generateSessionToken to the login methods.
12
- const injectGenerateSessionTokenToMethod =
13
- method =>
14
- async (...args) =>
15
- await method(...args, generateSessionToken);
16
- return {
17
- loginQAMember: injectGenerateSessionTokenToMethod(loginQAMember),
18
- authenticateSSOToken: injectGenerateSessionTokenToMethod(authenticateSSOToken),
19
- };
20
- };
21
-
22
- module.exports = {
23
- createLoginMethods,
24
- };