abmp-npm 2.0.80 → 2.0.82

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,132 @@
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
+ module.exports = {
123
+ parseExpirationToUtcDate,
124
+ classifyAssociationExpiration,
125
+ resolveAssociationExpiration,
126
+ summarizeExpirationOutcomes,
127
+ memberNeedsAssociationExpirationBackfill,
128
+ getTodayInAssociationTimeZone,
129
+ ASSOCIATION_EXPIRATION_FIELD,
130
+ ASSOCIATION_TIME_ZONE,
131
+ EXPIRATION_OUTCOMES,
132
+ };
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
  };
@@ -560,6 +560,22 @@ const getAllMembersWithoutContactFormEmail = async () => {
560
560
  }
561
561
  };
562
562
 
563
+ /**
564
+ * Every member, unfiltered. Filtering would hide the very records the expiry backfill's report
565
+ * exists to surface - the ones with no readable expiration.
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
+ };
@@ -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 = {
@@ -7,4 +7,5 @@ module.exports = {
7
7
  ...require('./address-primary-methods'),
8
8
  ...require('./url-space-fix-methods'),
9
9
  ...require('./daily-pull-check-methods'),
10
+ ...require('./association-expiry-backfill-methods'),
10
11
  };
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "abmp-npm",
3
- "version": "2.0.80",
3
+ "version": "2.0.82",
4
4
  "main": "index.js",
5
5
  "files": [
6
6
  "index.js",
package/pages/Home.js CHANGED
@@ -7,11 +7,10 @@ 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
- findMainAddress,
11
10
  formatPracticeAreasForDisplay,
12
11
  isWixHostedImage,
13
12
  normalizeExternalUrl,
14
- buildMapLink,
13
+ buildDirectionsLink,
15
14
  } = require('../public/Utils/sharedUtils.js');
16
15
 
17
16
  let filter = JSON.parse(JSON.stringify(DEFAULT_FILTER));
@@ -226,24 +225,13 @@ const homePageOnReady = async ({
226
225
  $item('#milesAwayText').text = '';
227
226
  }
228
227
 
229
- // 7) "Show maps" button - directions to whatever address the member displays.
228
+ // 7) "Show maps" button - only for members who selected "Show Full Address".
230
229
  //
231
- // Uses the same address as the location text above, so the button always
232
- // matches what the member is showing. Members displaying city/state/ZIP get
233
- // directions to that, rather than no button at all: their NetForum
234
- // coordinates can be miles off (see Monday 12596102059), and the text they
235
- // publish is the reliable value.
236
- //
237
- // Privacy is enforced in buildMapLink, not here - it delegates to
238
- // formatAddress, which prints the street line only for full_address, and
239
- // returns '' outright for dont_show. findMainAddress also excludes
240
- // dont_show. A hidden street address can never reach a maps URL.
241
- //
242
- // Gate on the link rather than the address: buildMapLink returns '' when it
243
- // can build neither an address nor a coordinate link, and handles being
244
- // passed '' or undefined, so the button is never shown linking nowhere.
245
- const mapAddress = findMainAddress(itemData.addressDisplayOption, addresses);
246
- const mapLink = buildMapLink(mapAddress);
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);
247
235
 
248
236
  if (mapLink) {
249
237
  $item('#showMaps').enable();
@@ -155,6 +155,40 @@ function buildMapLink(address) {
155
155
  return '';
156
156
  }
157
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
+
158
192
  /**
159
193
  * @param {Array} addressDisplayOption
160
194
  * @param {Array} addresses
@@ -266,6 +300,7 @@ module.exports = {
266
300
  generateId,
267
301
  formatAddress,
268
302
  buildMapLink,
303
+ buildDirectionsLink,
269
304
  isWixHostedImage,
270
305
  normalizeExternalUrl,
271
306
  normalizeEmail,