abmp-npm 10.3.16 → 10.3.18

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.
@@ -1,5 +1,5 @@
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.
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
3
 
4
4
  const ASSOCIATION_EXPIRATION_FIELD = 'associationExpiration';
5
5
  const ASSOCIATION_TIME_ZONE = 'America/Denver';
@@ -14,11 +14,8 @@ const EXPIRATION_OUTCOMES = {
14
14
  UNREADABLE_EXPIRATION: 'unreadableExpiration',
15
15
  };
16
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
- */
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.
22
19
  const parseExpirationToUtcDate = expiration => {
23
20
  if (typeof expiration !== 'string') return null;
24
21
 
@@ -37,10 +34,8 @@ const parseExpirationToUtcDate = expiration => {
37
34
  return isRealDate ? parsed : null;
38
35
  };
39
36
 
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
- */
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.
44
39
  const classifyAssociationExpiration = (member, siteAssociation) => {
45
40
  if (!siteAssociation) {
46
41
  return { date: null, outcome: EXPIRATION_OUTCOMES.NO_SITE_ASSOCIATION };
@@ -96,11 +91,8 @@ const memberNeedsAssociationExpirationBackfill = (member, siteAssociation) => {
96
91
  return storedTime !== resolvedTime;
97
92
  };
98
93
 
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
- */
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.
104
96
  const getTodayInAssociationTimeZone = (now = new Date()) => {
105
97
  try {
106
98
  const parts = new Intl.DateTimeFormat('en-US', {
@@ -127,6 +119,17 @@ const getTodayInAssociationTimeZone = (now = new Date()) => {
127
119
  }
128
120
  };
129
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
+
130
133
  module.exports = {
131
134
  parseExpirationToUtcDate,
132
135
  classifyAssociationExpiration,
@@ -134,6 +137,7 @@ module.exports = {
134
137
  summarizeExpirationOutcomes,
135
138
  memberNeedsAssociationExpirationBackfill,
136
139
  getTodayInAssociationTimeZone,
140
+ isAssociationExpirationCurrent,
137
141
  ASSOCIATION_EXPIRATION_FIELD,
138
142
  ASSOCIATION_TIME_ZONE,
139
143
  EXPIRATION_OUTCOMES,
@@ -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
- const toSyncMembersWithFilteredLicenses = toSyncMembers.map(member =>
130
- filterLicensesByAssociation(member, siteAssociation)
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
@@ -134,7 +134,7 @@ async function runDailyPullExecutionCheck(options = {}) {
134
134
  }
135
135
 
136
136
  /**
137
- * One-off backfill of associationExpiration. Run with `{ dryRun: true }` first to get the count of
137
+ * One-off backfill of associationExpiration. Run with `{ dryRun: true }` first: it counts the
138
138
  * members that resolve to no date, and would therefore be hidden, without writing anything.
139
139
  * @param {Object} [options]
140
140
  * @param {boolean} [options.dryRun]
@@ -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 { CONFIG_KEYS, SSO_TOKEN_AUTH_API_URL } = require('../consts');
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
- const memberData = await prepareMemberForSSOLogin(payload);
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 { MEMBERSHIPS_TYPES } = require('./consts');
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');
@@ -561,8 +562,8 @@ const getAllMembersWithoutContactFormEmail = async () => {
561
562
  };
562
563
 
563
564
  /**
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.
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.
566
567
  * @returns {Promise<Array>} - Array of member data
567
568
  */
568
569
  const getAllMembers = async () => {
@@ -696,6 +697,14 @@ async function prepareMemberForSSOLogin(data) {
696
697
  if (!memberData) {
697
698
  throw new Error(`Member data not found for memberId ${memberId}`);
698
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
+ }
699
708
  console.log('memberData', memberData);
700
709
  return await ensureWixMemberAndContactExist(memberData);
701
710
  } catch (error) {
@@ -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}`;
@@ -256,7 +256,9 @@ const TASKS = {
256
256
  getIdentifier: task => task.data,
257
257
  process: associationExpiryBackfillChunk,
258
258
  shouldSkipCheck: () => false,
259
- estimatedDurationSec: 80,
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,
260
262
  },
261
263
  [TASKS_NAMES.dailyPullExecutionCheck]: {
262
264
  name: TASKS_NAMES.dailyPullExecutionCheck,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abmp-npm",
3
- "version": "10.3.16",
3
+ "version": "10.3.18",
4
4
  "main": "index.js",
5
5
  "files": [
6
6
  "index.js",
@@ -166,10 +166,14 @@ async function personalDetailsOnReady({
166
166
 
167
167
  try {
168
168
  const {
169
- memberData: { isStudent: _isStudent, ...memberDataResponse },
169
+ memberData: validatedMember,
170
170
  isValid: isValidResponse,
171
171
  membersExternalPortalUrl: _membersExternalPortalUrl,
172
172
  } = await validateMemberToken(memberTokenId);
173
+ // validateMemberToken returns memberData: null for every rejection, expired and dropped
174
+ // included. Destructuring that directly threw, so the catch below showed the "something is
175
+ // broken" screen instead of the unauthorized one built for exactly this.
176
+ const { isStudent: _isStudent, ...memberDataResponse } = validatedMember ?? {};
173
177
  memberData = memberDataResponse;
174
178
  isValid = isValidResponse;
175
179
  isStudent = _isStudent;