abmp-npm 10.3.15 → 10.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/association-expiry.js +144 -0
- package/backend/cms-data-methods.js +9 -0
- package/backend/consts.js +7 -0
- package/backend/daily-pull/process-member-methods.js +4 -0
- package/backend/daily-pull/sync-to-cms-methods.js +10 -3
- package/backend/jobs.js +22 -0
- package/backend/login/sso-methods.js +19 -2
- package/backend/members-data-methods.js +27 -1
- package/backend/routers/utils.js +8 -0
- package/backend/tasks/association-expiry-backfill-methods.js +178 -0
- package/backend/tasks/consts.js +2 -0
- package/backend/tasks/index.js +1 -0
- package/backend/tasks/tasks-configs.js +21 -0
- package/package.json +8 -1
- package/pages/Home.js +11 -13
- package/pages/Profile.js +6 -1
- package/public/Utils/personalDetailsUtils.js +17 -6
- package/public/Utils/sharedUtils.js +73 -0
- package/.claude/skills/wix-data-query/SKILL.md +0 -121
- package/.claude/skills/wix-data-query/references/members-data-latest.md +0 -125
- package/.claude/skills/wix-data-query/references/recipes.md +0 -210
- package/.husky/pre-commit +0 -19
- package/.prettierignore +0 -7
- package/.prettierrc.json +0 -16
- package/backend/__tests__/daily-pull-execution-check.test.js +0 -124
- package/backend/__tests__/ensure-unique-urls-in-batch.test.js +0 -129
- package/backend/__tests__/login-email-sync.test.js +0 -49
- package/backend/__tests__/transient-retry-and-bulk-lookup.test.js +0 -129
- package/backend/__tests__/url-uniqueness.test.js +0 -194
- package/backend/__tests__/url-validation.test.js +0 -69
- package/dev-only-scripts/extract-duplicate-url-groups.js +0 -201
- package/dev-only-scripts/find-duplicate-ids.js +0 -159
- package/dev-only-scripts/find-duplicate-urls.js +0 -201
- package/eslint.config.js +0 -120
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The per-association expiry rule, shared by the sync, the backfill and the read paths so they
|
|
2
|
+
// cannot drift on what "expired" means. Deliberately free of Wix imports.
|
|
3
|
+
|
|
4
|
+
const ASSOCIATION_EXPIRATION_FIELD = 'associationExpiration';
|
|
5
|
+
const ASSOCIATION_TIME_ZONE = 'America/Denver';
|
|
6
|
+
|
|
7
|
+
const EXPIRATION_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
8
|
+
|
|
9
|
+
const EXPIRATION_OUTCOMES = {
|
|
10
|
+
RESOLVED: 'resolved',
|
|
11
|
+
NO_SITE_ASSOCIATION: 'noSiteAssociation',
|
|
12
|
+
NO_MEMBERSHIP_FOR_ASSOCIATION: 'noMembershipForAssociation',
|
|
13
|
+
MISSING_EXPIRATION: 'missingExpiration',
|
|
14
|
+
UNREADABLE_EXPIRATION: 'unreadableExpiration',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// PAC sends zoneless ISO strings, which `new Date()` would read as local time - the same feed
|
|
18
|
+
// would then mean different days depending on where it ran.
|
|
19
|
+
const parseExpirationToUtcDate = expiration => {
|
|
20
|
+
if (typeof expiration !== 'string') return null;
|
|
21
|
+
|
|
22
|
+
const match = EXPIRATION_DATE_PATTERN.exec(expiration.trim());
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
|
|
25
|
+
const [, year, month, day] = match.map(Number);
|
|
26
|
+
const parsed = new Date(Date.UTC(year, month - 1, day));
|
|
27
|
+
|
|
28
|
+
// Date.UTC rolls 2026-02-31 forward to 3 March rather than rejecting it.
|
|
29
|
+
const isRealDate =
|
|
30
|
+
parsed.getUTCFullYear() === year &&
|
|
31
|
+
parsed.getUTCMonth() === month - 1 &&
|
|
32
|
+
parsed.getUTCDate() === day;
|
|
33
|
+
|
|
34
|
+
return isRealDate ? parsed : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// outcome explains a null date for the backfill report: no entry for this association is a very
|
|
38
|
+
// different thing from a malformed one.
|
|
39
|
+
const classifyAssociationExpiration = (member, siteAssociation) => {
|
|
40
|
+
if (!siteAssociation) {
|
|
41
|
+
return { date: null, outcome: EXPIRATION_OUTCOMES.NO_SITE_ASSOCIATION };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const memberships = Array.isArray(member?.memberships) ? member.memberships : [];
|
|
45
|
+
const membership = memberships.find(entry => entry?.association === siteAssociation);
|
|
46
|
+
|
|
47
|
+
if (!membership) {
|
|
48
|
+
return { date: null, outcome: EXPIRATION_OUTCOMES.NO_MEMBERSHIP_FOR_ASSOCIATION };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const raw = membership.expiration;
|
|
52
|
+
if (raw === null || raw === undefined || (typeof raw === 'string' && !raw.trim())) {
|
|
53
|
+
return { date: null, outcome: EXPIRATION_OUTCOMES.MISSING_EXPIRATION };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const date = parseExpirationToUtcDate(raw);
|
|
57
|
+
|
|
58
|
+
return date
|
|
59
|
+
? { date, outcome: EXPIRATION_OUTCOMES.RESOLVED }
|
|
60
|
+
: { date: null, outcome: EXPIRATION_OUTCOMES.UNREADABLE_EXPIRATION };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const resolveAssociationExpiration = (member, siteAssociation) =>
|
|
64
|
+
classifyAssociationExpiration(member, siteAssociation).date;
|
|
65
|
+
|
|
66
|
+
const summarizeExpirationOutcomes = (members = [], siteAssociation) => {
|
|
67
|
+
const counts = Object.values(EXPIRATION_OUTCOMES).reduce(
|
|
68
|
+
(acc, outcome) => ({ ...acc, [outcome]: 0 }),
|
|
69
|
+
{}
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
(Array.isArray(members) ? members : []).forEach(member => {
|
|
73
|
+
counts[classifyAssociationExpiration(member, siteAssociation).outcome] += 1;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return counts;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Keeps the backfill idempotent. Accepts a Date or the ISO string the CMS may return. */
|
|
80
|
+
const memberNeedsAssociationExpirationBackfill = (member, siteAssociation) => {
|
|
81
|
+
const resolved = resolveAssociationExpiration(member, siteAssociation);
|
|
82
|
+
const stored = member?.[ASSOCIATION_EXPIRATION_FIELD];
|
|
83
|
+
|
|
84
|
+
const storedTime =
|
|
85
|
+
stored instanceof Date ? stored.getTime() : stored ? new Date(stored).getTime() : null;
|
|
86
|
+
const resolvedTime = resolved ? resolved.getTime() : null;
|
|
87
|
+
|
|
88
|
+
if (storedTime === null && resolvedTime === null) return false;
|
|
89
|
+
if (storedTime === null || resolvedTime === null) return true;
|
|
90
|
+
|
|
91
|
+
return storedTime !== resolvedTime;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Today in Denver, where PAC operates. UTC rolls over first, so a UTC-derived today would hide
|
|
95
|
+
// everyone expiring that day up to seven hours early, every evening.
|
|
96
|
+
const getTodayInAssociationTimeZone = (now = new Date()) => {
|
|
97
|
+
try {
|
|
98
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
99
|
+
timeZone: ASSOCIATION_TIME_ZONE,
|
|
100
|
+
year: 'numeric',
|
|
101
|
+
month: '2-digit',
|
|
102
|
+
day: '2-digit',
|
|
103
|
+
}).formatToParts(now);
|
|
104
|
+
|
|
105
|
+
const valueOf = type => Number(parts.find(part => part.type === type)?.value);
|
|
106
|
+
const [year, month, day] = [valueOf('year'), valueOf('month'), valueOf('day')];
|
|
107
|
+
|
|
108
|
+
if (![year, month, day].every(Number.isFinite)) {
|
|
109
|
+
throw new Error('incomplete date parts');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return new Date(Date.UTC(year, month - 1, day));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// Hiding people a few hours early beats throwing on every search.
|
|
115
|
+
console.error(
|
|
116
|
+
`[associationExpiry] cannot resolve ${ASSOCIATION_TIME_ZONE}, using the UTC date instead. Members may be hidden up to 7 hours early. ${error.message}`
|
|
117
|
+
);
|
|
118
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Mirrors the directory query's `.ge()` for a record already in hand, so search, the profile page
|
|
123
|
+
// and the members area cannot disagree. Missing or unreadable is not current, as in the query.
|
|
124
|
+
const isAssociationExpirationCurrent = (member, now) => {
|
|
125
|
+
const stored = member?.[ASSOCIATION_EXPIRATION_FIELD];
|
|
126
|
+
const expiration = stored instanceof Date ? stored : stored ? new Date(stored) : null;
|
|
127
|
+
|
|
128
|
+
if (!expiration || Number.isNaN(expiration.getTime())) return false;
|
|
129
|
+
|
|
130
|
+
return expiration.getTime() >= getTodayInAssociationTimeZone(now).getTime();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
parseExpirationToUtcDate,
|
|
135
|
+
classifyAssociationExpiration,
|
|
136
|
+
resolveAssociationExpiration,
|
|
137
|
+
summarizeExpirationOutcomes,
|
|
138
|
+
memberNeedsAssociationExpirationBackfill,
|
|
139
|
+
getTodayInAssociationTimeZone,
|
|
140
|
+
isAssociationExpirationCurrent,
|
|
141
|
+
ASSOCIATION_EXPIRATION_FIELD,
|
|
142
|
+
ASSOCIATION_TIME_ZONE,
|
|
143
|
+
EXPIRATION_OUTCOMES,
|
|
144
|
+
};
|
|
@@ -4,6 +4,10 @@ const { COLLECTIONS, MEMBERS_FIELDS } = require('../public/consts.js');
|
|
|
4
4
|
const { findMainAddress } = require('../public/Utils/sharedUtils.js');
|
|
5
5
|
const { calculateDistance, shuffleArray } = require('../public/Utils/sharedUtils.js');
|
|
6
6
|
|
|
7
|
+
const {
|
|
8
|
+
getTodayInAssociationTimeZone,
|
|
9
|
+
ASSOCIATION_EXPIRATION_FIELD,
|
|
10
|
+
} = require('./association-expiry');
|
|
7
11
|
const {
|
|
8
12
|
GEO_HASH_PRECISION,
|
|
9
13
|
MAX__MEMBERS_SEARCH_RESULTS,
|
|
@@ -34,6 +38,11 @@ function buildMembersSearchQuery(data) {
|
|
|
34
38
|
.ne('action', 'drop')
|
|
35
39
|
.ne('memberships.membertype', MEMBERSHIPS_TYPES.PAC_STAFF)
|
|
36
40
|
.eq('isVisible', true);
|
|
41
|
+
|
|
42
|
+
// Filter in the query, not after it: count() and skip() in run() then page over the
|
|
43
|
+
// filtered set. Do not publish this to a site whose backfill has not run - members with no
|
|
44
|
+
// date are excluded, and before the backfill that is all of them.
|
|
45
|
+
query = query.ge(ASSOCIATION_EXPIRATION_FIELD, getTodayInAssociationTimeZone());
|
|
37
46
|
let filterConfig = [
|
|
38
47
|
{
|
|
39
48
|
filterKey: 'practiceAreas',
|
package/backend/consts.js
CHANGED
|
@@ -43,6 +43,12 @@ const LOGIN_EMAIL_SYNC_STATUS = {
|
|
|
43
43
|
SKIPPED: 'skipped', // member has no wixMemberId, nothing to change
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
// Thrown by the data layer, recognised by the login layer, which turns it into the ordinary error
|
|
47
|
+
// response rather than a 500.
|
|
48
|
+
const LOGIN_REFUSAL_REASONS = {
|
|
49
|
+
ASSOCIATION_MEMBERSHIP_EXPIRED: 'ASSOCIATION_MEMBERSHIP_EXPIRED',
|
|
50
|
+
};
|
|
51
|
+
|
|
46
52
|
module.exports = {
|
|
47
53
|
CONFIG_KEYS,
|
|
48
54
|
MAX__MEMBERS_SEARCH_RESULTS,
|
|
@@ -55,4 +61,5 @@ module.exports = {
|
|
|
55
61
|
SSO_TOKEN_AUTH_API_URL,
|
|
56
62
|
BACKUP_API_URL,
|
|
57
63
|
LOGIN_EMAIL_SYNC_STATUS,
|
|
64
|
+
LOGIN_REFUSAL_REASONS,
|
|
58
65
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { ADDRESS_STATUS_TYPES } = require('../../public/consts');
|
|
2
|
+
const { ASSOCIATION_EXPIRATION_FIELD } = require('../association-expiry');
|
|
2
3
|
const { findMemberById, getMemberBySlug } = require('../members-data-methods');
|
|
3
4
|
const { isValidArray, generateGeoHash } = require('../utils');
|
|
4
5
|
|
|
@@ -198,6 +199,9 @@ async function createCoreMemberData(inputMemberData, existingDbMember, currentPa
|
|
|
198
199
|
memberships: inputMemberData.memberships,
|
|
199
200
|
pageNumber: currentPageNumber,
|
|
200
201
|
isVisible: inputMemberData.action !== MEMBER_ACTIONS.DROP,
|
|
202
|
+
// Belongs here, not in getNewMemberOnlyFields: that returns {} for existing members, so the
|
|
203
|
+
// date would never refresh on renewal and a member who paid would stay hidden. null = hidden.
|
|
204
|
+
[ASSOCIATION_EXPIRATION_FIELD]: inputMemberData[ASSOCIATION_EXPIRATION_FIELD] ?? null,
|
|
201
205
|
|
|
202
206
|
// Handle Member emails
|
|
203
207
|
...getMemberEmails(),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
const { taskManager } = require('psdev-task-manager');
|
|
2
2
|
|
|
3
|
+
const {
|
|
4
|
+
resolveAssociationExpiration,
|
|
5
|
+
ASSOCIATION_EXPIRATION_FIELD,
|
|
6
|
+
} = require('../association-expiry');
|
|
3
7
|
const { CONFIG_KEYS } = require('../consts');
|
|
4
8
|
const { fetchPACMembers } = require('../pac-api-methods');
|
|
5
9
|
const { TASKS_NAMES } = require('../tasks/consts');
|
|
@@ -126,9 +130,12 @@ async function synchronizeSinglePage(taskObject) {
|
|
|
126
130
|
}
|
|
127
131
|
return isUpdatedMember(member);
|
|
128
132
|
});
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
// Narrow each member to this site's association: licenses filtered to it, and its expiration
|
|
134
|
+
// lifted out of the memberships array into a scalar the directory query can filter on.
|
|
135
|
+
const toSyncMembersWithFilteredLicenses = toSyncMembers.map(member => ({
|
|
136
|
+
...filterLicensesByAssociation(member, siteAssociation),
|
|
137
|
+
[ASSOCIATION_EXPIRATION_FIELD]: resolveAssociationExpiration(member, siteAssociation),
|
|
138
|
+
}));
|
|
132
139
|
if (toSyncMembers.length === 0) {
|
|
133
140
|
return {
|
|
134
141
|
success: true,
|
package/backend/jobs.js
CHANGED
|
@@ -133,6 +133,27 @@ async function runDailyPullExecutionCheck(options = {}) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* One-off backfill of associationExpiration. Run with `{ dryRun: true }` first: it counts the
|
|
138
|
+
* members that resolve to no date, and would therefore be hidden, without writing anything.
|
|
139
|
+
* @param {Object} [options]
|
|
140
|
+
* @param {boolean} [options.dryRun]
|
|
141
|
+
*/
|
|
142
|
+
async function scheduleAssociationExpiryBackfillTask(options = {}) {
|
|
143
|
+
try {
|
|
144
|
+
const { dryRun = false } = options || {};
|
|
145
|
+
console.log(`scheduleAssociationExpiryBackfill started! dryRun=${dryRun}`);
|
|
146
|
+
return await taskManager().schedule({
|
|
147
|
+
name: TASKS_NAMES.scheduleAssociationExpiryBackfill,
|
|
148
|
+
data: { dryRun },
|
|
149
|
+
type: 'scheduled',
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error(`Failed to scheduleAssociationExpiryBackfill: ${error.message}`);
|
|
153
|
+
throw new Error(`Failed to scheduleAssociationExpiryBackfill: ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
136
157
|
async function updateSiteMapS3() {
|
|
137
158
|
try {
|
|
138
159
|
return await taskManager().schedule({
|
|
@@ -154,5 +175,6 @@ module.exports = {
|
|
|
154
175
|
scheduleFixUrlsWithSpacesTask,
|
|
155
176
|
scheduleNormalizeMemberEmailsTask,
|
|
156
177
|
scheduleSetAddressesToCityStateTask,
|
|
178
|
+
scheduleAssociationExpiryBackfillTask,
|
|
157
179
|
runDailyPullExecutionCheck,
|
|
158
180
|
};
|
|
@@ -3,7 +3,8 @@ const { createHmac } = require('crypto');
|
|
|
3
3
|
const axios = require('axios');
|
|
4
4
|
const { decode } = require('jwt-js-decode');
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const { isAssociationExpirationCurrent } = require('../association-expiry');
|
|
7
|
+
const { CONFIG_KEYS, SSO_TOKEN_AUTH_API_URL, LOGIN_REFUSAL_REASONS } = require('../consts');
|
|
7
8
|
const { MEMBER_ACTIONS } = require('../daily-pull/consts');
|
|
8
9
|
const { getCurrentMember } = require('../members-area-methods');
|
|
9
10
|
const { getCMSMemberByWixMemberId, prepareMemberForSSOLogin } = require('../members-data-methods');
|
|
@@ -79,6 +80,15 @@ async function validateMemberToken(memberIdInput) {
|
|
|
79
80
|
return invalidTokenResponse;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// Ends an already-open session once the association lapses. Without this, a member logged in
|
|
84
|
+
// before their expiry date keeps editing a listing the directory no longer shows.
|
|
85
|
+
if (!isAssociationExpirationCurrent(memberData)) {
|
|
86
|
+
console.log(
|
|
87
|
+
`[validateMemberToken] association membership expired for memberId ${memberData.memberId}`
|
|
88
|
+
);
|
|
89
|
+
return invalidTokenResponse;
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
// Add computed properties
|
|
83
93
|
memberData.addressDisplayOption = getAddressDisplayOptions(memberData);
|
|
84
94
|
console.log('memberData', memberData);
|
|
@@ -134,7 +144,14 @@ const authenticateSSOToken = async ({ token }) => {
|
|
|
134
144
|
if (isValidToken) {
|
|
135
145
|
const jwt = decode(responseToken);
|
|
136
146
|
const payload = jwt.payload;
|
|
137
|
-
|
|
147
|
+
let memberData;
|
|
148
|
+
try {
|
|
149
|
+
memberData = await prepareMemberForSSOLogin(payload);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error.message !== LOGIN_REFUSAL_REASONS.ASSOCIATION_MEMBERSHIP_EXPIRED) throw error;
|
|
152
|
+
console.log('[authenticateSSOToken] refusing login, association membership expired');
|
|
153
|
+
return { type: 'error', memberId: '', sessionToken: '' };
|
|
154
|
+
}
|
|
138
155
|
console.log('memberDataCollectionId', memberData._id);
|
|
139
156
|
const sessionToken = await generateMemberSessionToken(memberData.email);
|
|
140
157
|
const authObj = {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const { COLLECTIONS } = require('../public/consts');
|
|
2
2
|
const { isWixHostedImage, emailsMatch, normalizeEmail } = require('../public/Utils/sharedUtils');
|
|
3
3
|
|
|
4
|
-
const {
|
|
4
|
+
const { isAssociationExpirationCurrent } = require('./association-expiry');
|
|
5
|
+
const { MEMBERSHIPS_TYPES, LOGIN_REFUSAL_REASONS } = require('./consts');
|
|
5
6
|
const { createSiteContact } = require('./contacts-methods');
|
|
6
7
|
const { MEMBER_ACTIONS } = require('./daily-pull/consts');
|
|
7
8
|
const { wixData } = require('./elevated-modules');
|
|
@@ -560,6 +561,22 @@ const getAllMembersWithoutContactFormEmail = async () => {
|
|
|
560
561
|
}
|
|
561
562
|
};
|
|
562
563
|
|
|
564
|
+
/**
|
|
565
|
+
* Every member, unfiltered. Filtering would hide the very records the expiry backfill's report
|
|
566
|
+
* exists to surface - the ones with no readable expiration.
|
|
567
|
+
* @returns {Promise<Array>} - Array of member data
|
|
568
|
+
*/
|
|
569
|
+
const getAllMembers = async () => {
|
|
570
|
+
try {
|
|
571
|
+
const membersQuery = wixData.query(COLLECTIONS.MEMBERS_DATA).limit(1000);
|
|
572
|
+
|
|
573
|
+
return await queryAllItems(membersQuery);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
console.error('Error getting all members:', error);
|
|
576
|
+
throw new Error(`Failed to get all members: ${error.message}`);
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
|
|
563
580
|
/**
|
|
564
581
|
* Gets all members whose email or contactFormEmail is stored with non-canonical casing
|
|
565
582
|
* (or surrounding whitespace) and therefore needs the normalization backfill.
|
|
@@ -680,6 +697,14 @@ async function prepareMemberForSSOLogin(data) {
|
|
|
680
697
|
if (!memberData) {
|
|
681
698
|
throw new Error(`Member data not found for memberId ${memberId}`);
|
|
682
699
|
}
|
|
700
|
+
// Before ensureWixMemberAndContactExist, which creates the Wix member and contact when they are
|
|
701
|
+
// missing - refusing later would still provision an account for someone who cannot use it.
|
|
702
|
+
if (!isAssociationExpirationCurrent(memberData)) {
|
|
703
|
+
console.log(
|
|
704
|
+
`[prepareMemberForSSOLogin] refusing login, association membership expired for memberId ${memberId}`
|
|
705
|
+
);
|
|
706
|
+
throw new Error(LOGIN_REFUSAL_REASONS.ASSOCIATION_MEMBERSHIP_EXPIRED);
|
|
707
|
+
}
|
|
683
708
|
console.log('memberData', memberData);
|
|
684
709
|
return await ensureWixMemberAndContactExist(memberData);
|
|
685
710
|
} catch (error) {
|
|
@@ -775,6 +800,7 @@ module.exports = {
|
|
|
775
800
|
getAllMembersWithExternalImages,
|
|
776
801
|
getMembersWithWixUrl,
|
|
777
802
|
getAllMembersWithoutContactFormEmail,
|
|
803
|
+
getAllMembers,
|
|
778
804
|
getAllMembersNeedingEmailNormalization,
|
|
779
805
|
memberNeedsEmailNormalization,
|
|
780
806
|
getAllUpdatedLoginEmails,
|
package/backend/routers/utils.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { getMainAddress } = require('../../public/Utils/sharedUtils');
|
|
2
|
+
const { isAssociationExpirationCurrent } = require('../association-expiry');
|
|
2
3
|
const { getMemberBySlug } = require('../members-data-methods');
|
|
3
4
|
const {
|
|
4
5
|
getMoreAddressesToDisplay,
|
|
@@ -113,6 +114,13 @@ const getMemberProfileData = async (slug, siteAssociation) => {
|
|
|
113
114
|
return null;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
// Gated here rather than in getMemberBySlug: that also backs URL uniqueness during the sync,
|
|
118
|
+
// where an expired member's slug must still count as taken or a new member could claim it.
|
|
119
|
+
if (!isAssociationExpirationCurrent(member)) {
|
|
120
|
+
console.log(`[getMemberProfileData] Association membership expired for slug: ${slug}`);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
116
124
|
return transformMemberToProfileData(member, siteAssociation);
|
|
117
125
|
} catch (error) {
|
|
118
126
|
const errorMessage = `Error in getMemberProfileData for slug: ${slug} : ${error.message}`;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
const { taskManager } = require('psdev-task-manager');
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
memberNeedsAssociationExpirationBackfill,
|
|
5
|
+
resolveAssociationExpiration,
|
|
6
|
+
summarizeExpirationOutcomes,
|
|
7
|
+
ASSOCIATION_EXPIRATION_FIELD,
|
|
8
|
+
EXPIRATION_OUTCOMES,
|
|
9
|
+
} = require('../association-expiry');
|
|
10
|
+
const { CONFIG_KEYS } = require('../consts');
|
|
11
|
+
const { bulkSaveMembers, getMembersByIds, getAllMembers } = require('../members-data-methods');
|
|
12
|
+
const { chunkArray, getSiteConfigs } = require('../utils');
|
|
13
|
+
|
|
14
|
+
const { TASKS_NAMES } = require('./consts');
|
|
15
|
+
|
|
16
|
+
const CHUNK_SIZE = 1000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One-off backfill of associationExpiration for members the daily sync will not touch.
|
|
20
|
+
* @param {Object} [data]
|
|
21
|
+
* @param {boolean} [data.dryRun] count without writing
|
|
22
|
+
*/
|
|
23
|
+
async function scheduleAssociationExpiryBackfill(data = {}) {
|
|
24
|
+
// process() receives whatever getIdentifier returns. A sentinel string there would read as
|
|
25
|
+
// dryRun: false and write to every member instead of counting them.
|
|
26
|
+
if (data === null || typeof data !== 'object') {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`scheduleAssociationExpiryBackfill expected its task data object but received ${typeof data}. ` +
|
|
29
|
+
'Check getIdentifier for this task in tasks-configs.js: it must be `task => task.data`.'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const dryRun = data.dryRun === true;
|
|
34
|
+
console.log(`=== Scheduling Association Expiry Backfill${dryRun ? ' (DRY RUN)' : ''} ===`);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const siteAssociation = await getSiteConfigs(CONFIG_KEYS.SITE_ASSOCIATION);
|
|
38
|
+
if (!siteAssociation) {
|
|
39
|
+
// Every member would resolve to null, i.e. hidden.
|
|
40
|
+
throw new Error('SITE_ASSOCIATION is not configured; refusing to run the backfill');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const members = await getAllMembers();
|
|
44
|
+
console.log(`Fetched ${members.length} members for association '${siteAssociation}'`);
|
|
45
|
+
|
|
46
|
+
// Over every member, not just those needing a write, so a re-run still reports the true total.
|
|
47
|
+
const outcomes = summarizeExpirationOutcomes(members, siteAssociation);
|
|
48
|
+
const hiddenByThisChange =
|
|
49
|
+
outcomes[EXPIRATION_OUTCOMES.NO_MEMBERSHIP_FOR_ASSOCIATION] +
|
|
50
|
+
outcomes[EXPIRATION_OUTCOMES.MISSING_EXPIRATION] +
|
|
51
|
+
outcomes[EXPIRATION_OUTCOMES.UNREADABLE_EXPIRATION];
|
|
52
|
+
|
|
53
|
+
console.log(`Outcome breakdown: ${JSON.stringify(outcomes)}`);
|
|
54
|
+
console.log(
|
|
55
|
+
`Will resolve to no date, so hidden once the query gates on it: ${hiddenByThisChange} of ${members.length}`
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const memberIds = [
|
|
59
|
+
...new Set(
|
|
60
|
+
members
|
|
61
|
+
.filter(member => memberNeedsAssociationExpirationBackfill(member, siteAssociation))
|
|
62
|
+
.map(member => Number(member.memberId))
|
|
63
|
+
.filter(memberId => Number.isFinite(memberId) && memberId > 0)
|
|
64
|
+
),
|
|
65
|
+
];
|
|
66
|
+
console.log(`Members whose stored value is out of date: ${memberIds.length}`);
|
|
67
|
+
|
|
68
|
+
const summary = {
|
|
69
|
+
success: true,
|
|
70
|
+
dryRun,
|
|
71
|
+
siteAssociation,
|
|
72
|
+
totalMembers: members.length,
|
|
73
|
+
outcomes,
|
|
74
|
+
hiddenByThisChange,
|
|
75
|
+
membersNeedingUpdate: memberIds.length,
|
|
76
|
+
tasksScheduled: 0,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (dryRun) {
|
|
80
|
+
summary.message = `Dry run: nothing written. ${hiddenByThisChange} of ${members.length} members resolve to no date`;
|
|
81
|
+
console.log('=== Dry Run Complete, nothing written ===');
|
|
82
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
83
|
+
return summary;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (memberIds.length === 0) {
|
|
87
|
+
summary.message = 'Every member already has the correct associationExpiration';
|
|
88
|
+
console.log(summary.message);
|
|
89
|
+
return summary;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const chunks = chunkArray(memberIds, CHUNK_SIZE);
|
|
93
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
94
|
+
await taskManager().schedule({
|
|
95
|
+
name: TASKS_NAMES.associationExpiryBackfillChunk,
|
|
96
|
+
data: { memberIds: chunks[i], chunkIndex: i, totalChunks: chunks.length },
|
|
97
|
+
type: 'scheduled',
|
|
98
|
+
});
|
|
99
|
+
console.log(`Scheduled task ${i + 1}/${chunks.length} (${chunks[i].length} members)`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
summary.tasksScheduled = chunks.length;
|
|
103
|
+
summary.message = `Scheduled ${chunks.length} tasks for ${memberIds.length} members`;
|
|
104
|
+
|
|
105
|
+
console.log('=== Scheduling Complete ===');
|
|
106
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
107
|
+
|
|
108
|
+
return summary;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error('Error scheduling association expiry backfill:', error);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Members are reloaded and re-resolved rather than trusting the queued data: a chunk can run long
|
|
117
|
+
* after it was scheduled, and the daily sync may have rewritten the record in between.
|
|
118
|
+
*/
|
|
119
|
+
async function associationExpiryBackfillChunk(data) {
|
|
120
|
+
const { memberIds, chunkIndex, totalChunks } = data;
|
|
121
|
+
console.log(
|
|
122
|
+
`Processing association expiry chunk ${chunkIndex + 1}/${totalChunks} (${memberIds.length} members)`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const result = {
|
|
126
|
+
successful: 0,
|
|
127
|
+
failed: 0,
|
|
128
|
+
skipped: 0,
|
|
129
|
+
errors: [],
|
|
130
|
+
failedIds: [],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const siteAssociation = await getSiteConfigs(CONFIG_KEYS.SITE_ASSOCIATION);
|
|
135
|
+
if (!siteAssociation) {
|
|
136
|
+
throw new Error('SITE_ASSOCIATION is not configured; refusing to write');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const members = await getMembersByIds(memberIds);
|
|
140
|
+
console.log(`Loaded ${members.length} members for this chunk`);
|
|
141
|
+
|
|
142
|
+
const membersToUpdate = members
|
|
143
|
+
.filter(member => memberNeedsAssociationExpirationBackfill(member, siteAssociation))
|
|
144
|
+
.map(member => ({
|
|
145
|
+
...member,
|
|
146
|
+
[ASSOCIATION_EXPIRATION_FIELD]: resolveAssociationExpiration(member, siteAssociation),
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
result.skipped = members.length - membersToUpdate.length;
|
|
150
|
+
result.outcomes = summarizeExpirationOutcomes(members, siteAssociation);
|
|
151
|
+
|
|
152
|
+
if (membersToUpdate.length === 0) {
|
|
153
|
+
console.log('No members need updating in this batch');
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await bulkSaveMembers(membersToUpdate);
|
|
159
|
+
result.successful += membersToUpdate.length;
|
|
160
|
+
console.log(`✅ Successfully backfilled ${membersToUpdate.length} members`);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error('❌ Error bulk saving members:', error);
|
|
163
|
+
result.failed += membersToUpdate.length;
|
|
164
|
+
result.failedIds.push(...membersToUpdate.map(member => member.memberId));
|
|
165
|
+
result.errors.push({ error: error.message, memberCount: membersToUpdate.length });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return result;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error(`Error processing association expiry chunk ${chunkIndex}:`, error);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
scheduleAssociationExpiryBackfill,
|
|
177
|
+
associationExpiryBackfillChunk,
|
|
178
|
+
};
|
package/backend/tasks/consts.js
CHANGED
|
@@ -27,6 +27,8 @@ const TASKS_NAMES = {
|
|
|
27
27
|
scheduleNormalizeMemberEmails: 'scheduleNormalizeMemberEmails',
|
|
28
28
|
normalizeMemberEmailsChunk: 'normalizeMemberEmailsChunk',
|
|
29
29
|
dailyPullExecutionCheck: 'dailyPullExecutionCheck',
|
|
30
|
+
scheduleAssociationExpiryBackfill: 'scheduleAssociationExpiryBackfill',
|
|
31
|
+
associationExpiryBackfillChunk: 'associationExpiryBackfillChunk',
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
module.exports = {
|
package/backend/tasks/index.js
CHANGED
|
@@ -12,6 +12,10 @@ const {
|
|
|
12
12
|
scheduleSetAddressesToCityState,
|
|
13
13
|
setAddressesToCityStateChunk,
|
|
14
14
|
} = require('./address-visibility-methods');
|
|
15
|
+
const {
|
|
16
|
+
scheduleAssociationExpiryBackfill,
|
|
17
|
+
associationExpiryBackfillChunk,
|
|
18
|
+
} = require('./association-expiry-backfill-methods');
|
|
15
19
|
const { TASKS_NAMES } = require('./consts');
|
|
16
20
|
const { dailyPullExecutionCheck } = require('./daily-pull-check-methods');
|
|
17
21
|
const {
|
|
@@ -239,6 +243,23 @@ const TASKS = {
|
|
|
239
243
|
shouldSkipCheck: () => false,
|
|
240
244
|
estimatedDurationSec: 80,
|
|
241
245
|
},
|
|
246
|
+
[TASKS_NAMES.scheduleAssociationExpiryBackfill]: {
|
|
247
|
+
name: TASKS_NAMES.scheduleAssociationExpiryBackfill,
|
|
248
|
+
// Must pass task.data through - process() receives this, and the backfill needs its dryRun.
|
|
249
|
+
getIdentifier: task => task.data,
|
|
250
|
+
process: scheduleAssociationExpiryBackfill,
|
|
251
|
+
shouldSkipCheck: () => false,
|
|
252
|
+
estimatedDurationSec: 120,
|
|
253
|
+
},
|
|
254
|
+
[TASKS_NAMES.associationExpiryBackfillChunk]: {
|
|
255
|
+
name: TASKS_NAMES.associationExpiryBackfillChunk,
|
|
256
|
+
getIdentifier: task => task.data,
|
|
257
|
+
process: associationExpiryBackfillChunk,
|
|
258
|
+
shouldSkipCheck: () => false,
|
|
259
|
+
// A packing budget, not a timeout: the manager fills each 240s tick with tasks costing
|
|
260
|
+
// estimate x 1.5. Chunks measured at ~6.5s, so 10 gives 16 per tick with room to spare.
|
|
261
|
+
estimatedDurationSec: 10,
|
|
262
|
+
},
|
|
242
263
|
[TASKS_NAMES.dailyPullExecutionCheck]: {
|
|
243
264
|
name: TASKS_NAMES.dailyPullExecutionCheck,
|
|
244
265
|
getIdentifier: task => task.data,
|
package/package.json
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abmp-npm",
|
|
3
|
-
"version": "10.3.
|
|
3
|
+
"version": "10.3.17",
|
|
4
4
|
"main": "index.js",
|
|
5
|
+
"files": [
|
|
6
|
+
"index.js",
|
|
7
|
+
"backend",
|
|
8
|
+
"pages",
|
|
9
|
+
"public",
|
|
10
|
+
"!backend/__tests__"
|
|
11
|
+
],
|
|
5
12
|
"scripts": {
|
|
6
13
|
"check-cycles": "madge --circular .",
|
|
7
14
|
"test": "jest backend/__tests__",
|
package/pages/Home.js
CHANGED
|
@@ -3,14 +3,14 @@ const { location: wixLocation } = require('@wix/site-location');
|
|
|
3
3
|
const { window: wixWindow, rendering } = require('@wix/site-window');
|
|
4
4
|
const { withWarmUpData } = require('psdev-utils/frontend');
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const { DEFAULT_FILTER, DROPDOWN_OPTIONS } = require('../public/consts.js');
|
|
7
7
|
const { createHomepageUtils } = require('../public/Utils/homePage.js');
|
|
8
8
|
const {
|
|
9
9
|
getMainAddress,
|
|
10
10
|
formatPracticeAreasForDisplay,
|
|
11
|
-
checkAddressIsVisible,
|
|
12
11
|
isWixHostedImage,
|
|
13
12
|
normalizeExternalUrl,
|
|
13
|
+
buildDirectionsLink,
|
|
14
14
|
} = require('../public/Utils/sharedUtils.js');
|
|
15
15
|
|
|
16
16
|
let filter = JSON.parse(JSON.stringify(DEFAULT_FILTER));
|
|
@@ -225,20 +225,18 @@ const homePageOnReady = async ({
|
|
|
225
225
|
$item('#milesAwayText').text = '';
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
// 7) "Show maps" button
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
);
|
|
228
|
+
// 7) "Show maps" button - only for members who selected "Show Full Address".
|
|
229
|
+
//
|
|
230
|
+
// buildDirectionsLink owns the rule and returns '' when the button should be
|
|
231
|
+
// hidden: city/state/ZIP and hidden members get nothing, and a full-address
|
|
232
|
+
// member gets a link built from their address text rather than their NetForum
|
|
233
|
+
// coordinates, which are unreliable (Monday 12596102059).
|
|
234
|
+
const mapLink = buildDirectionsLink(itemData.addressDisplayOption, addresses);
|
|
236
235
|
|
|
237
|
-
if (
|
|
236
|
+
if (mapLink) {
|
|
238
237
|
$item('#showMaps').enable();
|
|
239
238
|
$item('#showMaps').show();
|
|
240
|
-
|
|
241
|
-
$item('#showMaps').link = `https://maps.google.com/?q=${latitude},${longitude}`;
|
|
239
|
+
$item('#showMaps').link = mapLink;
|
|
242
240
|
$item('#showMaps').target = '_blank';
|
|
243
241
|
} else {
|
|
244
242
|
$item('#showMaps').hide();
|