abmp-npm 10.3.14 → 10.3.16
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 +140 -0
- package/backend/jobs.js +22 -0
- package/backend/members-data-methods.js +17 -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 +19 -0
- package/package.json +8 -1
- package/pages/Home.js +11 -13
- package/pages/Profile.js +6 -1
- package/pages/personalDetails.js +8 -3
- package/public/Utils/personalDetailsUtils.js +17 -2
- package/public/Utils/sharedUtils.js +73 -0
- 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/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,140 @@
|
|
|
1
|
+
// The per-association expiry rule. Shared by the daily sync, the backfill and the directory query
|
|
2
|
+
// so they cannot drift on what "expired" means. No 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
|
+
/**
|
|
18
|
+
* PAC sends expirations as zoneless ISO strings ("2027-06-12T00:00:00"). `new Date()` would read
|
|
19
|
+
* those as local time, so the same feed would mean different days depending on where it ran.
|
|
20
|
+
* @returns {Date|null} UTC midnight, or null if absent or unreadable
|
|
21
|
+
*/
|
|
22
|
+
const parseExpirationToUtcDate = expiration => {
|
|
23
|
+
if (typeof expiration !== 'string') return null;
|
|
24
|
+
|
|
25
|
+
const match = EXPIRATION_DATE_PATTERN.exec(expiration.trim());
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
|
|
28
|
+
const [, year, month, day] = match.map(Number);
|
|
29
|
+
const parsed = new Date(Date.UTC(year, month - 1, day));
|
|
30
|
+
|
|
31
|
+
// Date.UTC rolls 2026-02-31 forward to 3 March rather than rejecting it.
|
|
32
|
+
const isRealDate =
|
|
33
|
+
parsed.getUTCFullYear() === year &&
|
|
34
|
+
parsed.getUTCMonth() === month - 1 &&
|
|
35
|
+
parsed.getUTCDate() === day;
|
|
36
|
+
|
|
37
|
+
return isRealDate ? parsed : null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @returns {{ date: Date|null, outcome: string }} outcome explains a null date, for the backfill
|
|
42
|
+
* report: no entry for this association is a different thing from a malformed date.
|
|
43
|
+
*/
|
|
44
|
+
const classifyAssociationExpiration = (member, siteAssociation) => {
|
|
45
|
+
if (!siteAssociation) {
|
|
46
|
+
return { date: null, outcome: EXPIRATION_OUTCOMES.NO_SITE_ASSOCIATION };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const memberships = Array.isArray(member?.memberships) ? member.memberships : [];
|
|
50
|
+
const membership = memberships.find(entry => entry?.association === siteAssociation);
|
|
51
|
+
|
|
52
|
+
if (!membership) {
|
|
53
|
+
return { date: null, outcome: EXPIRATION_OUTCOMES.NO_MEMBERSHIP_FOR_ASSOCIATION };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const raw = membership.expiration;
|
|
57
|
+
if (raw === null || raw === undefined || (typeof raw === 'string' && !raw.trim())) {
|
|
58
|
+
return { date: null, outcome: EXPIRATION_OUTCOMES.MISSING_EXPIRATION };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const date = parseExpirationToUtcDate(raw);
|
|
62
|
+
|
|
63
|
+
return date
|
|
64
|
+
? { date, outcome: EXPIRATION_OUTCOMES.RESOLVED }
|
|
65
|
+
: { date: null, outcome: EXPIRATION_OUTCOMES.UNREADABLE_EXPIRATION };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const resolveAssociationExpiration = (member, siteAssociation) =>
|
|
69
|
+
classifyAssociationExpiration(member, siteAssociation).date;
|
|
70
|
+
|
|
71
|
+
const summarizeExpirationOutcomes = (members = [], siteAssociation) => {
|
|
72
|
+
const counts = Object.values(EXPIRATION_OUTCOMES).reduce(
|
|
73
|
+
(acc, outcome) => ({ ...acc, [outcome]: 0 }),
|
|
74
|
+
{}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
(Array.isArray(members) ? members : []).forEach(member => {
|
|
78
|
+
counts[classifyAssociationExpiration(member, siteAssociation).outcome] += 1;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return counts;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Keeps the backfill idempotent. Accepts a Date or the ISO string the CMS may return. */
|
|
85
|
+
const memberNeedsAssociationExpirationBackfill = (member, siteAssociation) => {
|
|
86
|
+
const resolved = resolveAssociationExpiration(member, siteAssociation);
|
|
87
|
+
const stored = member?.[ASSOCIATION_EXPIRATION_FIELD];
|
|
88
|
+
|
|
89
|
+
const storedTime =
|
|
90
|
+
stored instanceof Date ? stored.getTime() : stored ? new Date(stored).getTime() : null;
|
|
91
|
+
const resolvedTime = resolved ? resolved.getTime() : null;
|
|
92
|
+
|
|
93
|
+
if (storedTime === null && resolvedTime === null) return false;
|
|
94
|
+
if (storedTime === null || resolvedTime === null) return true;
|
|
95
|
+
|
|
96
|
+
return storedTime !== resolvedTime;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Today in Denver, where PAC operates. UTC rolls over first, so a UTC-derived today would hide
|
|
101
|
+
* everyone expiring that day up to seven hours early, every evening.
|
|
102
|
+
* @param {Date} [now] injectable for tests
|
|
103
|
+
*/
|
|
104
|
+
const getTodayInAssociationTimeZone = (now = new Date()) => {
|
|
105
|
+
try {
|
|
106
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
107
|
+
timeZone: ASSOCIATION_TIME_ZONE,
|
|
108
|
+
year: 'numeric',
|
|
109
|
+
month: '2-digit',
|
|
110
|
+
day: '2-digit',
|
|
111
|
+
}).formatToParts(now);
|
|
112
|
+
|
|
113
|
+
const valueOf = type => Number(parts.find(part => part.type === type)?.value);
|
|
114
|
+
const [year, month, day] = [valueOf('year'), valueOf('month'), valueOf('day')];
|
|
115
|
+
|
|
116
|
+
if (![year, month, day].every(Number.isFinite)) {
|
|
117
|
+
throw new Error('incomplete date parts');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return new Date(Date.UTC(year, month - 1, day));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// Hiding people a few hours early beats throwing on every search.
|
|
123
|
+
console.error(
|
|
124
|
+
`[associationExpiry] cannot resolve ${ASSOCIATION_TIME_ZONE}, using the UTC date instead. Members may be hidden up to 7 hours early. ${error.message}`
|
|
125
|
+
);
|
|
126
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
parseExpirationToUtcDate,
|
|
132
|
+
classifyAssociationExpiration,
|
|
133
|
+
resolveAssociationExpiration,
|
|
134
|
+
summarizeExpirationOutcomes,
|
|
135
|
+
memberNeedsAssociationExpirationBackfill,
|
|
136
|
+
getTodayInAssociationTimeZone,
|
|
137
|
+
ASSOCIATION_EXPIRATION_FIELD,
|
|
138
|
+
ASSOCIATION_TIME_ZONE,
|
|
139
|
+
EXPIRATION_OUTCOMES,
|
|
140
|
+
};
|
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 to get the count of
|
|
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
|
};
|
|
@@ -560,6 +560,22 @@ const getAllMembersWithoutContactFormEmail = async () => {
|
|
|
560
560
|
}
|
|
561
561
|
};
|
|
562
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Every member, unfiltered. The expiry backfill has to count members with no readable expiration,
|
|
565
|
+
* so filtering the query would hide the records its report exists to surface.
|
|
566
|
+
* @returns {Promise<Array>} - Array of member data
|
|
567
|
+
*/
|
|
568
|
+
const getAllMembers = async () => {
|
|
569
|
+
try {
|
|
570
|
+
const membersQuery = wixData.query(COLLECTIONS.MEMBERS_DATA).limit(1000);
|
|
571
|
+
|
|
572
|
+
return await queryAllItems(membersQuery);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
console.error('Error getting all members:', error);
|
|
575
|
+
throw new Error(`Failed to get all members: ${error.message}`);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
563
579
|
/**
|
|
564
580
|
* Gets all members whose email or contactFormEmail is stored with non-canonical casing
|
|
565
581
|
* (or surrounding whitespace) and therefore needs the normalization backfill.
|
|
@@ -775,6 +791,7 @@ module.exports = {
|
|
|
775
791
|
getAllMembersWithExternalImages,
|
|
776
792
|
getMembersWithWixUrl,
|
|
777
793
|
getAllMembersWithoutContactFormEmail,
|
|
794
|
+
getAllMembers,
|
|
778
795
|
getAllMembersNeedingEmailNormalization,
|
|
779
796
|
memberNeedsEmailNormalization,
|
|
780
797
|
getAllUpdatedLoginEmails,
|
|
@@ -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,21 @@ 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
|
+
estimatedDurationSec: 80,
|
|
260
|
+
},
|
|
242
261
|
[TASKS_NAMES.dailyPullExecutionCheck]: {
|
|
243
262
|
name: TASKS_NAMES.dailyPullExecutionCheck,
|
|
244
263
|
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.16",
|
|
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();
|
package/pages/Profile.js
CHANGED
|
@@ -6,6 +6,7 @@ const {
|
|
|
6
6
|
generateId,
|
|
7
7
|
formatPracticeAreasForDisplay,
|
|
8
8
|
isWixHostedImage,
|
|
9
|
+
normalizeExternalUrl,
|
|
9
10
|
} = require('../public/Utils/sharedUtils');
|
|
10
11
|
|
|
11
12
|
const TESTIMONIALS_PER_PAGE_CONFIG = {
|
|
@@ -159,7 +160,11 @@ async function profileOnReady({ $w: _$w }) {
|
|
|
159
160
|
|
|
160
161
|
function bindBookingUrl() {
|
|
161
162
|
if (profileData.bookingUrl) {
|
|
162
|
-
|
|
163
|
+
// Normalised for the same reason the directory does it (see Home.js): some
|
|
164
|
+
// stored booking URLs have no protocol, and a bare hostname would be treated
|
|
165
|
+
// as a path on this site rather than an external link, so the button would
|
|
166
|
+
// point at abmpmembers.com/<their-domain> and go nowhere.
|
|
167
|
+
_$w('#bookNowButton').link = normalizeExternalUrl(profileData.bookingUrl);
|
|
163
168
|
} else {
|
|
164
169
|
_$w('#bookNowButton').delete();
|
|
165
170
|
}
|
package/pages/personalDetails.js
CHANGED
|
@@ -1262,9 +1262,10 @@ async function personalDetailsOnReady({
|
|
|
1262
1262
|
console.groupEnd();
|
|
1263
1263
|
|
|
1264
1264
|
const result = await saveData(formData);
|
|
1265
|
-
formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.PERSONAL.section] = false;
|
|
1266
1265
|
|
|
1267
1266
|
if (result.success) {
|
|
1267
|
+
formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.PERSONAL.section] = false;
|
|
1268
|
+
|
|
1268
1269
|
if (personalChanges.url && personalChanges.url !== originalUrl) {
|
|
1269
1270
|
const newProfileLink = `${baseUrl}/profile/${personalChanges.url}`;
|
|
1270
1271
|
console.log('🔗 Updating profile link:', {
|
|
@@ -1317,7 +1318,10 @@ async function personalDetailsOnReady({
|
|
|
1317
1318
|
console.groupEnd();
|
|
1318
1319
|
|
|
1319
1320
|
const result = await saveData(formData);
|
|
1320
|
-
|
|
1321
|
+
if (result.success) {
|
|
1322
|
+
formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.BUSINESS_SERVICES.section] = false;
|
|
1323
|
+
_$w('#saveBusinessButton').disable();
|
|
1324
|
+
}
|
|
1321
1325
|
handleSaveDataFeedback(_$w('#businessMessage'), result.message);
|
|
1322
1326
|
_$w('#businessNameText').text = formData.businessName || DEFAULT_BUSINESS_NAME_TEXT;
|
|
1323
1327
|
}
|
|
@@ -2380,8 +2384,9 @@ async function personalDetailsOnReady({
|
|
|
2380
2384
|
// Sync Personal Details opt-in from saved member.
|
|
2381
2385
|
_$w('#optWebsiteCheckbox').checked = itemMemberObj.showWixUrl;
|
|
2382
2386
|
toggleFreeWebsiteText(itemMemberObj.showWixUrl);
|
|
2387
|
+
formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.CONTACT_BOOKING.section] = false;
|
|
2388
|
+
_$w('#saveContactBookingButton').disable();
|
|
2383
2389
|
}
|
|
2384
|
-
formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.CONTACT_BOOKING.section] = false;
|
|
2385
2390
|
handleSaveDataFeedback(_$w('#contactMessage'), result.message);
|
|
2386
2391
|
}
|
|
2387
2392
|
|
|
@@ -12,9 +12,24 @@ function isNotValidUrl(url) {
|
|
|
12
12
|
// Empty URLs are considered valid (optional field)
|
|
13
13
|
if (!url) return false;
|
|
14
14
|
|
|
15
|
-
//
|
|
15
|
+
// Handles all TLDs including multi-level, plus paths, query strings and anchors.
|
|
16
|
+
//
|
|
17
|
+
// The protocol is OPTIONAL. Members routinely type a bare hostname such as
|
|
18
|
+
// "patty-10439.square.site", which is what they see in their browser. Requiring
|
|
19
|
+
// http:// or www. rejected that and blocked the whole Business & Services save.
|
|
20
|
+
// getContactAndBookingData already runs the value through normalizeExternalUrl on
|
|
21
|
+
// save, so a bare hostname is stored with https:// prepended - accepting it here
|
|
22
|
+
// is what lets that happen.
|
|
23
|
+
//
|
|
24
|
+
// Dropping the explicit `www.` branch loses nothing: "www.example.com" still
|
|
25
|
+
// matches as host "www.example" plus TLD ".com".
|
|
26
|
+
//
|
|
27
|
+
// The host class is `[\da-z.-]` (digit, letter, dot, hyphen). It once read
|
|
28
|
+
// `[da-z.-]`, which - missing the backslash - matched only a literal "d" plus a-z,
|
|
29
|
+
// so any domain containing a digit was rejected. The `i` flag keeps mixed-case
|
|
30
|
+
// hosts valid; domains are case-insensitive.
|
|
16
31
|
const urlRegex =
|
|
17
|
-
/^(https
|
|
32
|
+
/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,})([/\w .-]*)*(\?[&\w=.-]*)?(#[&\w=.-]*)?\/?$/i;
|
|
18
33
|
|
|
19
34
|
return !urlRegex.test(url);
|
|
20
35
|
}
|
|
@@ -118,6 +118,77 @@ function formatAddress(item) {
|
|
|
118
118
|
return addressParts.filter(Boolean).join(', ');
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Builds the outbound map link for an address.
|
|
123
|
+
*
|
|
124
|
+
* Prefers the street address over the stored coordinates. NetForum's address
|
|
125
|
+
* verifier (Cdyne) sometimes returns coordinates that are miles away from the
|
|
126
|
+
* real address, and some coordinates were edited by hand in the past to change
|
|
127
|
+
* directory ranking, so the address text is the more reliable of the two.
|
|
128
|
+
* Distance ranking and the "XX miles away" figure keep using the coordinates -
|
|
129
|
+
* that is a separate calculation (see calculateDistance) and is unaffected.
|
|
130
|
+
*
|
|
131
|
+
* Falls back to coordinates only when the address cannot be formatted, so the
|
|
132
|
+
* button never links nowhere.
|
|
133
|
+
*
|
|
134
|
+
* @param {Object} address - a single address entry
|
|
135
|
+
* @returns {string} map URL, or '' when neither an address nor coordinates exist
|
|
136
|
+
*/
|
|
137
|
+
function buildMapLink(address) {
|
|
138
|
+
if (!address) return '';
|
|
139
|
+
|
|
140
|
+
// A member set to dont_show has opted out of publishing their location at all.
|
|
141
|
+
// formatAddress returns '' for them, so without this guard they would fall
|
|
142
|
+
// through to the coordinate fallback below and have their exact position put
|
|
143
|
+
// into an outbound maps URL - worse than the street line they chose to hide.
|
|
144
|
+
if (address.addressStatus === ADDRESS_STATUS_TYPES.DONT_SHOW) return '';
|
|
145
|
+
|
|
146
|
+
const query = formatAddress(address);
|
|
147
|
+
if (query) {
|
|
148
|
+
return `https://maps.google.com/?q=${encodeURIComponent(query)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (isValidLocation(address)) {
|
|
152
|
+
return `https://maps.google.com/?q=${address.latitude},${address.longitude}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Builds the directions-button link for a member, or '' when no button should show.
|
|
160
|
+
*
|
|
161
|
+
* PAC's rule (Monday 12596102059, confirmed by Richard Visser): directions appear
|
|
162
|
+
* only for members who selected "Show Full Address". Members displaying
|
|
163
|
+
* city/state/ZIP, and members who hid their address, get no button at all. An
|
|
164
|
+
* earlier pass relaxed this to city-level members and had to be reverted, so the
|
|
165
|
+
* rule lives here with tests around it rather than inline in the page code.
|
|
166
|
+
*
|
|
167
|
+
* The address is resolved with findMainAddress, so the button is always tied to
|
|
168
|
+
* the same address as the location text rendered beside it. A member whose
|
|
169
|
+
* displayed address is city-level gets no button even when a fuller address exists
|
|
170
|
+
* elsewhere on their record - the button must not point somewhere other than what
|
|
171
|
+
* is on screen.
|
|
172
|
+
*
|
|
173
|
+
* Coordinates are deliberately not required: buildMapLink navigates from the
|
|
174
|
+
* address text, so a member with missing or wrong NetForum coordinates still gets
|
|
175
|
+
* a working button where previously they got none. Distance ranking and the
|
|
176
|
+
* "XX miles away" figure keep using the coordinates and are unaffected.
|
|
177
|
+
*
|
|
178
|
+
* @param {Array} addressDisplayOption
|
|
179
|
+
* @param {Array} addresses
|
|
180
|
+
* @returns {string} map URL, or '' when the button should be hidden
|
|
181
|
+
*/
|
|
182
|
+
function buildDirectionsLink(addressDisplayOption = [], addresses = []) {
|
|
183
|
+
const address = findMainAddress(addressDisplayOption, addresses);
|
|
184
|
+
|
|
185
|
+
if (address?.addressStatus !== ADDRESS_STATUS_TYPES.FULL_ADDRESS) {
|
|
186
|
+
return '';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return buildMapLink(address);
|
|
190
|
+
}
|
|
191
|
+
|
|
121
192
|
/**
|
|
122
193
|
* @param {Array} addressDisplayOption
|
|
123
194
|
* @param {Array} addresses
|
|
@@ -228,6 +299,8 @@ module.exports = {
|
|
|
228
299
|
toRadians,
|
|
229
300
|
generateId,
|
|
230
301
|
formatAddress,
|
|
302
|
+
buildMapLink,
|
|
303
|
+
buildDirectionsLink,
|
|
231
304
|
isWixHostedImage,
|
|
232
305
|
normalizeExternalUrl,
|
|
233
306
|
normalizeEmail,
|