@rachelallyson/planning-center-people-ts 2.9.1 โ†’ 2.11.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,66 @@ 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.11.0] - 2025-01-15
9
+
10
+ ### โœจ **New Features**
11
+
12
+ - **๐Ÿ”„ Automatic Retry Logic for Contact Verification**: Added built-in retry logic to prevent duplicate person creation
13
+ - Automatically retries when searching for existing persons with email/phone
14
+ - Handles PCO contact verification delays (30-90+ seconds)
15
+ - Uses exponential backoff (10s โ†’ 15s โ†’ 22.5s โ†’ 33.75s โ†’ 50.6s)
16
+ - Configurable retry behavior via `retryConfig` option
17
+ - Default: 5 retries, 120 seconds max wait time
18
+ - Logs retry attempts with `[PERSON_MATCH]` prefix for debugging
19
+ - Prevents duplicate person creation when PCO takes time to verify/index contacts
20
+
21
+ ### ๐Ÿ”ง **Improvements**
22
+
23
+ - **Duplicate Prevention**: `findOrCreate` now automatically handles PCO contact verification delays
24
+ - Retry logic activates automatically when `createIfNotFound: false` and email/phone are provided
25
+ - No manual retry code needed - library handles it automatically
26
+ - Prevents race conditions where duplicate persons are created
27
+
28
+ ### ๐Ÿ“š **Documentation**
29
+
30
+ - Added `RETRY_LOGIC_FIX.md` - Detailed explanation of the retry logic fix
31
+ - Added `USING_RETRY_LOGIC.md` - Guide for using retry logic in your app
32
+ - Added `MIGRATION_GUIDE_FOR_YOUR_APP.md` - Migration guide to simplify existing code
33
+ - Added `TEST_FAILURE_ANALYSIS.md` - Troubleshooting guide for test failures
34
+
35
+ ### ๐Ÿงช **Testing**
36
+
37
+ - Added comprehensive integration tests for retry logic
38
+ - Tests verify retry logic prevents duplicate person creation
39
+ - Tests demonstrate bug scenario and fix
40
+
41
+ ## [2.10.0] - 2025-01-15
42
+
43
+ ### โœจ **New Features**
44
+
45
+ - **๐Ÿ“ง Email Normalization & Validation**: Added email normalization and format validation to improve search accuracy
46
+ - New `normalizeEmail()` helper function (lowercase and trim)
47
+ - Email is now normalized before search to improve PCO API search results
48
+ - Email format validation prevents wasted API calls on invalid emails
49
+ - **๐Ÿ“ฑ Phone Normalization**: Added phone normalization to improve search accuracy
50
+ - New `normalizePhone()` helper function (normalizes to `+1XXXXXXXXXX` format)
51
+ - Phone numbers are now normalized before search to improve PCO API search results
52
+ - **โœ… First Name Validation**: Added firstName validation in person creation
53
+ - Validates firstName is required before attempting person creation
54
+ - Provides clearer error messages: "First name is required to create a person"
55
+ - Fails fast instead of waiting for API error response
56
+
57
+ ### ๐Ÿ”ง **Improvements**
58
+
59
+ - **Normalization Consistency**: Refactored normalization logic into reusable helper functions
60
+ - All email/phone normalization now uses consistent helper functions
61
+ - Updated both `matcher.ts` and `scoring.ts` to use shared normalization functions
62
+ - Removed duplicate inline normalization code
63
+
64
+ ### ๐Ÿ“ฆ **Exports**
65
+
66
+ - Exported `normalizeEmail` and `normalizePhone` helper functions from main package index for library users
67
+
8
68
  ## [2.9.1] - 2025-01-14
9
69
 
10
70
  ### ๐Ÿ› **Bug Fixes**
package/dist/helpers.d.ts CHANGED
@@ -44,10 +44,21 @@ export declare function calculateBirthYearFromAge(age: number): number;
44
44
  * Validate email format
45
45
  */
46
46
  export declare function isValidEmail(email: string): boolean;
47
+ /**
48
+ * Normalize email address (lowercase and trim)
49
+ */
50
+ export declare function normalizeEmail(email: string): string;
47
51
  /**
48
52
  * Validate phone number format (basic validation)
49
53
  */
50
54
  export declare function isValidPhone(phone: string): boolean;
55
+ /**
56
+ * Normalize phone number to +1XXXXXXXXXX format
57
+ * - 10 digits: adds +1 prefix
58
+ * - 11 digits starting with 1: adds + prefix
59
+ * - Other lengths: adds + prefix to all digits
60
+ */
61
+ export declare function normalizePhone(phone: string): string;
51
62
  /**
52
63
  * Format person name from attributes
53
64
  */
package/dist/helpers.js CHANGED
@@ -8,7 +8,9 @@ exports.isChild = isChild;
8
8
  exports.matchesAgeCriteria = matchesAgeCriteria;
9
9
  exports.calculateBirthYearFromAge = calculateBirthYearFromAge;
10
10
  exports.isValidEmail = isValidEmail;
11
+ exports.normalizeEmail = normalizeEmail;
11
12
  exports.isValidPhone = isValidPhone;
13
+ exports.normalizePhone = normalizePhone;
12
14
  exports.formatPersonName = formatPersonName;
13
15
  exports.formatDate = formatDate;
14
16
  exports.validatePersonData = validatePersonData;
@@ -137,6 +139,12 @@ function isValidEmail(email) {
137
139
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
138
140
  return emailRegex.test(email);
139
141
  }
142
+ /**
143
+ * Normalize email address (lowercase and trim)
144
+ */
145
+ function normalizeEmail(email) {
146
+ return email.toLowerCase().trim();
147
+ }
140
148
  /**
141
149
  * Validate phone number format (basic validation)
142
150
  */
@@ -144,6 +152,22 @@ function isValidPhone(phone) {
144
152
  const phoneRegex = /^[\+]?[1-9][\d]{6,14}$/;
145
153
  return phoneRegex.test(phone.replace(/[\s\-\(\)]/g, ''));
146
154
  }
155
+ /**
156
+ * Normalize phone number to +1XXXXXXXXXX format
157
+ * - 10 digits: adds +1 prefix
158
+ * - 11 digits starting with 1: adds + prefix
159
+ * - Other lengths: adds + prefix to all digits
160
+ */
161
+ function normalizePhone(phone) {
162
+ const digits = phone.replace(/\D/g, '');
163
+ if (digits.length === 10) {
164
+ return `+1${digits}`;
165
+ }
166
+ if (digits.length === 11 && digits.startsWith('1')) {
167
+ return `+${digits}`;
168
+ }
169
+ return `+${digits}`;
170
+ }
147
171
  /**
148
172
  * Format person name from attributes
149
173
  */
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ 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, 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, processFileValue, searchPeople, validatePersonData, } from './helpers';
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';
24
24
  export { AdaptiveRateLimiter, ApiCache, batchFetchPersonDetails, fetchAllPages, getCachedPeople, monitorPerformance, PerformanceMonitor, processInBatches, processLargeDataset, streamPeopleData, } from './performance';
25
25
  export { MockPcoClient, MockResponseBuilder, RequestRecorder, createMockClient, createRecordingClient, createTestClient, createErrorMockClient, createSlowMockClient, } from './testing';
26
26
  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.getNotes = exports.getNoteCategories = exports.getNote = 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.fetchAllPages = exports.batchFetchPersonDetails = exports.ApiCache = exports.AdaptiveRateLimiter = exports.validatePersonData = exports.searchPeople = exports.processFileValue = 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 = 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 = void 0;
19
+ exports.ApiCache = 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 = 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 = 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; } });
@@ -130,6 +130,8 @@ Object.defineProperty(exports, "isFileUpload", { enumerable: true, get: function
130
130
  Object.defineProperty(exports, "isFileUrl", { enumerable: true, get: function () { return helpers_1.isFileUrl; } });
131
131
  Object.defineProperty(exports, "isValidEmail", { enumerable: true, get: function () { return helpers_1.isValidEmail; } });
132
132
  Object.defineProperty(exports, "isValidPhone", { enumerable: true, get: function () { return helpers_1.isValidPhone; } });
133
+ Object.defineProperty(exports, "normalizeEmail", { enumerable: true, get: function () { return helpers_1.normalizeEmail; } });
134
+ Object.defineProperty(exports, "normalizePhone", { enumerable: true, get: function () { return helpers_1.normalizePhone; } });
133
135
  Object.defineProperty(exports, "processFileValue", { enumerable: true, get: function () { return helpers_1.processFileValue; } });
134
136
  Object.defineProperty(exports, "searchPeople", { enumerable: true, get: function () { return helpers_1.searchPeople; } });
135
137
  Object.defineProperty(exports, "validatePersonData", { enumerable: true, get: function () { return helpers_1.validatePersonData; } });
@@ -22,11 +22,20 @@ export declare class PersonMatcher {
22
22
  * - Verifies email/phone matches by checking actual contact information
23
23
  * - Only uses name matching when appropriate (multiple people share contact info, or no contact info provided)
24
24
  * - Can automatically add missing contact information when a match is found (if addMissingContactInfo is true)
25
+ * - Retries with exponential backoff when contacts may not be verified yet (PCO takes 30-90+ seconds)
25
26
  *
26
27
  * @param options - Matching options
27
28
  * @param options.addMissingContactInfo - If true, automatically adds missing email/phone to matched person's profile
29
+ * @param options.retryConfig - Configuration for retry logic to handle PCO contact verification delays
28
30
  */
29
31
  findOrCreate(options: PersonMatchOptions): Promise<PersonResource>;
32
+ /**
33
+ * Find or create with retry logic to handle PCO contact verification delays
34
+ *
35
+ * PCO takes 30-90+ seconds to verify/index contacts after a person is created.
36
+ * This method retries with exponential backoff to give PCO time to process contacts.
37
+ */
38
+ private findOrCreateWithRetry;
30
39
  /**
31
40
  * Find the best match for a person
32
41
  */
@@ -20,12 +20,26 @@ class PersonMatcher {
20
20
  * - Verifies email/phone matches by checking actual contact information
21
21
  * - Only uses name matching when appropriate (multiple people share contact info, or no contact info provided)
22
22
  * - Can automatically add missing contact information when a match is found (if addMissingContactInfo is true)
23
+ * - Retries with exponential backoff when contacts may not be verified yet (PCO takes 30-90+ seconds)
23
24
  *
24
25
  * @param options - Matching options
25
26
  * @param options.addMissingContactInfo - If true, automatically adds missing email/phone to matched person's profile
27
+ * @param options.retryConfig - Configuration for retry logic to handle PCO contact verification delays
26
28
  */
27
29
  async findOrCreate(options) {
28
- const { createIfNotFound = true, matchStrategy = 'fuzzy', addMissingContactInfo = false, ...searchOptions } = options;
30
+ const { createIfNotFound = true, matchStrategy = 'fuzzy', addMissingContactInfo = false, retryConfig, ...searchOptions } = options;
31
+ // Determine if retry logic should be enabled
32
+ // Retry is useful when:
33
+ // 1. We have email/phone (these need verification)
34
+ // 2. createIfNotFound is false (we're trying to find existing, not create new)
35
+ // 3. retryConfig.enabled is not explicitly false
36
+ const hasContactInfo = !!(options.email || options.phone);
37
+ const shouldRetry = hasContactInfo &&
38
+ !createIfNotFound &&
39
+ (retryConfig?.enabled !== false);
40
+ if (shouldRetry) {
41
+ return this.findOrCreateWithRetry(options);
42
+ }
29
43
  // Try to find existing person
30
44
  const match = await this.findMatch({ ...searchOptions, matchStrategy });
31
45
  if (match) {
@@ -42,6 +56,79 @@ class PersonMatcher {
42
56
  }
43
57
  throw new Error(`No matching person found and creation is disabled`);
44
58
  }
59
+ /**
60
+ * Find or create with retry logic to handle PCO contact verification delays
61
+ *
62
+ * PCO takes 30-90+ seconds to verify/index contacts after a person is created.
63
+ * This method retries with exponential backoff to give PCO time to process contacts.
64
+ */
65
+ 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;
72
+ let totalWaitTime = 0;
73
+ let lastError = null;
74
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
75
+ try {
76
+ // Try to find existing person
77
+ const match = await this.findMatch({ ...searchOptions, matchStrategy });
78
+ if (match) {
79
+ const person = match.person;
80
+ // Add missing contact information if requested
81
+ if (addMissingContactInfo) {
82
+ await this.addMissingContactInfo(person, options);
83
+ }
84
+ // Log success if we had to retry
85
+ if (attempt > 1) {
86
+ console.log(`[PERSON_MATCH] Found person after ${attempt} attempts (waited ${totalWaitTime}ms)`, {
87
+ personId: person.id,
88
+ attempt,
89
+ totalWaitTime
90
+ });
91
+ }
92
+ return person;
93
+ }
94
+ // No match found - this might be because contacts aren't verified yet
95
+ lastError = new Error(`No matching person found and creation is disabled`);
96
+ }
97
+ catch (error) {
98
+ lastError = error instanceof Error ? error : new Error(String(error));
99
+ }
100
+ // Don't retry on the last attempt
101
+ if (attempt === maxRetries) {
102
+ break;
103
+ }
104
+ // Calculate delay with exponential backoff
105
+ const delay = Math.min(initialDelay * Math.pow(backoffMultiplier, attempt - 1), maxWaitTime - totalWaitTime // Don't exceed maxWaitTime
106
+ );
107
+ // Check if we've exceeded max wait time
108
+ if (totalWaitTime + delay > maxWaitTime) {
109
+ console.warn(`[PERSON_MATCH] Max wait time (${maxWaitTime}ms) exceeded, stopping retries`, {
110
+ attempt,
111
+ totalWaitTime,
112
+ remainingDelay: maxWaitTime - totalWaitTime
113
+ });
114
+ break;
115
+ }
116
+ totalWaitTime += delay;
117
+ // Log retry attempt
118
+ console.log(`[PERSON_MATCH] Attempt ${attempt} failed, retrying in ${delay}ms`, {
119
+ attempt,
120
+ delay,
121
+ totalWaitTime,
122
+ errorMessage: lastError?.message,
123
+ maxRetries,
124
+ maxWaitTime
125
+ });
126
+ // Wait before retrying
127
+ await new Promise(resolve => setTimeout(resolve, delay));
128
+ }
129
+ // All retries exhausted - throw error
130
+ throw lastError || new Error(`No matching person found after ${maxRetries} attempts (waited ${totalWaitTime}ms) and creation is disabled`);
131
+ }
45
132
  /**
46
133
  * Find the best match for a person
47
134
  */
@@ -50,20 +137,30 @@ class PersonMatcher {
50
137
  // Step 1: Try email/phone search first
51
138
  const emailPhoneMatches = [];
52
139
  const nameOnlyMatches = [];
53
- // Search by email
140
+ // Search by email (with normalization and validation)
54
141
  if (email) {
55
- try {
56
- const emailResults = await this.peopleModule.search({ email });
57
- emailPhoneMatches.push(...emailResults.data);
142
+ // Validate email format to avoid wasted API calls
143
+ if (!(0, helpers_1.isValidEmail)(email)) {
144
+ console.warn('Invalid email format, skipping email search:', email);
58
145
  }
59
- catch (error) {
60
- console.warn('Email search failed:', error);
146
+ else {
147
+ try {
148
+ // Normalize email before search to improve PCO search results
149
+ const normalizedEmail = (0, helpers_1.normalizeEmail)(email);
150
+ const emailResults = await this.peopleModule.search({ email: normalizedEmail });
151
+ emailPhoneMatches.push(...emailResults.data);
152
+ }
153
+ catch (error) {
154
+ console.warn('Email search failed:', error);
155
+ }
61
156
  }
62
157
  }
63
- // Search by phone
158
+ // Search by phone (with normalization)
64
159
  if (phone) {
65
160
  try {
66
- const phoneResults = await this.peopleModule.search({ phone });
161
+ // Normalize phone before search to improve PCO search results
162
+ const normalizedPhone = (0, helpers_1.normalizePhone)(phone);
163
+ const phoneResults = await this.peopleModule.search({ phone: normalizedPhone });
67
164
  emailPhoneMatches.push(...phoneResults.data);
68
165
  }
69
166
  catch (error) {
@@ -203,8 +300,8 @@ class PersonMatcher {
203
300
  async verifyEmailMatch(person, email) {
204
301
  try {
205
302
  const personEmails = await this.peopleModule.getEmails(person.id);
206
- const normalizedSearchEmail = email.toLowerCase().trim();
207
- const emails = personEmails.data?.map(e => e.attributes?.address?.toLowerCase().trim()).filter(Boolean) || [];
303
+ const normalizedSearchEmail = (0, helpers_1.normalizeEmail)(email);
304
+ const emails = personEmails.data?.map(e => (0, helpers_1.normalizeEmail)(e.attributes?.address || '')).filter(Boolean) || [];
208
305
  return emails.includes(normalizedSearchEmail);
209
306
  }
210
307
  catch {
@@ -217,14 +314,8 @@ class PersonMatcher {
217
314
  async verifyPhoneMatch(person, phone) {
218
315
  try {
219
316
  const personPhones = await this.peopleModule.getPhoneNumbers(person.id);
220
- const normalizePhone = (num) => {
221
- const digits = num.replace(/\D/g, '');
222
- return digits.length === 10 ? `+1${digits}` :
223
- digits.length === 11 && digits.startsWith('1') ? `+${digits}` :
224
- `+${digits}`;
225
- };
226
- const normalizedSearchPhone = normalizePhone(phone);
227
- const phones = personPhones.data?.map(p => normalizePhone(p.attributes?.number || '')).filter(Boolean) || [];
317
+ const normalizedSearchPhone = (0, helpers_1.normalizePhone)(phone);
318
+ const phones = personPhones.data?.map(p => (0, helpers_1.normalizePhone)(p.attributes?.number || '')).filter(Boolean) || [];
228
319
  return phones.includes(normalizedSearchPhone);
229
320
  }
230
321
  catch {
@@ -294,6 +385,10 @@ class PersonMatcher {
294
385
  * Create a new person
295
386
  */
296
387
  async createPerson(options) {
388
+ // Validate firstName is required for person creation
389
+ if (!options.firstName?.trim()) {
390
+ throw new Error('First name is required to create a person');
391
+ }
297
392
  // Create basic person data (only name fields)
298
393
  const personData = {};
299
394
  if (options.firstName)
@@ -98,9 +98,9 @@ class MatchScorer {
98
98
  async scoreEmailMatch(person, email) {
99
99
  try {
100
100
  const personEmails = await this.peopleModule.getEmails(person.id);
101
- const normalizedSearchEmail = email.toLowerCase().trim();
101
+ const normalizedSearchEmail = (0, helpers_1.normalizeEmail)(email);
102
102
  // Check if any of the person's emails match
103
- const emails = personEmails.data?.map(e => e.attributes?.address?.toLowerCase().trim()).filter(Boolean) || [];
103
+ const emails = personEmails.data?.map(e => (0, helpers_1.normalizeEmail)(e.attributes?.address || '')).filter(Boolean) || [];
104
104
  return emails.includes(normalizedSearchEmail) ? 1.0 : 0.0;
105
105
  }
106
106
  catch (error) {
@@ -114,15 +114,8 @@ class MatchScorer {
114
114
  async scorePhoneMatch(person, phone) {
115
115
  try {
116
116
  const personPhones = await this.peopleModule.getPhoneNumbers(person.id);
117
- // Normalize phone numbers for comparison
118
- const normalizePhone = (num) => {
119
- const digits = num.replace(/\D/g, '');
120
- return digits.length === 10 ? `+1${digits}` :
121
- digits.length === 11 && digits.startsWith('1') ? `+${digits}` :
122
- `+${digits}`;
123
- };
124
- const normalizedSearchPhone = normalizePhone(phone);
125
- const phones = personPhones.data?.map(p => normalizePhone(p.attributes?.number || '')).filter(Boolean) || [];
117
+ const normalizedSearchPhone = (0, helpers_1.normalizePhone)(phone);
118
+ const phones = personPhones.data?.map(p => (0, helpers_1.normalizePhone)(p.attributes?.number || '')).filter(Boolean) || [];
126
119
  return phones.includes(normalizedSearchPhone) ? 1.0 : 0.0;
127
120
  }
128
121
  catch (error) {
@@ -76,6 +76,23 @@ export interface PersonMatchOptions {
76
76
  maxAge?: number;
77
77
  /** Birth year filter (exact match) */
78
78
  birthYear?: number;
79
+ /**
80
+ * Retry configuration for handling PCO contact verification delays.
81
+ * When a person is created, PCO needs time (30-90+ seconds) to verify/index contacts
82
+ * before they become searchable. This retry logic helps prevent duplicate person creation.
83
+ */
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;
95
+ };
79
96
  }
80
97
  export declare class PeopleModule extends BaseModule {
81
98
  private personMatcher;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rachelallyson/planning-center-people-ts",
3
- "version": "2.9.1",
3
+ "version": "2.11.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",