abmp-npm 10.3.20 → 10.3.21
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/cms-data-methods.js +192 -132
- package/backend/consts.js +1 -0
- package/backend/search-filters-methods.js +2 -3
- package/package.json +1 -1
|
@@ -9,12 +9,40 @@ const {
|
|
|
9
9
|
ASSOCIATION_EXPIRATION_FIELD,
|
|
10
10
|
} = require('./association-expiry');
|
|
11
11
|
const {
|
|
12
|
+
CONFIG_KEYS,
|
|
12
13
|
GEO_HASH_PRECISION,
|
|
13
14
|
MAX__MEMBERS_SEARCH_RESULTS,
|
|
14
15
|
WIX_QUERY_MAX_LIMIT,
|
|
15
16
|
MEMBERSHIPS_TYPES,
|
|
16
17
|
} = require('./consts.js');
|
|
17
18
|
const { wixData } = require('./elevated-modules');
|
|
19
|
+
const { MEMBER_UPDATED_FIELD } = require('./listing-priority');
|
|
20
|
+
const { getSiteConfigs } = require('./utils');
|
|
21
|
+
|
|
22
|
+
// PAC asked for updated listings within 25 miles to rank first, and for the distance to be
|
|
23
|
+
// adjustable as more members fill their listings in - hence the site config rather than a constant.
|
|
24
|
+
const DEFAULT_PRIORITY_RADIUS_MILES = 25;
|
|
25
|
+
|
|
26
|
+
const LISTING_TIERS = {
|
|
27
|
+
UPDATED: 'updated',
|
|
28
|
+
REST: 'rest',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Falls back to the default rather than throwing: a missing or malformed config value should
|
|
33
|
+
* change the ordering, never break the directory.
|
|
34
|
+
*/
|
|
35
|
+
const getPriorityRadiusMiles = async () => {
|
|
36
|
+
try {
|
|
37
|
+
const configured = Number(await getSiteConfigs(CONFIG_KEYS.LISTING_PRIORITY_RADIUS_MILES));
|
|
38
|
+
return Number.isFinite(configured) && configured > 0
|
|
39
|
+
? configured
|
|
40
|
+
: DEFAULT_PRIORITY_RADIUS_MILES;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error('Could not read the listing priority radius, using the default', error);
|
|
43
|
+
return DEFAULT_PRIORITY_RADIUS_MILES;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
18
46
|
|
|
19
47
|
function buildMembersSearchQuery(data) {
|
|
20
48
|
console.log('data: ', JSON.stringify(data));
|
|
@@ -30,151 +58,183 @@ function buildMembersSearchQuery(data) {
|
|
|
30
58
|
filter.latitude = filter.latitude || 0;
|
|
31
59
|
filter.longitude = filter.longitude || 0;
|
|
32
60
|
filter.postalcode = filter.postalcode || '';
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
61
|
+
// Built fresh per call rather than derived from a shared base: the two tier queries must not
|
|
62
|
+
// share any state.
|
|
63
|
+
const buildQuery = tier => {
|
|
64
|
+
let query = wixData
|
|
65
|
+
.query(COLLECTIONS.MEMBERS_DATA)
|
|
66
|
+
.ne('optOut', true)
|
|
67
|
+
.ne('action', 'drop')
|
|
68
|
+
.ne('memberships.membertype', MEMBERSHIPS_TYPES.PAC_STAFF)
|
|
69
|
+
.eq('isVisible', true);
|
|
41
70
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
queryMethod: 'contains',
|
|
52
|
-
queryField: 'areasOfPractices',
|
|
53
|
-
condition: value => value && value.trim() !== '',
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
filterKey: 'postalcode',
|
|
71
|
+
query = query.ge(ASSOCIATION_EXPIRATION_FIELD, getTodayInAssociationTimeZone());
|
|
72
|
+
let filterConfig = [
|
|
73
|
+
{
|
|
74
|
+
filterKey: 'practiceAreas',
|
|
75
|
+
queryMethod: 'hasSome',
|
|
76
|
+
queryField: 'areasOfPractices',
|
|
77
|
+
condition: value => value && value.length > 0,
|
|
78
|
+
fallback: {
|
|
79
|
+
filterKey: 'practiceAreasSearch',
|
|
58
80
|
queryMethod: 'contains',
|
|
59
|
-
queryField: '
|
|
81
|
+
queryField: 'areasOfPractices',
|
|
60
82
|
condition: value => value && value.trim() !== '',
|
|
61
83
|
},
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
filterKey: 'postalcode',
|
|
87
|
+
queryMethod: 'contains',
|
|
88
|
+
queryField: 'addresses.postalcode',
|
|
89
|
+
condition: value => value && value.trim() !== '',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
filterKey: 'state',
|
|
93
|
+
queryMethod: 'hasSome',
|
|
94
|
+
queryField: 'addresses.state',
|
|
95
|
+
condition: value => value && value.length > 0,
|
|
96
|
+
fallback: {
|
|
97
|
+
filterKey: 'stateSearch',
|
|
98
|
+
queryMethod: 'contains',
|
|
65
99
|
queryField: 'addresses.state',
|
|
66
|
-
condition: value => value && value.
|
|
67
|
-
fallback: {
|
|
68
|
-
filterKey: 'stateSearch',
|
|
69
|
-
queryMethod: 'contains',
|
|
70
|
-
queryField: 'addresses.state',
|
|
71
|
-
condition: value => value && value.trim() !== '',
|
|
72
|
-
},
|
|
100
|
+
condition: value => value && value.trim() !== '',
|
|
73
101
|
},
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
filterKey: 'city',
|
|
105
|
+
queryMethod: 'hasSome',
|
|
106
|
+
queryField: 'addresses.city',
|
|
107
|
+
condition: value => value && value.length > 0,
|
|
108
|
+
fallback: {
|
|
109
|
+
filterKey: 'citySearch',
|
|
110
|
+
queryMethod: 'contains',
|
|
77
111
|
queryField: 'addresses.city',
|
|
78
|
-
condition: value => value && value.
|
|
79
|
-
fallback: {
|
|
80
|
-
filterKey: 'citySearch',
|
|
81
|
-
queryMethod: 'contains',
|
|
82
|
-
queryField: 'addresses.city',
|
|
83
|
-
condition: value => value && value.trim() !== '',
|
|
84
|
-
},
|
|
112
|
+
condition: value => value && value.trim() !== '',
|
|
85
113
|
},
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return query;
|
|
101
|
-
};
|
|
102
|
-
// Apply filters using the configuration
|
|
103
|
-
filterConfig.forEach(config => {
|
|
104
|
-
query = applyFilterToQuery(query, config, filter);
|
|
105
|
-
});
|
|
106
|
-
if (isUserLocationEnabled && isSearchingNearby) {
|
|
107
|
-
const userGeohash = geohash.encode(filter.latitude, filter.longitude, GEO_HASH_PRECISION);
|
|
108
|
-
const neighborGeohashes = geohash.neighbors(userGeohash);
|
|
109
|
-
const geohashList = [userGeohash, ...neighborGeohashes];
|
|
110
|
-
query = query.hasSome('locHash', geohashList);
|
|
111
|
-
}
|
|
112
|
-
if (filter.searchText.trim() !== '') {
|
|
113
|
-
query = query.contains('fullName', filter.searchText);
|
|
114
|
-
}
|
|
115
|
-
if (!includeStudents) {
|
|
116
|
-
query = query.ne('memberships.membertype', MEMBERSHIPS_TYPES.STUDENT);
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
//Ignore state, city and postal code when isSearchingNearby is true
|
|
117
|
+
if (isSearchingNearby) {
|
|
118
|
+
filterConfig = filterConfig.filter(
|
|
119
|
+
config => !['state', 'city', 'postalcode'].includes(config.filterKey)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const applyFilterToQuery = (query, config, filter) => {
|
|
123
|
+
const filterValue = filter[config.filterKey];
|
|
124
|
+
if (config.condition(filterValue)) {
|
|
125
|
+
return query[config.queryMethod](config.queryField, filterValue);
|
|
126
|
+
} else if (config.fallback) {
|
|
127
|
+
return applyFilterToQuery(query, config.fallback, filter);
|
|
117
128
|
}
|
|
118
129
|
return query;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
130
|
+
};
|
|
131
|
+
// Apply filters using the configuration
|
|
132
|
+
filterConfig.forEach(config => {
|
|
133
|
+
query = applyFilterToQuery(query, config, filter);
|
|
134
|
+
});
|
|
135
|
+
if (isUserLocationEnabled && isSearchingNearby) {
|
|
136
|
+
const userGeohash = geohash.encode(filter.latitude, filter.longitude, GEO_HASH_PRECISION);
|
|
137
|
+
const neighborGeohashes = geohash.neighbors(userGeohash);
|
|
138
|
+
const geohashList = [userGeohash, ...neighborGeohashes];
|
|
139
|
+
query = query.hasSome('locHash', geohashList);
|
|
140
|
+
}
|
|
141
|
+
if (filter.searchText.trim() !== '') {
|
|
142
|
+
query = query.contains('fullName', filter.searchText);
|
|
143
|
+
}
|
|
144
|
+
if (!includeStudents) {
|
|
145
|
+
query = query.ne('memberships.membertype', MEMBERSHIPS_TYPES.STUDENT);
|
|
146
|
+
}
|
|
147
|
+
if (tier === LISTING_TIERS.UPDATED) {
|
|
148
|
+
query = query.eq(MEMBER_UPDATED_FIELD, true);
|
|
149
|
+
} else if (tier === LISTING_TIERS.REST) {
|
|
150
|
+
// Matches members whose flag was never written at all, the same way the optOut and action
|
|
151
|
+
// gates above already rely on.
|
|
152
|
+
query = query.ne(MEMBER_UPDATED_FIELD, true);
|
|
153
|
+
}
|
|
154
|
+
return query.ascending('firstName').fields(...Object.values(MEMBERS_FIELDS));
|
|
155
|
+
};
|
|
140
156
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Takes a random window of `limit` rows from a tier. The collection is never loaded in full -
|
|
159
|
+
* the count tells us how far we may skip, exactly as the untiered search did.
|
|
160
|
+
*/
|
|
161
|
+
const takeRandomWindow = async (query, totalCount, limit) => {
|
|
162
|
+
if (limit <= 0 || totalCount === 0) return [];
|
|
163
|
+
const maxSkip = Math.max(0, totalCount - limit);
|
|
164
|
+
const skip = maxSkip > 0 ? Math.floor(Math.random() * (maxSkip + 1)) : 0;
|
|
165
|
+
const result = await query.skip(skip).limit(limit).find({ omitTotalCount: true });
|
|
166
|
+
return result.items;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Typed search: updated listings first, then the rest, each shuffled within its own tier so the
|
|
171
|
+
* tier boundary survives the randomisation. Two counts and two windows, run together, so this
|
|
172
|
+
* costs the same round trips as the single untiered query it replaces.
|
|
173
|
+
*/
|
|
174
|
+
const fetchTieredWindow = async () => {
|
|
175
|
+
const updatedQuery = buildQuery(LISTING_TIERS.UPDATED);
|
|
176
|
+
const restQuery = buildQuery(LISTING_TIERS.REST);
|
|
177
|
+
|
|
178
|
+
const [updatedCount, restCount] = await Promise.all([updatedQuery.count(), restQuery.count()]);
|
|
179
|
+
|
|
180
|
+
// Derived from the count rather than from the first window's length, so both windows can be
|
|
181
|
+
// fetched in parallel.
|
|
182
|
+
const updatedLimit = Math.min(updatedCount, MAX__MEMBERS_SEARCH_RESULTS);
|
|
183
|
+
const [updatedItems, restItems] = await Promise.all([
|
|
184
|
+
takeRandomWindow(updatedQuery, updatedCount, updatedLimit),
|
|
185
|
+
takeRandomWindow(restQuery, restCount, MAX__MEMBERS_SEARCH_RESULTS - updatedLimit),
|
|
186
|
+
]);
|
|
147
187
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
return {
|
|
189
|
+
items: [...shuffleArray(updatedItems), ...shuffleArray(restItems)],
|
|
190
|
+
totalCount: updatedCount + restCount,
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
get: () => buildQuery(),
|
|
196
|
+
run: async () => {
|
|
197
|
+
const result = isSearchingNearby
|
|
198
|
+
? await fetchAllItemsInParallel(buildQuery())
|
|
199
|
+
: await fetchTieredWindow();
|
|
200
|
+
|
|
201
|
+
if (!isUserLocationEnabled) {
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const withDistances = result.items.map(item => ({
|
|
206
|
+
...item,
|
|
207
|
+
distance: calculateDistance(
|
|
208
|
+
{
|
|
209
|
+
latitude: filter.latitude,
|
|
210
|
+
longitude: filter.longitude,
|
|
211
|
+
},
|
|
212
|
+
findMainAddress(item.addressDisplayOption, item.addresses, {
|
|
213
|
+
requireValidCoordinates: true,
|
|
214
|
+
})
|
|
215
|
+
),
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
if (!isSearchingNearby) {
|
|
219
|
+
return { ...result, items: withDistances };
|
|
176
220
|
}
|
|
177
|
-
|
|
221
|
+
|
|
222
|
+
// "Near me": updated listings inside the radius first, shuffled among themselves, then
|
|
223
|
+
// everything else nearest-first. Every row and its distance is already in memory here.
|
|
224
|
+
const radiusMiles = await getPriorityRadiusMiles();
|
|
225
|
+
const located = withDistances.filter(item => item.distance !== null);
|
|
226
|
+
const isPrioritised = item =>
|
|
227
|
+
item[MEMBER_UPDATED_FIELD] === true && item.distance <= radiusMiles;
|
|
228
|
+
|
|
229
|
+
const prioritised = shuffleArray(located.filter(isPrioritised));
|
|
230
|
+
const nearest = located
|
|
231
|
+
.filter(item => !isPrioritised(item))
|
|
232
|
+
.sort((a, b) => a.distance - b.distance);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
...result,
|
|
236
|
+
items: [...prioritised, ...nearest].slice(0, MAX__MEMBERS_SEARCH_RESULTS),
|
|
237
|
+
};
|
|
178
238
|
},
|
|
179
239
|
};
|
|
180
240
|
}
|
package/backend/consts.js
CHANGED
|
@@ -17,6 +17,7 @@ const CONFIG_KEYS = {
|
|
|
17
17
|
MEMBERS_EXTERNAL_PORTAL_URL: 'MEMBERS_EXTERNAL_PORTAL_URL',
|
|
18
18
|
DEFAULT_PROFILE_IMAGE: 'DEFAULT_PROFILE_IMAGE',
|
|
19
19
|
QA_ALLOW_ANY_MEMBER: 'QA_ALLOW_ANY_MEMBER',
|
|
20
|
+
LISTING_PRIORITY_RADIUS_MILES: 'LISTING_PRIORITY_RADIUS_MILES',
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
const MAX__MEMBERS_SEARCH_RESULTS = 120;
|
|
@@ -15,10 +15,9 @@ const getNonCompiledFiltersOptions = async () => {
|
|
|
15
15
|
]);
|
|
16
16
|
return { completeStateList, areasOfPracticesList, stateCityMapList };
|
|
17
17
|
};
|
|
18
|
-
const filterProfiles =
|
|
18
|
+
const filterProfiles = data => {
|
|
19
19
|
const membersSearchQuery = buildMembersSearchQuery({ ...data, includeStudents: false });
|
|
20
|
-
|
|
21
|
-
return membersSearchQuery.run(query);
|
|
20
|
+
return membersSearchQuery.run();
|
|
22
21
|
};
|
|
23
22
|
|
|
24
23
|
async function getAreasOfPracticeList() {
|