@rachelallyson/planning-center-people-ts 2.12.2 โ†’ 2.14.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,79 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.14.0] - 2026-01-21
9
+
10
+ ### โœจ **New Features**
11
+
12
+ - **๐Ÿ” Multi-Step Search Strategy**: New `searchStrategy: 'multi-step'` option for maximum matching success
13
+ - Tries multiple matching approaches in order until a match is found
14
+ - Strategy order: fuzzy+age โ†’ fuzzy โ†’ exact+age โ†’ exact
15
+ - Handles name variations and age preference filtering automatically
16
+ - Significantly reduces duplicate person creation
17
+
18
+ - **๐Ÿ“› Name-Based Search Fallback**: New `fallbackToNameSearch` option with contact validation
19
+ - Falls back to name search when email/phone search fails
20
+ - Validates contact info to prevent wrong-person matches (e.g., two "John Smith"s)
21
+ - Configurable validation: `contactValidation: 'strict' | 'domain' | 'similarity'`
22
+
23
+ - **โšก Multi-Phase Retry Configurations**: New `retryConfigs` option for phase-specific retry control
24
+ - `initial`: Quick search phase (default: 30s max wait, 3 retries)
25
+ - `aggressive`: Final search before create (default: 60s max wait, 6 retries)
26
+ - Prevents duplicates when PCO hasn't indexed contacts yet (15-30 min delay)
27
+
28
+ - **โœ… Person ID Verification**: New `client.people.verifyPersonExists()` method
29
+ - Verifies a person exists in PCO with configurable timeout
30
+ - Handles merged/deleted persons gracefully (returns false for 404)
31
+ - Useful for validating cached person IDs before use
32
+
33
+ - **๐Ÿ” Trust-Based Caching Helpers**: New utilities for smart person ID caching
34
+ - `calculateTrust(createdAt, trustWindow)`: Determines if cached personId can be trusted
35
+ - `DEFAULT_TRUST_WINDOW`: 1-hour default trust window constant
36
+ - Skip verification for fresh personIds to avoid race conditions
37
+
38
+ - **๐Ÿ“ง Contact Validation Helpers**: New utilities for contact info matching
39
+ - `emailDomainsMatch(email1, email2)`: Handles aliases (gmail/googlemail) and typos
40
+ - `phoneNumbersSimilar(phone1, phone2)`: Handles format variations and country codes
41
+ - `validateContactSimilarity()`: Validates contact info for name-based matches
42
+ - `extractEmailDomain()`: Extracts domain from email address
43
+
44
+ ### ๐Ÿ”ง **Improvements**
45
+
46
+ - **Retry Config Refactoring**: Extracted `RetryConfig` interface for reusability
47
+ - **Better Logging**: Multi-step search logs which strategy found the match
48
+ - **Aggressive Final Search**: When `retryConfigs.aggressive` is set, performs final search before creating
49
+
50
+ ### ๐Ÿ“š **Documentation**
51
+
52
+ - Updated README_V2.md with examples for all new features
53
+ - Added code examples for multi-step search, contact validation, and trust calculation
54
+
55
+ ### ๐Ÿงช **Testing**
56
+
57
+ - Added `tests/helpers/contact-validation.test.ts` - Contact validation helper tests
58
+ - Added `tests/helpers/trust-calculation.test.ts` - Trust calculation tests
59
+ - Added `tests/matching/multi-step.test.ts` - Multi-step search strategy tests
60
+ - Added `tests/modules/people-verify.test.ts` - Person verification tests
61
+
62
+ ### ๐Ÿ“ฆ **Exports**
63
+
64
+ New exports from the package:
65
+
66
+ - `emailDomainsMatch`, `extractEmailDomain`, `phoneNumbersSimilar`, `validateContactSimilarity`
67
+ - `calculateTrust`, `DEFAULT_TRUST_WINDOW`, `TrustResult` (type)
68
+ - `RetryConfig` (type), `PersonMatchOptions` (type)
69
+ - `DEFAULT_INITIAL_RETRY_CONFIG`, `DEFAULT_AGGRESSIVE_RETRY_CONFIG`
70
+
71
+ ## [2.13.0] - 2026-01-20
72
+
73
+ ### โœจ **New Features**
74
+
75
+ - **๐ŸŽฏ Lenient Age Preference Filtering**: Added `agePreferenceLenient` option for flexible age-based filtering
76
+ - When `agePreferenceLenient: true`, age preferences only filter profiles that have birthdates
77
+ - Profiles without birthdates are included regardless of `agePreference` setting
78
+ - Prevents false negatives when searching for existing people with incomplete age data
79
+ - Backward compatible - default behavior remains strict filtering (only `agePreference: 'any'` includes profiles without birthdates)
80
+
8
81
  ## [2.12.2] - 2026-01-20
9
82
 
10
83
  ### โœจ **New Features**
package/dist/helpers.d.ts CHANGED
@@ -35,6 +35,7 @@ export declare function matchesAgeCriteria(birthdate: string | undefined, criter
35
35
  minAge?: number;
36
36
  maxAge?: number;
37
37
  birthYear?: number;
38
+ agePreferenceLenient?: boolean;
38
39
  }): boolean;
39
40
  /**
40
41
  * Calculate birth year from age
@@ -59,6 +60,90 @@ export declare function isValidPhone(phone: string): boolean;
59
60
  * - Other lengths: adds + prefix to all digits
60
61
  */
61
62
  export declare function normalizePhone(phone: string): string;
63
+ /**
64
+ * Extract domain from email address
65
+ */
66
+ export declare function extractEmailDomain(email: string): string;
67
+ /**
68
+ * Check if two email domains match or are similar
69
+ * Handles:
70
+ * - Exact domain matches
71
+ * - Common aliases (gmail.com vs googlemail.com)
72
+ * - Prefix matching for similar domains (first 3+ characters)
73
+ *
74
+ * @param email1 - First email address
75
+ * @param email2 - Second email address
76
+ * @returns True if domains match or are similar
77
+ */
78
+ export declare function emailDomainsMatch(email1: string, email2: string): boolean;
79
+ /**
80
+ * Check if two phone numbers are similar
81
+ * Handles:
82
+ * - Different formats (+1, 1, or just 10 digits)
83
+ * - Country code variations
84
+ *
85
+ * @param phone1 - First phone number
86
+ * @param phone2 - Second phone number
87
+ * @returns True if phone numbers are similar
88
+ */
89
+ export declare function phoneNumbersSimilar(phone1: string, phone2: string): boolean;
90
+ /**
91
+ * Validate contact info similarity for name-based matches
92
+ *
93
+ * This is useful when falling back to name-based search to ensure
94
+ * we don't match the wrong person with the same name.
95
+ *
96
+ * @param searchEmail - Email being searched for
97
+ * @param searchPhone - Phone being searched for
98
+ * @param personEmails - Array of email addresses from the person's profile
99
+ * @param personPhones - Array of phone numbers from the person's profile
100
+ * @returns Object with match results and overall validity
101
+ */
102
+ export declare function validateContactSimilarity(searchEmail: string | undefined, searchPhone: string | undefined, personEmails: string[], personPhones: string[]): {
103
+ emailMatch: boolean;
104
+ phoneMatch: boolean;
105
+ isValid: boolean;
106
+ };
107
+ /**
108
+ * Default trust window for person IDs (1 hour in milliseconds)
109
+ * If a personId was saved within this time, it can be trusted without verification
110
+ */
111
+ export declare const DEFAULT_TRUST_WINDOW: number;
112
+ /**
113
+ * Result of trust calculation for a person ID
114
+ */
115
+ export interface TrustResult {
116
+ /** Whether the person ID should be trusted without verification */
117
+ shouldTrust: boolean;
118
+ /** Age of the person ID in milliseconds (null if no timestamp) */
119
+ age: number | null;
120
+ /** Human-readable reason for the trust decision */
121
+ reason: string;
122
+ }
123
+ /**
124
+ * Calculate whether a person ID can be trusted based on when it was created/verified
125
+ *
126
+ * This is useful for caching person IDs to avoid unnecessary API calls.
127
+ * PCO takes 15-30 minutes to index new contacts, so recently created person IDs
128
+ * should be trusted without re-verification to avoid race conditions.
129
+ *
130
+ * @param createdAt - ISO timestamp when the person ID was created/saved
131
+ * @param trustWindow - Trust window in milliseconds (default: 1 hour)
132
+ * @returns Object with trust decision, age, and reason
133
+ *
134
+ * @example
135
+ * ```typescript
136
+ * const trust = calculateTrust(pcoInfo.personIdCreatedAt);
137
+ * if (trust.shouldTrust) {
138
+ * // Use cached personId without verification
139
+ * return cachedPersonId;
140
+ * } else {
141
+ * // Verify personId still exists in PCO
142
+ * await client.people.getById(cachedPersonId);
143
+ * }
144
+ * ```
145
+ */
146
+ export declare function calculateTrust(createdAt: string | undefined, trustWindow?: number): TrustResult;
62
147
  /**
63
148
  * Format person name from attributes
64
149
  */
package/dist/helpers.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_TRUST_WINDOW = void 0;
3
4
  exports.buildQueryParams = buildQueryParams;
4
5
  exports.calculateAge = calculateAge;
5
6
  exports.calculateAgeSafe = calculateAgeSafe;
@@ -11,6 +12,11 @@ exports.isValidEmail = isValidEmail;
11
12
  exports.normalizeEmail = normalizeEmail;
12
13
  exports.isValidPhone = isValidPhone;
13
14
  exports.normalizePhone = normalizePhone;
15
+ exports.extractEmailDomain = extractEmailDomain;
16
+ exports.emailDomainsMatch = emailDomainsMatch;
17
+ exports.phoneNumbersSimilar = phoneNumbersSimilar;
18
+ exports.validateContactSimilarity = validateContactSimilarity;
19
+ exports.calculateTrust = calculateTrust;
14
20
  exports.formatPersonName = formatPersonName;
15
21
  exports.formatDate = formatDate;
16
22
  exports.validatePersonData = validatePersonData;
@@ -103,8 +109,13 @@ function isChild(birthdate) {
103
109
  */
104
110
  function matchesAgeCriteria(birthdate, criteria) {
105
111
  const age = calculateAgeSafe(birthdate);
106
- // If no birthdate, only match if preference is 'any'
112
+ // If no birthdate, match based on lenient setting
107
113
  if (age === null) {
114
+ if (criteria.agePreferenceLenient) {
115
+ // Lenient mode: include profiles without birthdates regardless of agePreference
116
+ return true;
117
+ }
118
+ // Strict mode (default): only match if preference is 'any'
108
119
  return criteria.agePreference === 'any' || criteria.agePreference === undefined;
109
120
  }
110
121
  // Check age preference
@@ -168,6 +179,200 @@ function normalizePhone(phone) {
168
179
  }
169
180
  return `+${digits}`;
170
181
  }
182
+ // ===== Contact Validation Helpers =====
183
+ /**
184
+ * Extract domain from email address
185
+ */
186
+ function extractEmailDomain(email) {
187
+ const normalized = normalizeEmail(email);
188
+ const atIndex = normalized.indexOf('@');
189
+ return atIndex >= 0 ? normalized.substring(atIndex + 1) : '';
190
+ }
191
+ /**
192
+ * Common email domain aliases (e.g., gmail.com and googlemail.com are the same)
193
+ */
194
+ const EMAIL_DOMAIN_ALIASES = {
195
+ 'googlemail.com': 'gmail.com',
196
+ 'google.com': 'gmail.com',
197
+ };
198
+ /**
199
+ * Normalize email domain to handle common aliases
200
+ */
201
+ function normalizeEmailDomain(domain) {
202
+ const lowerDomain = domain.toLowerCase();
203
+ return EMAIL_DOMAIN_ALIASES[lowerDomain] || lowerDomain;
204
+ }
205
+ /**
206
+ * Check if two email domains match or are similar
207
+ * Handles:
208
+ * - Exact domain matches
209
+ * - Common aliases (gmail.com vs googlemail.com)
210
+ * - Prefix matching for similar domains (first 3+ characters)
211
+ *
212
+ * @param email1 - First email address
213
+ * @param email2 - Second email address
214
+ * @returns True if domains match or are similar
215
+ */
216
+ function emailDomainsMatch(email1, email2) {
217
+ const domain1 = normalizeEmailDomain(extractEmailDomain(email1));
218
+ const domain2 = normalizeEmailDomain(extractEmailDomain(email2));
219
+ if (!domain1 || !domain2) {
220
+ return false;
221
+ }
222
+ // Exact match after normalization
223
+ if (domain1 === domain2) {
224
+ return true;
225
+ }
226
+ // Check if domains share a common prefix (at least 3 characters)
227
+ // This helps catch typos like "gmial.com" vs "gmail.com"
228
+ const minPrefixLength = 3;
229
+ if (domain1.length >= minPrefixLength && domain2.length >= minPrefixLength) {
230
+ const prefix1 = domain1.substring(0, minPrefixLength);
231
+ const prefix2 = domain2.substring(0, minPrefixLength);
232
+ if (prefix1 === prefix2) {
233
+ return true;
234
+ }
235
+ }
236
+ return false;
237
+ }
238
+ /**
239
+ * Normalize phone number to digits only, stripping country code if present
240
+ */
241
+ function normalizePhoneDigits(phone) {
242
+ const digits = phone.replace(/\D/g, '');
243
+ // Strip leading 1 for US numbers (11 digits starting with 1)
244
+ if (digits.length === 11 && digits.startsWith('1')) {
245
+ return digits.substring(1);
246
+ }
247
+ return digits;
248
+ }
249
+ /**
250
+ * Check if two phone numbers are similar
251
+ * Handles:
252
+ * - Different formats (+1, 1, or just 10 digits)
253
+ * - Country code variations
254
+ *
255
+ * @param phone1 - First phone number
256
+ * @param phone2 - Second phone number
257
+ * @returns True if phone numbers are similar
258
+ */
259
+ function phoneNumbersSimilar(phone1, phone2) {
260
+ if (!phone1 || !phone2) {
261
+ return false;
262
+ }
263
+ const normalized1 = normalizePhoneDigits(phone1);
264
+ const normalized2 = normalizePhoneDigits(phone2);
265
+ // Empty after normalization
266
+ if (!normalized1 || !normalized2) {
267
+ return false;
268
+ }
269
+ // Exact match after normalization
270
+ if (normalized1 === normalized2) {
271
+ return true;
272
+ }
273
+ // Also check raw digits (handles international numbers)
274
+ const digits1 = phone1.replace(/\D/g, '');
275
+ const digits2 = phone2.replace(/\D/g, '');
276
+ return digits1 === digits2;
277
+ }
278
+ /**
279
+ * Validate contact info similarity for name-based matches
280
+ *
281
+ * This is useful when falling back to name-based search to ensure
282
+ * we don't match the wrong person with the same name.
283
+ *
284
+ * @param searchEmail - Email being searched for
285
+ * @param searchPhone - Phone being searched for
286
+ * @param personEmails - Array of email addresses from the person's profile
287
+ * @param personPhones - Array of phone numbers from the person's profile
288
+ * @returns Object with match results and overall validity
289
+ */
290
+ function validateContactSimilarity(searchEmail, searchPhone, personEmails, personPhones) {
291
+ let emailMatch = false;
292
+ let phoneMatch = false;
293
+ // Check email domain match
294
+ if (searchEmail) {
295
+ emailMatch = personEmails.some(personEmail => emailDomainsMatch(searchEmail, personEmail));
296
+ }
297
+ // Check phone similarity
298
+ if (searchPhone) {
299
+ phoneMatch = personPhones.some(personPhone => phoneNumbersSimilar(searchPhone, personPhone));
300
+ }
301
+ // Valid if either email domain matches or phone is similar
302
+ // (or if we didn't have search criteria to check)
303
+ const hasSearchCriteria = !!(searchEmail || searchPhone);
304
+ const isValid = !hasSearchCriteria || emailMatch || phoneMatch;
305
+ return { emailMatch, phoneMatch, isValid };
306
+ }
307
+ // ===== Person ID Trust Calculation =====
308
+ /**
309
+ * Default trust window for person IDs (1 hour in milliseconds)
310
+ * If a personId was saved within this time, it can be trusted without verification
311
+ */
312
+ exports.DEFAULT_TRUST_WINDOW = 60 * 60 * 1000; // 1 hour
313
+ /**
314
+ * Calculate whether a person ID can be trusted based on when it was created/verified
315
+ *
316
+ * This is useful for caching person IDs to avoid unnecessary API calls.
317
+ * PCO takes 15-30 minutes to index new contacts, so recently created person IDs
318
+ * should be trusted without re-verification to avoid race conditions.
319
+ *
320
+ * @param createdAt - ISO timestamp when the person ID was created/saved
321
+ * @param trustWindow - Trust window in milliseconds (default: 1 hour)
322
+ * @returns Object with trust decision, age, and reason
323
+ *
324
+ * @example
325
+ * ```typescript
326
+ * const trust = calculateTrust(pcoInfo.personIdCreatedAt);
327
+ * if (trust.shouldTrust) {
328
+ * // Use cached personId without verification
329
+ * return cachedPersonId;
330
+ * } else {
331
+ * // Verify personId still exists in PCO
332
+ * await client.people.getById(cachedPersonId);
333
+ * }
334
+ * ```
335
+ */
336
+ function calculateTrust(createdAt, trustWindow = exports.DEFAULT_TRUST_WINDOW) {
337
+ if (!createdAt) {
338
+ return {
339
+ shouldTrust: false,
340
+ age: null,
341
+ reason: 'No timestamp (legacy data or never saved)',
342
+ };
343
+ }
344
+ const createdDate = new Date(createdAt);
345
+ if (isNaN(createdDate.getTime())) {
346
+ return {
347
+ shouldTrust: false,
348
+ age: null,
349
+ reason: 'Invalid timestamp format',
350
+ };
351
+ }
352
+ const age = Date.now() - createdDate.getTime();
353
+ if (age < 0) {
354
+ return {
355
+ shouldTrust: false,
356
+ age,
357
+ reason: 'Timestamp is in the future (clock skew)',
358
+ };
359
+ }
360
+ if (age < trustWindow) {
361
+ const ageSeconds = Math.round(age / 1000);
362
+ const trustWindowMinutes = Math.round(trustWindow / 1000 / 60);
363
+ return {
364
+ shouldTrust: true,
365
+ age,
366
+ reason: `Fresh personId (${ageSeconds}s old, within ${trustWindowMinutes}min trust window)`,
367
+ };
368
+ }
369
+ const ageMinutes = Math.round(age / 1000 / 60);
370
+ return {
371
+ shouldTrust: false,
372
+ age,
373
+ reason: `Old personId (${ageMinutes}min old, needs verification)`,
374
+ };
375
+ }
171
376
  /**
172
377
  * Format person name from attributes
173
378
  */
package/dist/index.d.ts CHANGED
@@ -20,7 +20,10 @@ export type { ErrorContext } from '@rachelallyson/planning-center-base-ts';
20
20
  export { ErrorCategory, ErrorSeverity, handleNetworkError, handleTimeoutError, handleValidationError, PcoError, retryWithBackoff, shouldNotRetry, withErrorBoundary, } from '@rachelallyson/planning-center-base-ts';
21
21
  export { createFieldDefinition, createFieldOption, createPerson, createPersonAddress, createPersonEmail, createPersonFieldData, createPersonPhoneNumber, createPersonSocialProfile, createWorkflowCard, createWorkflowCardNote, deleteFieldDefinition, deletePerson, deletePersonFieldData, deleteSocialProfile, getFieldDefinitions, getFieldOptions, getHousehold, getHouseholds, getTabs, getListById, getListCategories, getLists, runList, getNote, getNoteCategories, getNotes, getOrganization, getPeople, getPerson, getPersonAddresses, getPersonEmails, getPersonFieldData, getPersonPhoneNumbers, getPersonSocialProfiles, getWorkflow, getWorkflowCardNotes, getWorkflowCards, getWorkflows, updatePerson, updatePersonAddress, } from './people';
22
22
  export { attemptRecovery, CircuitBreaker, classifyError, createErrorReport, DEFAULT_RETRY_CONFIG, executeBulkOperation, retryWithExponentialBackoff, TIMEOUT_CONFIG, withTimeout, } from './error-scenarios';
23
- export { buildQueryParams, calculateAge, createPersonWithContact, createWorkflowCardWithNote, exportAllPeopleData, extractFileUrl, formatDate, formatPersonName, getCompletePersonProfile, getFileExtension, getFilename, getListsWithCategories, getOrganizationInfo, getPeopleByHousehold, getPersonWorkflowCardsWithNotes, getPrimaryContact, isFileUpload, isFileUrl, isValidEmail, isValidPhone, normalizeEmail, normalizePhone, processFileValue, searchPeople, validatePersonData, } from './helpers';
23
+ export { buildQueryParams, calculateAge, calculateTrust, createPersonWithContact, createWorkflowCardWithNote, DEFAULT_TRUST_WINDOW, emailDomainsMatch, exportAllPeopleData, extractEmailDomain, extractFileUrl, formatDate, formatPersonName, getCompletePersonProfile, getFileExtension, getFilename, getListsWithCategories, getOrganizationInfo, getPeopleByHousehold, getPersonWorkflowCardsWithNotes, getPrimaryContact, isFileUpload, isFileUrl, isValidEmail, isValidPhone, normalizeEmail, normalizePhone, phoneNumbersSimilar, processFileValue, searchPeople, validateContactSimilarity, validatePersonData, } from './helpers';
24
+ export type { TrustResult } from './helpers';
25
+ export { DEFAULT_INITIAL_RETRY_CONFIG, DEFAULT_AGGRESSIVE_RETRY_CONFIG, } from './modules/people';
26
+ export type { RetryConfig, PersonMatchOptions } from './modules/people';
24
27
  export { AdaptiveRateLimiter, ApiCache, batchFetchPersonDetails, fetchAllPages, getCachedPeople, monitorPerformance, PerformanceMonitor, processInBatches, processLargeDataset, streamPeopleData, } from './performance';
25
28
  export { MockPcoClient, MockResponseBuilder, RequestRecorder, createMockClient, createRecordingClient, createTestClient, createErrorMockClient, createSlowMockClient, } from './testing';
26
29
  export type { MockClientConfig, RecordingConfig } from './testing';
package/dist/index.js CHANGED
@@ -16,8 +16,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  };
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.getNoteCategories = exports.getNote = exports.runList = exports.getLists = exports.getListCategories = exports.getListById = exports.getTabs = exports.getHouseholds = exports.getHousehold = exports.getFieldOptions = exports.getFieldDefinitions = exports.deleteSocialProfile = exports.deletePersonFieldData = exports.deletePerson = exports.deleteFieldDefinition = exports.createWorkflowCardNote = exports.createWorkflowCard = exports.createPersonSocialProfile = exports.createPersonPhoneNumber = exports.createPersonFieldData = exports.createPersonEmail = exports.createPersonAddress = exports.createPerson = exports.createFieldOption = exports.createFieldDefinition = exports.withErrorBoundary = exports.shouldNotRetry = exports.retryWithBackoff = exports.PcoError = exports.handleValidationError = exports.handleTimeoutError = exports.handleNetworkError = exports.ErrorSeverity = exports.ErrorCategory = exports.PcoRateLimiter = exports.PcoApiError = exports.updateClientTokens = exports.refreshAccessToken = exports.hasRefreshTokenCapability = exports.attemptTokenRefresh = exports.post = exports.patch = exports.getSingle = exports.getRateLimitInfo = exports.getList = exports.getAllPages = exports.del = exports.createPcoClient = exports.PcoClientManager = exports.PcoClient = void 0;
19
- exports.AdaptiveRateLimiter = exports.validatePersonData = exports.searchPeople = exports.processFileValue = exports.normalizePhone = exports.normalizeEmail = exports.isValidPhone = exports.isValidEmail = exports.isFileUrl = exports.isFileUpload = exports.getPrimaryContact = exports.getPersonWorkflowCardsWithNotes = exports.getPeopleByHousehold = exports.getOrganizationInfo = exports.getListsWithCategories = exports.getFilename = exports.getFileExtension = exports.getCompletePersonProfile = exports.formatPersonName = exports.formatDate = exports.extractFileUrl = exports.exportAllPeopleData = exports.createWorkflowCardWithNote = exports.createPersonWithContact = exports.calculateAge = exports.buildQueryParams = exports.withTimeout = exports.TIMEOUT_CONFIG = exports.retryWithExponentialBackoff = exports.executeBulkOperation = exports.DEFAULT_RETRY_CONFIG = exports.createErrorReport = exports.classifyError = exports.CircuitBreaker = exports.attemptRecovery = exports.updatePersonAddress = exports.updatePerson = exports.getWorkflows = exports.getWorkflowCards = exports.getWorkflowCardNotes = exports.getWorkflow = exports.getPersonSocialProfiles = exports.getPersonPhoneNumbers = exports.getPersonFieldData = exports.getPersonEmails = exports.getPersonAddresses = exports.getPerson = exports.getPeople = exports.getOrganization = exports.getNotes = void 0;
20
- exports.createSlowMockClient = exports.createErrorMockClient = exports.createTestClient = exports.createRecordingClient = exports.createMockClient = exports.RequestRecorder = exports.MockResponseBuilder = exports.MockPcoClient = exports.streamPeopleData = exports.processLargeDataset = exports.processInBatches = exports.PerformanceMonitor = exports.monitorPerformance = exports.getCachedPeople = exports.fetchAllPages = exports.batchFetchPersonDetails = exports.ApiCache = void 0;
19
+ exports.normalizePhone = exports.normalizeEmail = exports.isValidPhone = exports.isValidEmail = exports.isFileUrl = exports.isFileUpload = exports.getPrimaryContact = exports.getPersonWorkflowCardsWithNotes = exports.getPeopleByHousehold = exports.getOrganizationInfo = exports.getListsWithCategories = exports.getFilename = exports.getFileExtension = exports.getCompletePersonProfile = exports.formatPersonName = exports.formatDate = exports.extractFileUrl = exports.extractEmailDomain = exports.exportAllPeopleData = exports.emailDomainsMatch = exports.DEFAULT_TRUST_WINDOW = exports.createWorkflowCardWithNote = exports.createPersonWithContact = exports.calculateTrust = exports.calculateAge = exports.buildQueryParams = exports.withTimeout = exports.TIMEOUT_CONFIG = exports.retryWithExponentialBackoff = exports.executeBulkOperation = exports.DEFAULT_RETRY_CONFIG = exports.createErrorReport = exports.classifyError = exports.CircuitBreaker = exports.attemptRecovery = exports.updatePersonAddress = exports.updatePerson = exports.getWorkflows = exports.getWorkflowCards = exports.getWorkflowCardNotes = exports.getWorkflow = exports.getPersonSocialProfiles = exports.getPersonPhoneNumbers = exports.getPersonFieldData = exports.getPersonEmails = exports.getPersonAddresses = exports.getPerson = exports.getPeople = exports.getOrganization = exports.getNotes = void 0;
20
+ exports.createSlowMockClient = exports.createErrorMockClient = exports.createTestClient = exports.createRecordingClient = exports.createMockClient = exports.RequestRecorder = exports.MockResponseBuilder = exports.MockPcoClient = exports.streamPeopleData = exports.processLargeDataset = exports.processInBatches = exports.PerformanceMonitor = exports.monitorPerformance = exports.getCachedPeople = exports.fetchAllPages = exports.batchFetchPersonDetails = exports.ApiCache = exports.AdaptiveRateLimiter = exports.DEFAULT_AGGRESSIVE_RETRY_CONFIG = exports.DEFAULT_INITIAL_RETRY_CONFIG = exports.validatePersonData = exports.validateContactSimilarity = exports.searchPeople = exports.processFileValue = exports.phoneNumbersSimilar = void 0;
21
21
  // Main client class
22
22
  var client_1 = require("./client");
23
23
  Object.defineProperty(exports, "PcoClient", { enumerable: true, get: function () { return client_1.PcoClient; } });
@@ -113,9 +113,13 @@ Object.defineProperty(exports, "withTimeout", { enumerable: true, get: function
113
113
  var helpers_1 = require("./helpers");
114
114
  Object.defineProperty(exports, "buildQueryParams", { enumerable: true, get: function () { return helpers_1.buildQueryParams; } });
115
115
  Object.defineProperty(exports, "calculateAge", { enumerable: true, get: function () { return helpers_1.calculateAge; } });
116
+ Object.defineProperty(exports, "calculateTrust", { enumerable: true, get: function () { return helpers_1.calculateTrust; } });
116
117
  Object.defineProperty(exports, "createPersonWithContact", { enumerable: true, get: function () { return helpers_1.createPersonWithContact; } });
117
118
  Object.defineProperty(exports, "createWorkflowCardWithNote", { enumerable: true, get: function () { return helpers_1.createWorkflowCardWithNote; } });
119
+ Object.defineProperty(exports, "DEFAULT_TRUST_WINDOW", { enumerable: true, get: function () { return helpers_1.DEFAULT_TRUST_WINDOW; } });
120
+ Object.defineProperty(exports, "emailDomainsMatch", { enumerable: true, get: function () { return helpers_1.emailDomainsMatch; } });
118
121
  Object.defineProperty(exports, "exportAllPeopleData", { enumerable: true, get: function () { return helpers_1.exportAllPeopleData; } });
122
+ Object.defineProperty(exports, "extractEmailDomain", { enumerable: true, get: function () { return helpers_1.extractEmailDomain; } });
119
123
  Object.defineProperty(exports, "extractFileUrl", { enumerable: true, get: function () { return helpers_1.extractFileUrl; } });
120
124
  Object.defineProperty(exports, "formatDate", { enumerable: true, get: function () { return helpers_1.formatDate; } });
121
125
  Object.defineProperty(exports, "formatPersonName", { enumerable: true, get: function () { return helpers_1.formatPersonName; } });
@@ -133,9 +137,15 @@ Object.defineProperty(exports, "isValidEmail", { enumerable: true, get: function
133
137
  Object.defineProperty(exports, "isValidPhone", { enumerable: true, get: function () { return helpers_1.isValidPhone; } });
134
138
  Object.defineProperty(exports, "normalizeEmail", { enumerable: true, get: function () { return helpers_1.normalizeEmail; } });
135
139
  Object.defineProperty(exports, "normalizePhone", { enumerable: true, get: function () { return helpers_1.normalizePhone; } });
140
+ Object.defineProperty(exports, "phoneNumbersSimilar", { enumerable: true, get: function () { return helpers_1.phoneNumbersSimilar; } });
136
141
  Object.defineProperty(exports, "processFileValue", { enumerable: true, get: function () { return helpers_1.processFileValue; } });
137
142
  Object.defineProperty(exports, "searchPeople", { enumerable: true, get: function () { return helpers_1.searchPeople; } });
143
+ Object.defineProperty(exports, "validateContactSimilarity", { enumerable: true, get: function () { return helpers_1.validateContactSimilarity; } });
138
144
  Object.defineProperty(exports, "validatePersonData", { enumerable: true, get: function () { return helpers_1.validatePersonData; } });
145
+ // ===== Matching Configuration =====
146
+ var people_2 = require("./modules/people");
147
+ Object.defineProperty(exports, "DEFAULT_INITIAL_RETRY_CONFIG", { enumerable: true, get: function () { return people_2.DEFAULT_INITIAL_RETRY_CONFIG; } });
148
+ Object.defineProperty(exports, "DEFAULT_AGGRESSIVE_RETRY_CONFIG", { enumerable: true, get: function () { return people_2.DEFAULT_AGGRESSIVE_RETRY_CONFIG; } });
139
149
  // ===== Performance Optimization =====
140
150
  var performance_1 = require("./performance");
141
151
  Object.defineProperty(exports, "AdaptiveRateLimiter", { enumerable: true, get: function () { return performance_1.AdaptiveRateLimiter; } });
@@ -15,6 +15,10 @@ export declare class PersonMatcher {
15
15
  private strategies;
16
16
  private scorer;
17
17
  constructor(peopleModule: PeopleModule);
18
+ /**
19
+ * Resolve retry configuration from options, with defaults
20
+ */
21
+ private resolveRetryConfig;
18
22
  /**
19
23
  * Find or create a person with smart matching
20
24
  *
@@ -23,23 +27,66 @@ export declare class PersonMatcher {
23
27
  * - Only uses name matching when appropriate (multiple people share contact info, or no contact info provided)
24
28
  * - Can automatically add missing contact information when a match is found (if addMissingContactInfo is true)
25
29
  * - Retries with exponential backoff when contacts may not be verified yet (PCO takes 30-90+ seconds)
30
+ * - Supports multi-step search strategy for maximum matching success
26
31
  *
27
32
  * @param options - Matching options
28
33
  * @param options.addMissingContactInfo - If true, automatically adds missing email/phone to matched person's profile
29
34
  * @param options.retryConfig - Configuration for retry logic to handle PCO contact verification delays
35
+ * @param options.searchStrategy - 'single' for standard search, 'multi-step' for trying multiple strategies
30
36
  */
31
37
  findOrCreate(options: PersonMatchOptions): Promise<PersonResource>;
38
+ /**
39
+ * Final aggressive search before creating a new person
40
+ *
41
+ * This is a safeguard to prevent duplicate person creation when:
42
+ * - PCO hasn't indexed contacts yet (15-30 minute delay)
43
+ * - Multiple workers are processing the same person
44
+ * - The fast path didn't find an existing person
45
+ */
46
+ private findWithAggressiveRetry;
47
+ /**
48
+ * Multi-step search strategy configuration
49
+ */
50
+ private static readonly MULTI_STEP_STRATEGIES;
51
+ /**
52
+ * Find a match using multi-step search strategy
53
+ *
54
+ * Tries multiple matching strategies in order until a match is found:
55
+ * 1. Fuzzy with age preference (handles name variations, prefers adults)
56
+ * 2. Fuzzy without age preference (catches single matches filtered by age)
57
+ * 3. Exact with age preference (high confidence, prefers adults)
58
+ * 4. Exact without age preference (high confidence, any age)
59
+ *
60
+ * This approach maximizes matching success while maintaining quality.
61
+ */
62
+ findMatchMultiStep(options: PersonMatchOptions): Promise<MatchResult | null>;
32
63
  /**
33
64
  * Find or create with retry logic to handle PCO contact verification delays
34
65
  *
35
66
  * PCO takes 30-90+ seconds to verify/index contacts after a person is created.
36
67
  * This method retries with exponential backoff to give PCO time to process contacts.
68
+ *
69
+ * Supports phase-specific retry configurations via retryConfigs:
70
+ * - initial: Quick search (default 30s)
71
+ * - aggressive: Final search before create (default 60s)
37
72
  */
38
73
  private findOrCreateWithRetry;
39
74
  /**
40
75
  * Find the best match for a person
41
76
  */
42
77
  findMatch(options: PersonMatchOptions): Promise<MatchResult | null>;
78
+ /**
79
+ * Find a match by name with contact validation
80
+ *
81
+ * This is a fallback when email/phone search fails. It searches by name
82
+ * but validates that the person's contact info is similar to prevent
83
+ * wrong-person matches (e.g., two people named "John Smith").
84
+ */
85
+ private findMatchByNameWithContactValidation;
86
+ /**
87
+ * Validate a candidate's contact info based on the validation strategy
88
+ */
89
+ private validateCandidateContact;
43
90
  /**
44
91
  * Get potential matching candidates
45
92
  * @deprecated Use findMatch which has improved logic for separating verified matches from name-only matches
@@ -4,6 +4,7 @@
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.PersonMatcher = void 0;
7
+ const people_1 = require("../modules/people");
7
8
  const strategies_1 = require("./strategies");
8
9
  const scoring_1 = require("./scoring");
9
10
  const helpers_1 = require("../helpers");
@@ -13,6 +14,19 @@ class PersonMatcher {
13
14
  this.strategies = new strategies_1.MatchStrategies();
14
15
  this.scorer = new scoring_1.MatchScorer(peopleModule);
15
16
  }
17
+ /**
18
+ * Resolve retry configuration from options, with defaults
19
+ */
20
+ resolveRetryConfig(explicitConfig, phaseConfig, defaults = people_1.DEFAULT_INITIAL_RETRY_CONFIG) {
21
+ // Phase config takes precedence over explicit config
22
+ const config = phaseConfig || explicitConfig;
23
+ return {
24
+ maxRetries: config?.maxRetries ?? defaults.maxRetries,
25
+ maxWaitTime: config?.maxWaitTime ?? defaults.maxWaitTime,
26
+ initialDelay: config?.initialDelay ?? defaults.initialDelay,
27
+ backoffMultiplier: config?.backoffMultiplier ?? defaults.backoffMultiplier,
28
+ };
29
+ }
16
30
  /**
17
31
  * Find or create a person with smart matching
18
32
  *
@@ -21,13 +35,15 @@ class PersonMatcher {
21
35
  * - Only uses name matching when appropriate (multiple people share contact info, or no contact info provided)
22
36
  * - Can automatically add missing contact information when a match is found (if addMissingContactInfo is true)
23
37
  * - Retries with exponential backoff when contacts may not be verified yet (PCO takes 30-90+ seconds)
38
+ * - Supports multi-step search strategy for maximum matching success
24
39
  *
25
40
  * @param options - Matching options
26
41
  * @param options.addMissingContactInfo - If true, automatically adds missing email/phone to matched person's profile
27
42
  * @param options.retryConfig - Configuration for retry logic to handle PCO contact verification delays
43
+ * @param options.searchStrategy - 'single' for standard search, 'multi-step' for trying multiple strategies
28
44
  */
29
45
  async findOrCreate(options) {
30
- const { createIfNotFound = true, matchStrategy = 'fuzzy', addMissingContactInfo = false, retryConfig, ...searchOptions } = options;
46
+ const { createIfNotFound = true, matchStrategy = 'fuzzy', searchStrategy = 'single', addMissingContactInfo = false, retryConfig, ...searchOptions } = options;
31
47
  // Determine if retry logic should be enabled
32
48
  // Retry is useful when:
33
49
  // 1. We have email/phone (these need verification)
@@ -40,8 +56,14 @@ class PersonMatcher {
40
56
  if (shouldRetry) {
41
57
  return this.findOrCreateWithRetry(options);
42
58
  }
43
- // Try to find existing person
44
- const match = await this.findMatch({ ...searchOptions, matchStrategy });
59
+ // Try to find existing person using appropriate search strategy
60
+ let match = null;
61
+ if (searchStrategy === 'multi-step') {
62
+ match = await this.findMatchMultiStep(options);
63
+ }
64
+ else {
65
+ match = await this.findMatch({ ...searchOptions, matchStrategy });
66
+ }
45
67
  if (match) {
46
68
  const person = match.person;
47
69
  // Add missing contact information if requested
@@ -52,29 +74,150 @@ class PersonMatcher {
52
74
  }
53
75
  // Create new person if not found and creation is enabled
54
76
  if (createIfNotFound) {
77
+ // If aggressive retry config is provided, do a final aggressive search before creating
78
+ // This is a safeguard to prevent duplicates when PCO hasn't indexed contacts yet
79
+ if (options.retryConfigs?.aggressive) {
80
+ const aggressiveMatch = await this.findWithAggressiveRetry(options);
81
+ if (aggressiveMatch) {
82
+ const person = aggressiveMatch.person;
83
+ if (addMissingContactInfo) {
84
+ await this.addMissingContactInfo(person, options);
85
+ }
86
+ return person;
87
+ }
88
+ }
55
89
  return this.createPerson(options);
56
90
  }
57
91
  throw new Error(`No matching person found and creation is disabled`);
58
92
  }
93
+ /**
94
+ * Final aggressive search before creating a new person
95
+ *
96
+ * This is a safeguard to prevent duplicate person creation when:
97
+ * - PCO hasn't indexed contacts yet (15-30 minute delay)
98
+ * - Multiple workers are processing the same person
99
+ * - The fast path didn't find an existing person
100
+ */
101
+ async findWithAggressiveRetry(options) {
102
+ const { matchStrategy = 'fuzzy', searchStrategy = 'single', retryConfigs } = options;
103
+ const aggressiveConfig = this.resolveRetryConfig(undefined, retryConfigs?.aggressive, people_1.DEFAULT_AGGRESSIVE_RETRY_CONFIG);
104
+ console.log(`[PERSON_MATCH] Starting aggressive final search before create`, {
105
+ maxWaitTime: aggressiveConfig.maxWaitTime,
106
+ maxRetries: aggressiveConfig.maxRetries,
107
+ });
108
+ let totalWaitTime = 0;
109
+ for (let attempt = 1; attempt <= aggressiveConfig.maxRetries; attempt++) {
110
+ try {
111
+ let match = null;
112
+ if (searchStrategy === 'multi-step') {
113
+ match = await this.findMatchMultiStep(options);
114
+ }
115
+ else {
116
+ match = await this.findMatch({ ...options, matchStrategy });
117
+ }
118
+ if (match) {
119
+ console.log(`[PERSON_MATCH] Aggressive search found person (would have created duplicate)`, {
120
+ personId: match.person.id,
121
+ attempt,
122
+ totalWaitTime,
123
+ });
124
+ return match;
125
+ }
126
+ }
127
+ catch (error) {
128
+ console.warn(`[PERSON_MATCH] Aggressive search attempt ${attempt} failed:`, error);
129
+ }
130
+ // Don't wait on the last attempt
131
+ if (attempt === aggressiveConfig.maxRetries) {
132
+ break;
133
+ }
134
+ // Calculate delay with exponential backoff
135
+ const delay = Math.min(aggressiveConfig.initialDelay * Math.pow(aggressiveConfig.backoffMultiplier, attempt - 1), aggressiveConfig.maxWaitTime - totalWaitTime);
136
+ if (totalWaitTime + delay > aggressiveConfig.maxWaitTime) {
137
+ break;
138
+ }
139
+ totalWaitTime += delay;
140
+ await new Promise(resolve => setTimeout(resolve, delay));
141
+ }
142
+ console.log(`[PERSON_MATCH] Aggressive search completed - no match found, safe to create`, {
143
+ totalWaitTime,
144
+ maxRetries: aggressiveConfig.maxRetries,
145
+ });
146
+ return null;
147
+ }
148
+ /**
149
+ * Find a match using multi-step search strategy
150
+ *
151
+ * Tries multiple matching strategies in order until a match is found:
152
+ * 1. Fuzzy with age preference (handles name variations, prefers adults)
153
+ * 2. Fuzzy without age preference (catches single matches filtered by age)
154
+ * 3. Exact with age preference (high confidence, prefers adults)
155
+ * 4. Exact without age preference (high confidence, any age)
156
+ *
157
+ * This approach maximizes matching success while maintaining quality.
158
+ */
159
+ async findMatchMultiStep(options) {
160
+ const { agePreference, agePreferenceLenient, ...baseOptions } = options;
161
+ for (const strategy of PersonMatcher.MULTI_STEP_STRATEGIES) {
162
+ try {
163
+ const searchOptions = {
164
+ ...baseOptions,
165
+ matchStrategy: strategy.matchStrategy,
166
+ };
167
+ // Apply age preference only when specified by strategy
168
+ if (strategy.useAgePreference && agePreference) {
169
+ searchOptions.agePreference = agePreference;
170
+ // Use lenient mode when specified, so profiles without birthdates are included
171
+ searchOptions.agePreferenceLenient = agePreferenceLenient ?? true;
172
+ }
173
+ const match = await this.findMatch(searchOptions);
174
+ if (match) {
175
+ console.log(`[PERSON_MATCH] Multi-step search found match using ${strategy.description}`, {
176
+ personId: match.person.id,
177
+ score: match.score,
178
+ reason: match.reason,
179
+ });
180
+ return match;
181
+ }
182
+ }
183
+ catch (error) {
184
+ // Log but continue to next strategy
185
+ console.warn(`[PERSON_MATCH] Multi-step strategy "${strategy.description}" failed:`, error);
186
+ }
187
+ }
188
+ return null;
189
+ }
59
190
  /**
60
191
  * Find or create with retry logic to handle PCO contact verification delays
61
192
  *
62
193
  * PCO takes 30-90+ seconds to verify/index contacts after a person is created.
63
194
  * This method retries with exponential backoff to give PCO time to process contacts.
195
+ *
196
+ * Supports phase-specific retry configurations via retryConfigs:
197
+ * - initial: Quick search (default 30s)
198
+ * - aggressive: Final search before create (default 60s)
64
199
  */
65
200
  async findOrCreateWithRetry(options) {
66
- const { createIfNotFound = false, matchStrategy = 'fuzzy', addMissingContactInfo = false, retryConfig, ...searchOptions } = options;
67
- // Default retry configuration
68
- const maxRetries = retryConfig?.maxRetries ?? 5;
69
- const maxWaitTime = retryConfig?.maxWaitTime ?? 120000; // 120 seconds
70
- const initialDelay = retryConfig?.initialDelay ?? 10000; // 10 seconds
71
- const backoffMultiplier = retryConfig?.backoffMultiplier ?? 1.5;
201
+ const { createIfNotFound = false, matchStrategy = 'fuzzy', searchStrategy = 'single', addMissingContactInfo = false, retryConfig, retryConfigs, ...searchOptions } = options;
202
+ // Determine which retry configuration to use
203
+ // Priority: retryConfigs.initial > retryConfig > defaults
204
+ const effectiveRetryConfig = this.resolveRetryConfig(retryConfig, retryConfigs?.initial);
205
+ const maxRetries = effectiveRetryConfig.maxRetries;
206
+ const maxWaitTime = effectiveRetryConfig.maxWaitTime;
207
+ const initialDelay = effectiveRetryConfig.initialDelay;
208
+ const backoffMultiplier = effectiveRetryConfig.backoffMultiplier;
72
209
  let totalWaitTime = 0;
73
210
  let lastError = null;
74
211
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
75
212
  try {
76
- // Try to find existing person
77
- const match = await this.findMatch({ ...searchOptions, matchStrategy });
213
+ // Try to find existing person using appropriate search strategy
214
+ let match = null;
215
+ if (searchStrategy === 'multi-step') {
216
+ match = await this.findMatchMultiStep(options);
217
+ }
218
+ else {
219
+ match = await this.findMatch({ ...searchOptions, matchStrategy });
220
+ }
78
221
  if (match) {
79
222
  const person = match.person;
80
223
  // Add missing contact information if requested
@@ -86,7 +229,8 @@ class PersonMatcher {
86
229
  console.log(`[PERSON_MATCH] Found person after ${attempt} attempts (waited ${totalWaitTime}ms)`, {
87
230
  personId: person.id,
88
231
  attempt,
89
- totalWaitTime
232
+ totalWaitTime,
233
+ searchStrategy,
90
234
  });
91
235
  }
92
236
  return person;
@@ -133,7 +277,7 @@ class PersonMatcher {
133
277
  * Find the best match for a person
134
278
  */
135
279
  async findMatch(options) {
136
- const { matchStrategy = 'fuzzy', email, phone, firstName, lastName } = options;
280
+ const { matchStrategy = 'fuzzy', email, phone, firstName, lastName, fallbackToNameSearch = false, contactValidation = 'similarity' } = options;
137
281
  // Step 1: Try email/phone search first
138
282
  const emailPhoneMatches = [];
139
283
  const nameOnlyMatches = [];
@@ -231,11 +375,124 @@ class PersonMatcher {
231
375
  // For "exact" strategy, only return verified email/phone matches
232
376
  if (matchStrategy === 'exact') {
233
377
  const exactMatches = scoredCandidates.filter(c => c.isVerifiedContactMatch && c.score >= 0.8);
234
- return exactMatches.length > 0 ? exactMatches[0] : null;
378
+ if (exactMatches.length > 0) {
379
+ return exactMatches[0];
380
+ }
381
+ // Continue to fallback if enabled
382
+ }
383
+ else {
384
+ // Apply strategy-specific filtering
385
+ const bestMatch = this.strategies.selectBestMatch(scoredCandidates, matchStrategy);
386
+ if (bestMatch) {
387
+ return bestMatch;
388
+ }
389
+ // Continue to fallback if enabled
390
+ }
391
+ // Step 5: Fallback to name-based search with contact validation
392
+ // Only used when email/phone search fails to find a match
393
+ if (fallbackToNameSearch && firstName && lastName && (email || phone)) {
394
+ const nameSearchMatch = await this.findMatchByNameWithContactValidation(firstName, lastName, email, phone, contactValidation, options);
395
+ if (nameSearchMatch) {
396
+ return nameSearchMatch;
397
+ }
398
+ }
399
+ return null;
400
+ }
401
+ /**
402
+ * Find a match by name with contact validation
403
+ *
404
+ * This is a fallback when email/phone search fails. It searches by name
405
+ * but validates that the person's contact info is similar to prevent
406
+ * wrong-person matches (e.g., two people named "John Smith").
407
+ */
408
+ async findMatchByNameWithContactValidation(firstName, lastName, searchEmail, searchPhone, validationStrategy, options) {
409
+ try {
410
+ console.log(`[PERSON_MATCH] Attempting name-based search fallback for ${firstName} ${lastName}`);
411
+ const nameResults = await this.peopleModule.search({
412
+ name: `${firstName} ${lastName}`
413
+ });
414
+ if (nameResults.data.length === 0) {
415
+ return null;
416
+ }
417
+ // Validate each candidate's contact info
418
+ for (const candidate of nameResults.data) {
419
+ const isValid = await this.validateCandidateContact(candidate, searchEmail, searchPhone, validationStrategy);
420
+ if (isValid) {
421
+ const score = await this.scorer.scoreMatch(candidate, options);
422
+ const reason = await this.scorer.getMatchReason(candidate, options);
423
+ console.log(`[PERSON_MATCH] Name-based search found validated match`, {
424
+ personId: candidate.id,
425
+ validationStrategy,
426
+ score,
427
+ });
428
+ return {
429
+ person: candidate,
430
+ score,
431
+ reason: `name match with ${validationStrategy} contact validation, ${reason}`,
432
+ isVerifiedContactMatch: false, // Not a direct contact match
433
+ };
434
+ }
435
+ }
436
+ console.log(`[PERSON_MATCH] Name-based search found candidates but none passed contact validation`, {
437
+ candidateCount: nameResults.data.length,
438
+ validationStrategy,
439
+ });
440
+ return null;
441
+ }
442
+ catch (error) {
443
+ console.warn('[PERSON_MATCH] Name-based search fallback failed:', error);
444
+ return null;
445
+ }
446
+ }
447
+ /**
448
+ * Validate a candidate's contact info based on the validation strategy
449
+ */
450
+ async validateCandidateContact(candidate, searchEmail, searchPhone, validationStrategy) {
451
+ try {
452
+ // Get person's contact info
453
+ const [personEmails, personPhones] = await Promise.all([
454
+ this.peopleModule.getEmails(candidate.id).then(r => r.data?.map(e => e.attributes?.address || '').filter(Boolean) || []).catch(() => []),
455
+ this.peopleModule.getPhoneNumbers(candidate.id).then(r => r.data?.map(p => p.attributes?.number || '').filter(Boolean) || []).catch(() => []),
456
+ ]);
457
+ switch (validationStrategy) {
458
+ case 'strict':
459
+ // Require exact match
460
+ if (searchEmail) {
461
+ const normalizedSearch = (0, helpers_1.normalizeEmail)(searchEmail);
462
+ if (personEmails.some(e => (0, helpers_1.normalizeEmail)(e) === normalizedSearch)) {
463
+ return true;
464
+ }
465
+ }
466
+ if (searchPhone) {
467
+ const normalizedSearch = (0, helpers_1.normalizePhone)(searchPhone);
468
+ if (personPhones.some(p => (0, helpers_1.normalizePhone)(p) === normalizedSearch)) {
469
+ return true;
470
+ }
471
+ }
472
+ return false;
473
+ case 'domain':
474
+ // Require domain match for email or exact match for phone
475
+ if (searchEmail && personEmails.some(e => (0, helpers_1.emailDomainsMatch)(searchEmail, e))) {
476
+ return true;
477
+ }
478
+ if (searchPhone) {
479
+ const normalizedSearch = (0, helpers_1.normalizePhone)(searchPhone);
480
+ if (personPhones.some(p => (0, helpers_1.normalizePhone)(p) === normalizedSearch)) {
481
+ return true;
482
+ }
483
+ }
484
+ return false;
485
+ case 'similarity':
486
+ default:
487
+ // Use domain matching for email and similarity for phone
488
+ const validation = (0, helpers_1.validateContactSimilarity)(searchEmail, searchPhone, personEmails, personPhones);
489
+ return validation.isValid;
490
+ }
491
+ }
492
+ catch (error) {
493
+ console.warn(`[PERSON_MATCH] Contact validation failed for person ${candidate.id}:`, error);
494
+ return false;
235
495
  }
236
- // Apply strategy-specific filtering
237
- const bestMatch = this.strategies.selectBestMatch(scoredCandidates, matchStrategy);
238
- return bestMatch;
239
496
  }
240
497
  /**
241
498
  * Get potential matching candidates
@@ -377,7 +634,8 @@ class PersonMatcher {
377
634
  agePreference: options.agePreference,
378
635
  minAge: options.minAge,
379
636
  maxAge: options.maxAge,
380
- birthYear: options.birthYear
637
+ birthYear: options.birthYear,
638
+ agePreferenceLenient: options.agePreferenceLenient
381
639
  });
382
640
  });
383
641
  }
@@ -531,3 +789,12 @@ class PersonMatcher {
531
789
  }
532
790
  }
533
791
  exports.PersonMatcher = PersonMatcher;
792
+ /**
793
+ * Multi-step search strategy configuration
794
+ */
795
+ PersonMatcher.MULTI_STEP_STRATEGIES = [
796
+ { matchStrategy: 'fuzzy', useAgePreference: true, description: 'fuzzy with age preference' },
797
+ { matchStrategy: 'fuzzy', useAgePreference: false, description: 'fuzzy without age preference' },
798
+ { matchStrategy: 'exact', useAgePreference: true, description: 'exact with age preference' },
799
+ { matchStrategy: 'exact', useAgePreference: false, description: 'exact without age preference' },
800
+ ];
@@ -164,7 +164,8 @@ class MatchScorer {
164
164
  agePreference: options.agePreference,
165
165
  minAge: options.minAge,
166
166
  maxAge: options.maxAge,
167
- birthYear: options.birthYear
167
+ birthYear: options.birthYear,
168
+ agePreferenceLenient: options.agePreferenceLenient
168
169
  });
169
170
  if (!matches) {
170
171
  return 0; // No match
@@ -58,6 +58,16 @@ export interface PersonMatchOptions {
58
58
  * - 'aggressive': Return best match with lower threshold
59
59
  */
60
60
  matchStrategy?: 'exact' | 'fuzzy' | 'aggressive';
61
+ /**
62
+ * Search strategy for finding matches:
63
+ * - 'single': Use only the specified matchStrategy (default)
64
+ * - 'multi-step': Try multiple strategies in order until a match is found:
65
+ * 1. Fuzzy with age preference
66
+ * 2. Fuzzy without age preference
67
+ * 3. Exact with age preference
68
+ * 4. Exact without age preference
69
+ */
70
+ searchStrategy?: 'single' | 'multi-step';
61
71
  /** Campus ID to associate with the person */
62
72
  campusId?: string;
63
73
  /** If true, create a new person if no match is found (default: true) */
@@ -70,6 +80,12 @@ export interface PersonMatchOptions {
70
80
  addMissingContactInfo?: boolean;
71
81
  /** Age preference filter: 'adults' (18+), 'children' (<18), or 'any' */
72
82
  agePreference?: 'adults' | 'children' | 'any';
83
+ /**
84
+ * When true, age preference filters only apply to profiles with birthdates.
85
+ * Profiles without birthdates are included regardless of agePreference.
86
+ * When false (default), profiles without birthdates only match when agePreference is 'any'.
87
+ */
88
+ agePreferenceLenient?: boolean;
73
89
  /** Minimum age filter */
74
90
  minAge?: number;
75
91
  /** Maximum age filter */
@@ -81,19 +97,54 @@ export interface PersonMatchOptions {
81
97
  * When a person is created, PCO needs time (30-90+ seconds) to verify/index contacts
82
98
  * before they become searchable. This retry logic helps prevent duplicate person creation.
83
99
  */
84
- retryConfig?: {
85
- /** Maximum number of retry attempts (default: 5) */
86
- maxRetries?: number;
87
- /** Maximum total wait time in milliseconds (default: 120000 = 120 seconds) */
88
- maxWaitTime?: number;
89
- /** Initial delay in milliseconds before first retry (default: 10000 = 10 seconds) */
90
- initialDelay?: number;
91
- /** Multiplier for exponential backoff (default: 1.5) */
92
- backoffMultiplier?: number;
93
- /** Whether to enable retry logic (default: true when email/phone provided and createIfNotFound: false) */
94
- enabled?: boolean;
100
+ retryConfig?: RetryConfig;
101
+ /**
102
+ * Phase-specific retry configurations for advanced control.
103
+ * When both retryConfig and retryConfigs are provided, retryConfigs takes precedence.
104
+ */
105
+ retryConfigs?: {
106
+ /** Configuration for initial/quick search phase (default: 30s max wait) */
107
+ initial?: RetryConfig;
108
+ /** Configuration for aggressive search before creating (default: 60s max wait) */
109
+ aggressive?: RetryConfig;
95
110
  };
111
+ /**
112
+ * If true, fall back to name-based search when email/phone search fails.
113
+ * Requires both firstName and lastName to be provided.
114
+ * Uses contact validation to avoid wrong-person matches. (default: false)
115
+ */
116
+ fallbackToNameSearch?: boolean;
117
+ /**
118
+ * Contact validation strategy for name-based search fallback:
119
+ * - 'strict': Requires exact email or phone match
120
+ * - 'domain': Requires matching email domain or similar phone
121
+ * - 'similarity': Uses domain matching for email, similarity for phone (default)
122
+ */
123
+ contactValidation?: 'strict' | 'domain' | 'similarity';
124
+ }
125
+ /**
126
+ * Retry configuration for handling PCO contact verification delays
127
+ */
128
+ export interface RetryConfig {
129
+ /** Maximum number of retry attempts (default: 5) */
130
+ maxRetries?: number;
131
+ /** Maximum total wait time in milliseconds (default: 120000 = 120 seconds) */
132
+ maxWaitTime?: number;
133
+ /** Initial delay in milliseconds before first retry (default: 10000 = 10 seconds) */
134
+ initialDelay?: number;
135
+ /** Multiplier for exponential backoff (default: 1.5) */
136
+ backoffMultiplier?: number;
137
+ /** Whether to enable retry logic (default: true when email/phone provided and createIfNotFound: false) */
138
+ enabled?: boolean;
96
139
  }
140
+ /**
141
+ * Default retry configuration for initial search phase
142
+ */
143
+ export declare const DEFAULT_INITIAL_RETRY_CONFIG: Required<Omit<RetryConfig, 'enabled'>>;
144
+ /**
145
+ * Default retry configuration for aggressive search phase (before creating)
146
+ */
147
+ export declare const DEFAULT_AGGRESSIVE_RETRY_CONFIG: Required<Omit<RetryConfig, 'enabled'>>;
97
148
  export declare class PeopleModule extends BaseModule {
98
149
  private personMatcher;
99
150
  constructor(httpClient: PcoHttpClient, paginationHelper: PaginationHelper, eventEmitter: PcoEventEmitter);
@@ -113,6 +164,30 @@ export declare class PeopleModule extends BaseModule {
113
164
  * Get a single person by ID
114
165
  */
115
166
  getById(id: string, include?: string[]): Promise<PersonResource>;
167
+ /**
168
+ * Verify that a person exists in PCO
169
+ *
170
+ * This is useful for validating cached person IDs before use,
171
+ * especially when person records may have been merged or deleted.
172
+ *
173
+ * @param personId - The person ID to verify
174
+ * @param options - Optional configuration
175
+ * @param options.timeout - Timeout in milliseconds (default: 30000)
176
+ * @returns True if person exists, false if not found
177
+ * @throws Error if request times out or other error occurs (except 404)
178
+ *
179
+ * @example
180
+ * ```typescript
181
+ * const exists = await client.people.verifyPersonExists(cachedPersonId);
182
+ * if (!exists) {
183
+ * // Person was merged or deleted, need to search again
184
+ * const person = await client.people.findOrCreate(options);
185
+ * }
186
+ * ```
187
+ */
188
+ verifyPersonExists(personId: string, options?: {
189
+ timeout?: number;
190
+ }): Promise<boolean>;
116
191
  /**
117
192
  * Create a new person
118
193
  */
@@ -3,9 +3,27 @@
3
3
  * v2.0.0 People Module
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.PeopleModule = void 0;
6
+ exports.PeopleModule = exports.DEFAULT_AGGRESSIVE_RETRY_CONFIG = exports.DEFAULT_INITIAL_RETRY_CONFIG = void 0;
7
7
  const planning_center_base_ts_1 = require("@rachelallyson/planning-center-base-ts");
8
8
  const matcher_1 = require("../matching/matcher");
9
+ /**
10
+ * Default retry configuration for initial search phase
11
+ */
12
+ exports.DEFAULT_INITIAL_RETRY_CONFIG = {
13
+ maxRetries: 3,
14
+ maxWaitTime: 30000, // 30 seconds
15
+ initialDelay: 3000,
16
+ backoffMultiplier: 2,
17
+ };
18
+ /**
19
+ * Default retry configuration for aggressive search phase (before creating)
20
+ */
21
+ exports.DEFAULT_AGGRESSIVE_RETRY_CONFIG = {
22
+ maxRetries: 6,
23
+ maxWaitTime: 60000, // 60 seconds
24
+ initialDelay: 5000,
25
+ backoffMultiplier: 2,
26
+ };
9
27
  class PeopleModule extends planning_center_base_ts_1.BaseModule {
10
28
  constructor(httpClient, paginationHelper, eventEmitter) {
11
29
  super(httpClient, paginationHelper, eventEmitter);
@@ -57,6 +75,46 @@ class PeopleModule extends planning_center_base_ts_1.BaseModule {
57
75
  }
58
76
  return this.getSingle(`/people/${id}`, params);
59
77
  }
78
+ /**
79
+ * Verify that a person exists in PCO
80
+ *
81
+ * This is useful for validating cached person IDs before use,
82
+ * especially when person records may have been merged or deleted.
83
+ *
84
+ * @param personId - The person ID to verify
85
+ * @param options - Optional configuration
86
+ * @param options.timeout - Timeout in milliseconds (default: 30000)
87
+ * @returns True if person exists, false if not found
88
+ * @throws Error if request times out or other error occurs (except 404)
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * const exists = await client.people.verifyPersonExists(cachedPersonId);
93
+ * if (!exists) {
94
+ * // Person was merged or deleted, need to search again
95
+ * const person = await client.people.findOrCreate(options);
96
+ * }
97
+ * ```
98
+ */
99
+ async verifyPersonExists(personId, options) {
100
+ const timeout = options?.timeout ?? 30000;
101
+ const verificationPromise = this.getById(personId)
102
+ .then(() => true)
103
+ .catch((error) => {
104
+ // 404 means person doesn't exist (merged or deleted)
105
+ if (error?.status === 404 || error?.response?.status === 404) {
106
+ return false;
107
+ }
108
+ // Re-throw other errors
109
+ throw error;
110
+ });
111
+ const timeoutPromise = new Promise((_, reject) => {
112
+ setTimeout(() => {
113
+ reject(new Error(`Person verification timed out after ${timeout}ms`));
114
+ }, timeout);
115
+ });
116
+ return Promise.race([verificationPromise, timeoutPromise]);
117
+ }
60
118
  /**
61
119
  * Create a new person
62
120
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rachelallyson/planning-center-people-ts",
3
- "version": "2.12.2",
3
+ "version": "2.14.0",
4
4
  "description": "A strictly typed TypeScript client for Planning Center Online People API with comprehensive functionality and enhanced developer experience",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",