@rachelallyson/planning-center-people-ts 2.7.0 → 2.9.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 +174 -0
- package/LICENSE +1 -1
- package/README.md +12 -14
- package/dist/client.d.ts +8 -8
- package/dist/client.js +8 -11
- package/dist/core/http.d.ts +1 -1
- package/dist/core/http.js +18 -11
- package/dist/core/pagination.js +16 -2
- package/dist/core.d.ts +3 -3
- package/dist/core.js +4 -4
- package/dist/error-handling.d.ts +1 -1
- package/dist/error-handling.js +3 -3
- package/dist/error-scenarios.js +4 -4
- package/dist/helpers.js +5 -4
- package/dist/index.d.ts +8 -8
- package/dist/index.js +15 -15
- package/dist/matching/matcher.d.ts +22 -0
- package/dist/matching/matcher.js +260 -27
- package/dist/matching/scoring.d.ts +9 -7
- package/dist/matching/scoring.js +54 -23
- package/dist/matching/strategies.d.ts +1 -0
- package/dist/matching/strategies.js +17 -4
- package/dist/modules/campus.d.ts +5 -5
- package/dist/modules/campus.js +2 -2
- package/dist/modules/contacts.d.ts +1 -1
- package/dist/modules/contacts.js +2 -2
- package/dist/modules/fields.d.ts +4 -4
- package/dist/modules/fields.js +2 -2
- package/dist/modules/forms.d.ts +7 -4
- package/dist/modules/forms.js +5 -2
- package/dist/modules/households.d.ts +2 -2
- package/dist/modules/households.js +2 -2
- package/dist/modules/lists.d.ts +2 -2
- package/dist/modules/lists.js +2 -2
- package/dist/modules/notes.d.ts +2 -2
- package/dist/modules/notes.js +2 -2
- package/dist/modules/people.d.ts +57 -5
- package/dist/modules/people.js +44 -9
- package/dist/modules/reports.d.ts +5 -5
- package/dist/modules/reports.js +2 -2
- package/dist/modules/service-time.d.ts +5 -5
- package/dist/modules/service-time.js +2 -2
- package/dist/modules/workflows.d.ts +2 -2
- package/dist/modules/workflows.js +2 -2
- package/dist/people/contacts.d.ts +1 -1
- package/dist/people/core.d.ts +1 -1
- package/dist/people/fields.d.ts +1 -1
- package/dist/people/fields.js +4 -4
- package/dist/people/households.d.ts +1 -1
- package/dist/people/lists.d.ts +1 -1
- package/dist/people/notes.d.ts +1 -1
- package/dist/people/organization.d.ts +1 -1
- package/dist/people/workflows.d.ts +1 -1
- package/dist/types/client.d.ts +3 -96
- package/dist/types/client.js +2 -0
- package/dist/types/index.d.ts +1 -2
- package/dist/types/index.js +0 -2
- package/package.json +16 -19
- package/dist/api-error.d.ts +0 -10
- package/dist/api-error.js +0 -32
- package/dist/batch.d.ts +0 -47
- package/dist/batch.js +0 -376
- package/dist/modules/base.d.ts +0 -46
- package/dist/modules/base.js +0 -82
- package/dist/monitoring.d.ts +0 -53
- package/dist/monitoring.js +0 -142
- package/dist/rate-limiter.d.ts +0 -79
- package/dist/rate-limiter.js +0 -137
- package/dist/types/batch.d.ts +0 -50
- package/dist/types/batch.js +0 -5
- package/dist/types/events.d.ts +0 -85
- package/dist/types/events.js +0 -5
package/dist/index.js
CHANGED
|
@@ -41,21 +41,21 @@ Object.defineProperty(exports, "attemptTokenRefresh", { enumerable: true, get: f
|
|
|
41
41
|
Object.defineProperty(exports, "hasRefreshTokenCapability", { enumerable: true, get: function () { return auth_1.hasRefreshTokenCapability; } });
|
|
42
42
|
Object.defineProperty(exports, "refreshAccessToken", { enumerable: true, get: function () { return auth_1.refreshAccessToken; } });
|
|
43
43
|
Object.defineProperty(exports, "updateClientTokens", { enumerable: true, get: function () { return auth_1.updateClientTokens; } });
|
|
44
|
-
// Export API error
|
|
45
|
-
var
|
|
46
|
-
Object.defineProperty(exports, "PcoApiError", { enumerable: true, get: function () { return
|
|
47
|
-
var
|
|
48
|
-
Object.defineProperty(exports, "PcoRateLimiter", { enumerable: true, get: function () { return
|
|
49
|
-
var
|
|
50
|
-
Object.defineProperty(exports, "ErrorCategory", { enumerable: true, get: function () { return
|
|
51
|
-
Object.defineProperty(exports, "ErrorSeverity", { enumerable: true, get: function () { return
|
|
52
|
-
Object.defineProperty(exports, "handleNetworkError", { enumerable: true, get: function () { return
|
|
53
|
-
Object.defineProperty(exports, "handleTimeoutError", { enumerable: true, get: function () { return
|
|
54
|
-
Object.defineProperty(exports, "handleValidationError", { enumerable: true, get: function () { return
|
|
55
|
-
Object.defineProperty(exports, "PcoError", { enumerable: true, get: function () { return
|
|
56
|
-
Object.defineProperty(exports, "retryWithBackoff", { enumerable: true, get: function () { return
|
|
57
|
-
Object.defineProperty(exports, "shouldNotRetry", { enumerable: true, get: function () { return
|
|
58
|
-
Object.defineProperty(exports, "withErrorBoundary", { enumerable: true, get: function () { return
|
|
44
|
+
// Export API error (re-exported from base)
|
|
45
|
+
var planning_center_base_ts_1 = require("@rachelallyson/planning-center-base-ts");
|
|
46
|
+
Object.defineProperty(exports, "PcoApiError", { enumerable: true, get: function () { return planning_center_base_ts_1.PcoApiError; } });
|
|
47
|
+
var planning_center_base_ts_2 = require("@rachelallyson/planning-center-base-ts");
|
|
48
|
+
Object.defineProperty(exports, "PcoRateLimiter", { enumerable: true, get: function () { return planning_center_base_ts_2.PcoRateLimiter; } });
|
|
49
|
+
var planning_center_base_ts_3 = require("@rachelallyson/planning-center-base-ts");
|
|
50
|
+
Object.defineProperty(exports, "ErrorCategory", { enumerable: true, get: function () { return planning_center_base_ts_3.ErrorCategory; } });
|
|
51
|
+
Object.defineProperty(exports, "ErrorSeverity", { enumerable: true, get: function () { return planning_center_base_ts_3.ErrorSeverity; } });
|
|
52
|
+
Object.defineProperty(exports, "handleNetworkError", { enumerable: true, get: function () { return planning_center_base_ts_3.handleNetworkError; } });
|
|
53
|
+
Object.defineProperty(exports, "handleTimeoutError", { enumerable: true, get: function () { return planning_center_base_ts_3.handleTimeoutError; } });
|
|
54
|
+
Object.defineProperty(exports, "handleValidationError", { enumerable: true, get: function () { return planning_center_base_ts_3.handleValidationError; } });
|
|
55
|
+
Object.defineProperty(exports, "PcoError", { enumerable: true, get: function () { return planning_center_base_ts_3.PcoError; } });
|
|
56
|
+
Object.defineProperty(exports, "retryWithBackoff", { enumerable: true, get: function () { return planning_center_base_ts_3.retryWithBackoff; } });
|
|
57
|
+
Object.defineProperty(exports, "shouldNotRetry", { enumerable: true, get: function () { return planning_center_base_ts_3.shouldNotRetry; } });
|
|
58
|
+
Object.defineProperty(exports, "withErrorBoundary", { enumerable: true, get: function () { return planning_center_base_ts_3.withErrorBoundary; } });
|
|
59
59
|
// Export People-specific functions (deprecated)
|
|
60
60
|
var people_1 = require("./people");
|
|
61
61
|
Object.defineProperty(exports, "createFieldDefinition", { enumerable: true, get: function () { return people_1.createFieldDefinition; } });
|
|
@@ -8,6 +8,7 @@ export interface MatchResult {
|
|
|
8
8
|
person: PersonResource;
|
|
9
9
|
score: number;
|
|
10
10
|
reason: string;
|
|
11
|
+
isVerifiedContactMatch?: boolean;
|
|
11
12
|
}
|
|
12
13
|
export declare class PersonMatcher {
|
|
13
14
|
private peopleModule;
|
|
@@ -16,6 +17,14 @@ export declare class PersonMatcher {
|
|
|
16
17
|
constructor(peopleModule: PeopleModule);
|
|
17
18
|
/**
|
|
18
19
|
* Find or create a person with smart matching
|
|
20
|
+
*
|
|
21
|
+
* Uses intelligent matching logic that:
|
|
22
|
+
* - Verifies email/phone matches by checking actual contact information
|
|
23
|
+
* - Only uses name matching when appropriate (multiple people share contact info, or no contact info provided)
|
|
24
|
+
* - Can automatically add missing contact information when a match is found (if addMissingContactInfo is true)
|
|
25
|
+
*
|
|
26
|
+
* @param options - Matching options
|
|
27
|
+
* @param options.addMissingContactInfo - If true, automatically adds missing email/phone to matched person's profile
|
|
19
28
|
*/
|
|
20
29
|
findOrCreate(options: PersonMatchOptions): Promise<PersonResource>;
|
|
21
30
|
/**
|
|
@@ -24,8 +33,21 @@ export declare class PersonMatcher {
|
|
|
24
33
|
findMatch(options: PersonMatchOptions): Promise<MatchResult | null>;
|
|
25
34
|
/**
|
|
26
35
|
* Get potential matching candidates
|
|
36
|
+
* @deprecated Use findMatch which has improved logic for separating verified matches from name-only matches
|
|
27
37
|
*/
|
|
28
38
|
private getCandidates;
|
|
39
|
+
/**
|
|
40
|
+
* Verify if a person's email actually matches the search email
|
|
41
|
+
*/
|
|
42
|
+
private verifyEmailMatch;
|
|
43
|
+
/**
|
|
44
|
+
* Verify if a person's phone actually matches the search phone
|
|
45
|
+
*/
|
|
46
|
+
private verifyPhoneMatch;
|
|
47
|
+
/**
|
|
48
|
+
* Add missing contact information to a person's profile
|
|
49
|
+
*/
|
|
50
|
+
private addMissingContactInfo;
|
|
29
51
|
/**
|
|
30
52
|
* Filter candidates by age preferences
|
|
31
53
|
*/
|
package/dist/matching/matcher.js
CHANGED
|
@@ -11,17 +11,30 @@ class PersonMatcher {
|
|
|
11
11
|
constructor(peopleModule) {
|
|
12
12
|
this.peopleModule = peopleModule;
|
|
13
13
|
this.strategies = new strategies_1.MatchStrategies();
|
|
14
|
-
this.scorer = new scoring_1.MatchScorer();
|
|
14
|
+
this.scorer = new scoring_1.MatchScorer(peopleModule);
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
17
|
* Find or create a person with smart matching
|
|
18
|
+
*
|
|
19
|
+
* Uses intelligent matching logic that:
|
|
20
|
+
* - Verifies email/phone matches by checking actual contact information
|
|
21
|
+
* - Only uses name matching when appropriate (multiple people share contact info, or no contact info provided)
|
|
22
|
+
* - Can automatically add missing contact information when a match is found (if addMissingContactInfo is true)
|
|
23
|
+
*
|
|
24
|
+
* @param options - Matching options
|
|
25
|
+
* @param options.addMissingContactInfo - If true, automatically adds missing email/phone to matched person's profile
|
|
18
26
|
*/
|
|
19
27
|
async findOrCreate(options) {
|
|
20
|
-
const { createIfNotFound = true, matchStrategy = 'fuzzy', ...searchOptions } = options;
|
|
28
|
+
const { createIfNotFound = true, matchStrategy = 'fuzzy', addMissingContactInfo = false, ...searchOptions } = options;
|
|
21
29
|
// Try to find existing person
|
|
22
30
|
const match = await this.findMatch({ ...searchOptions, matchStrategy });
|
|
23
31
|
if (match) {
|
|
24
|
-
|
|
32
|
+
const person = match.person;
|
|
33
|
+
// Add missing contact information if requested
|
|
34
|
+
if (addMissingContactInfo) {
|
|
35
|
+
await this.addMissingContactInfo(person, options);
|
|
36
|
+
}
|
|
37
|
+
return person;
|
|
25
38
|
}
|
|
26
39
|
// Create new person if not found and creation is enabled
|
|
27
40
|
if (createIfNotFound) {
|
|
@@ -33,26 +46,103 @@ class PersonMatcher {
|
|
|
33
46
|
* Find the best match for a person
|
|
34
47
|
*/
|
|
35
48
|
async findMatch(options) {
|
|
36
|
-
const { matchStrategy = 'fuzzy' } = options;
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
|
|
49
|
+
const { matchStrategy = 'fuzzy', email, phone, firstName, lastName } = options;
|
|
50
|
+
// Step 1: Try email/phone search first
|
|
51
|
+
const emailPhoneMatches = [];
|
|
52
|
+
const nameOnlyMatches = [];
|
|
53
|
+
// Search by email
|
|
54
|
+
if (email) {
|
|
55
|
+
try {
|
|
56
|
+
const emailResults = await this.peopleModule.search({ email });
|
|
57
|
+
emailPhoneMatches.push(...emailResults.data);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
console.warn('Email search failed:', error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Search by phone
|
|
64
|
+
if (phone) {
|
|
65
|
+
try {
|
|
66
|
+
const phoneResults = await this.peopleModule.search({ phone });
|
|
67
|
+
emailPhoneMatches.push(...phoneResults.data);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
console.warn('Phone search failed:', error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Remove duplicates
|
|
74
|
+
const uniqueEmailPhoneMatches = emailPhoneMatches.filter((person, index, self) => index === self.findIndex(p => p.id === person.id));
|
|
75
|
+
// Step 2: Verify email/phone actually match
|
|
76
|
+
const verifiedMatches = [];
|
|
77
|
+
for (const candidate of uniqueEmailPhoneMatches) {
|
|
78
|
+
let emailMatches = false;
|
|
79
|
+
let phoneMatches = false;
|
|
80
|
+
if (email) {
|
|
81
|
+
emailMatches = await this.verifyEmailMatch(candidate, email);
|
|
82
|
+
}
|
|
83
|
+
if (phone) {
|
|
84
|
+
phoneMatches = await this.verifyPhoneMatch(candidate, phone);
|
|
85
|
+
}
|
|
86
|
+
// Only include if email OR phone matches (at least one required)
|
|
87
|
+
if (emailMatches || phoneMatches) {
|
|
88
|
+
verifiedMatches.push(candidate);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Step 3: Only search by name if:
|
|
92
|
+
// - Email/phone search found multiple verified results (need name to distinguish), OR
|
|
93
|
+
// - No email/phone was provided (name-only matching is OK)
|
|
94
|
+
// NOTE: Do NOT search by name if email/phone were provided but don't match
|
|
95
|
+
if (verifiedMatches.length > 1 || (!email && !phone)) {
|
|
96
|
+
if (firstName && lastName) {
|
|
97
|
+
try {
|
|
98
|
+
const nameResults = await this.peopleModule.search({
|
|
99
|
+
name: `${firstName} ${lastName}`
|
|
100
|
+
});
|
|
101
|
+
nameOnlyMatches.push(...nameResults.data);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.warn('Name search failed:', error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Step 4: Combine verified email/phone matches with name matches
|
|
109
|
+
// Prioritize email/phone matches over name-only matches
|
|
110
|
+
const allCandidates = [...verifiedMatches, ...nameOnlyMatches];
|
|
111
|
+
// Remove duplicates
|
|
112
|
+
const uniqueCandidates = allCandidates.filter((person, index, self) => index === self.findIndex(p => p.id === person.id));
|
|
113
|
+
// Filter by age preferences
|
|
114
|
+
const ageFilteredCandidates = this.filterByAgePreferences(uniqueCandidates, options);
|
|
115
|
+
if (ageFilteredCandidates.length === 0) {
|
|
40
116
|
return null;
|
|
41
117
|
}
|
|
42
118
|
// Score and rank candidates
|
|
43
|
-
const scoredCandidates =
|
|
119
|
+
const scoredCandidates = await Promise.all(ageFilteredCandidates.map(async (candidate) => ({
|
|
44
120
|
person: candidate,
|
|
45
|
-
score: this.scorer.scoreMatch(candidate, options),
|
|
46
|
-
reason: this.scorer.getMatchReason(candidate, options),
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
121
|
+
score: await this.scorer.scoreMatch(candidate, options),
|
|
122
|
+
reason: await this.scorer.getMatchReason(candidate, options),
|
|
123
|
+
// Mark if this is a verified email/phone match
|
|
124
|
+
isVerifiedContactMatch: verifiedMatches.some(v => v.id === candidate.id)
|
|
125
|
+
})));
|
|
126
|
+
// Sort by verified match first, then by score
|
|
127
|
+
scoredCandidates.sort((a, b) => {
|
|
128
|
+
if (a.isVerifiedContactMatch && !b.isVerifiedContactMatch)
|
|
129
|
+
return -1;
|
|
130
|
+
if (!a.isVerifiedContactMatch && b.isVerifiedContactMatch)
|
|
131
|
+
return 1;
|
|
132
|
+
return b.score - a.score;
|
|
133
|
+
});
|
|
134
|
+
// For "exact" strategy, only return verified email/phone matches
|
|
135
|
+
if (matchStrategy === 'exact') {
|
|
136
|
+
const exactMatches = scoredCandidates.filter(c => c.isVerifiedContactMatch && c.score >= 0.8);
|
|
137
|
+
return exactMatches.length > 0 ? exactMatches[0] : null;
|
|
138
|
+
}
|
|
50
139
|
// Apply strategy-specific filtering
|
|
51
140
|
const bestMatch = this.strategies.selectBestMatch(scoredCandidates, matchStrategy);
|
|
52
141
|
return bestMatch;
|
|
53
142
|
}
|
|
54
143
|
/**
|
|
55
144
|
* Get potential matching candidates
|
|
145
|
+
* @deprecated Use findMatch which has improved logic for separating verified matches from name-only matches
|
|
56
146
|
*/
|
|
57
147
|
async getCandidates(options) {
|
|
58
148
|
const candidates = [];
|
|
@@ -64,7 +154,7 @@ class PersonMatcher {
|
|
|
64
154
|
candidates.push(...emailMatches.data);
|
|
65
155
|
}
|
|
66
156
|
catch (error) {
|
|
67
|
-
|
|
157
|
+
console.warn('Email search failed:', error);
|
|
68
158
|
}
|
|
69
159
|
}
|
|
70
160
|
// Strategy 2: Exact phone match
|
|
@@ -74,7 +164,7 @@ class PersonMatcher {
|
|
|
74
164
|
candidates.push(...phoneMatches.data);
|
|
75
165
|
}
|
|
76
166
|
catch (error) {
|
|
77
|
-
|
|
167
|
+
console.warn('Phone search failed:', error);
|
|
78
168
|
}
|
|
79
169
|
}
|
|
80
170
|
// Strategy 3: Name-based search
|
|
@@ -86,7 +176,7 @@ class PersonMatcher {
|
|
|
86
176
|
candidates.push(...nameMatches.data);
|
|
87
177
|
}
|
|
88
178
|
catch (error) {
|
|
89
|
-
|
|
179
|
+
console.warn('Name search failed:', error);
|
|
90
180
|
}
|
|
91
181
|
}
|
|
92
182
|
// Strategy 4: Broader search if no exact matches
|
|
@@ -98,7 +188,7 @@ class PersonMatcher {
|
|
|
98
188
|
candidates.push(...broadMatches.data);
|
|
99
189
|
}
|
|
100
190
|
catch (error) {
|
|
101
|
-
|
|
191
|
+
console.warn('Broad search failed:', error);
|
|
102
192
|
}
|
|
103
193
|
}
|
|
104
194
|
// Remove duplicates based on person ID
|
|
@@ -107,6 +197,78 @@ class PersonMatcher {
|
|
|
107
197
|
const ageFilteredCandidates = this.filterByAgePreferences(uniqueCandidates, options);
|
|
108
198
|
return ageFilteredCandidates;
|
|
109
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Verify if a person's email actually matches the search email
|
|
202
|
+
*/
|
|
203
|
+
async verifyEmailMatch(person, email) {
|
|
204
|
+
try {
|
|
205
|
+
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) || [];
|
|
208
|
+
return emails.includes(normalizedSearchEmail);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Verify if a person's phone actually matches the search phone
|
|
216
|
+
*/
|
|
217
|
+
async verifyPhoneMatch(person, phone) {
|
|
218
|
+
try {
|
|
219
|
+
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) || [];
|
|
228
|
+
return phones.includes(normalizedSearchPhone);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Add missing contact information to a person's profile
|
|
236
|
+
*/
|
|
237
|
+
async addMissingContactInfo(person, options) {
|
|
238
|
+
const { email, phone } = options;
|
|
239
|
+
// Check and add email if provided and missing
|
|
240
|
+
if (email) {
|
|
241
|
+
try {
|
|
242
|
+
const hasEmail = await this.verifyEmailMatch(person, email);
|
|
243
|
+
if (!hasEmail) {
|
|
244
|
+
await this.peopleModule.addEmail(person.id, {
|
|
245
|
+
address: email,
|
|
246
|
+
location: 'Home',
|
|
247
|
+
primary: false // Don't override existing primary email
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
console.warn(`Failed to add email contact for person ${person.id}:`, error);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Check and add phone if provided and missing
|
|
256
|
+
if (phone) {
|
|
257
|
+
try {
|
|
258
|
+
const hasPhone = await this.verifyPhoneMatch(person, phone);
|
|
259
|
+
if (!hasPhone) {
|
|
260
|
+
await this.peopleModule.addPhoneNumber(person.id, {
|
|
261
|
+
number: phone,
|
|
262
|
+
location: 'Home',
|
|
263
|
+
primary: false // Don't override existing primary phone
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
console.warn(`Failed to add phone contact for person ${person.id}:`, error);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
110
272
|
/**
|
|
111
273
|
* Filter candidates by age preferences
|
|
112
274
|
*/
|
|
@@ -145,11 +307,12 @@ class PersonMatcher {
|
|
|
145
307
|
try {
|
|
146
308
|
await this.peopleModule.addEmail(person.id, {
|
|
147
309
|
address: options.email,
|
|
310
|
+
location: 'Home', // Required field
|
|
148
311
|
primary: true
|
|
149
312
|
});
|
|
150
313
|
}
|
|
151
314
|
catch (error) {
|
|
152
|
-
console.warn(
|
|
315
|
+
console.warn(`Failed to create email contact for person ${person.id}:`, error);
|
|
153
316
|
}
|
|
154
317
|
}
|
|
155
318
|
// Add phone contact if provided
|
|
@@ -157,11 +320,12 @@ class PersonMatcher {
|
|
|
157
320
|
try {
|
|
158
321
|
await this.peopleModule.addPhoneNumber(person.id, {
|
|
159
322
|
number: options.phone,
|
|
323
|
+
location: 'Home', // Required field
|
|
160
324
|
primary: true
|
|
161
325
|
});
|
|
162
326
|
}
|
|
163
327
|
catch (error) {
|
|
164
|
-
console.warn(
|
|
328
|
+
console.warn(`Failed to create phone contact for person ${person.id}:`, error);
|
|
165
329
|
}
|
|
166
330
|
}
|
|
167
331
|
// Set campus if provided
|
|
@@ -170,7 +334,7 @@ class PersonMatcher {
|
|
|
170
334
|
await this.peopleModule.setPrimaryCampus(person.id, options.campusId);
|
|
171
335
|
}
|
|
172
336
|
catch (error) {
|
|
173
|
-
console.warn(
|
|
337
|
+
console.warn(`Failed to set campus for person ${person.id}:`, error);
|
|
174
338
|
}
|
|
175
339
|
}
|
|
176
340
|
return person;
|
|
@@ -179,24 +343,93 @@ class PersonMatcher {
|
|
|
179
343
|
* Get all potential matches with detailed scoring
|
|
180
344
|
*/
|
|
181
345
|
async getAllMatches(options) {
|
|
182
|
-
|
|
183
|
-
|
|
346
|
+
// Use the improved matching logic from findMatch
|
|
347
|
+
const { matchStrategy = 'fuzzy', email, phone, firstName, lastName } = options;
|
|
348
|
+
const emailPhoneMatches = [];
|
|
349
|
+
const nameOnlyMatches = [];
|
|
350
|
+
if (email) {
|
|
351
|
+
try {
|
|
352
|
+
const emailResults = await this.peopleModule.search({ email });
|
|
353
|
+
emailPhoneMatches.push(...emailResults.data);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
console.warn('Email search failed:', error);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (phone) {
|
|
360
|
+
try {
|
|
361
|
+
const phoneResults = await this.peopleModule.search({ phone });
|
|
362
|
+
emailPhoneMatches.push(...phoneResults.data);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
console.warn('Phone search failed:', error);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const uniqueEmailPhoneMatches = emailPhoneMatches.filter((person, index, self) => index === self.findIndex(p => p.id === person.id));
|
|
369
|
+
const verifiedMatches = [];
|
|
370
|
+
for (const candidate of uniqueEmailPhoneMatches) {
|
|
371
|
+
let emailMatches = false;
|
|
372
|
+
let phoneMatches = false;
|
|
373
|
+
if (email) {
|
|
374
|
+
emailMatches = await this.verifyEmailMatch(candidate, email);
|
|
375
|
+
}
|
|
376
|
+
if (phone) {
|
|
377
|
+
phoneMatches = await this.verifyPhoneMatch(candidate, phone);
|
|
378
|
+
}
|
|
379
|
+
if (emailMatches || phoneMatches) {
|
|
380
|
+
verifiedMatches.push(candidate);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (verifiedMatches.length > 1 || (!email && !phone)) {
|
|
384
|
+
if (firstName && lastName) {
|
|
385
|
+
try {
|
|
386
|
+
const nameResults = await this.peopleModule.search({
|
|
387
|
+
name: `${firstName} ${lastName}`
|
|
388
|
+
});
|
|
389
|
+
nameOnlyMatches.push(...nameResults.data);
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
console.warn('Name search failed:', error);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const allCandidates = [...verifiedMatches, ...nameOnlyMatches];
|
|
397
|
+
const uniqueCandidates = allCandidates.filter((person, index, self) => index === self.findIndex(p => p.id === person.id));
|
|
398
|
+
const ageFilteredCandidates = this.filterByAgePreferences(uniqueCandidates, options);
|
|
399
|
+
const scoredCandidates = await Promise.all(ageFilteredCandidates.map(async (candidate) => ({
|
|
184
400
|
person: candidate,
|
|
185
|
-
score: this.scorer.scoreMatch(candidate, options),
|
|
186
|
-
reason: this.scorer.getMatchReason(candidate, options),
|
|
187
|
-
|
|
401
|
+
score: await this.scorer.scoreMatch(candidate, options),
|
|
402
|
+
reason: await this.scorer.getMatchReason(candidate, options),
|
|
403
|
+
isVerifiedContactMatch: verifiedMatches.some(v => v.id === candidate.id)
|
|
404
|
+
})));
|
|
405
|
+
return scoredCandidates.sort((a, b) => {
|
|
406
|
+
if (a.isVerifiedContactMatch && !b.isVerifiedContactMatch)
|
|
407
|
+
return -1;
|
|
408
|
+
if (!a.isVerifiedContactMatch && b.isVerifiedContactMatch)
|
|
409
|
+
return 1;
|
|
410
|
+
return b.score - a.score;
|
|
411
|
+
});
|
|
188
412
|
}
|
|
189
413
|
/**
|
|
190
414
|
* Check if a person matches the given criteria
|
|
191
415
|
*/
|
|
192
416
|
async isMatch(personId, options) {
|
|
193
417
|
const person = await this.peopleModule.getById(personId);
|
|
194
|
-
const score = this.scorer.scoreMatch(person, options);
|
|
418
|
+
const score = await this.scorer.scoreMatch(person, options);
|
|
195
419
|
if (score > 0.5) { // Threshold for considering it a match
|
|
420
|
+
// Check if this is a verified contact match
|
|
421
|
+
let isVerifiedContactMatch = false;
|
|
422
|
+
if (options.email) {
|
|
423
|
+
isVerifiedContactMatch = await this.verifyEmailMatch(person, options.email);
|
|
424
|
+
}
|
|
425
|
+
if (!isVerifiedContactMatch && options.phone) {
|
|
426
|
+
isVerifiedContactMatch = await this.verifyPhoneMatch(person, options.phone);
|
|
427
|
+
}
|
|
196
428
|
return {
|
|
197
429
|
person,
|
|
198
430
|
score,
|
|
199
|
-
reason: this.scorer.getMatchReason(person, options),
|
|
431
|
+
reason: await this.scorer.getMatchReason(person, options),
|
|
432
|
+
isVerifiedContactMatch,
|
|
200
433
|
};
|
|
201
434
|
}
|
|
202
435
|
return null;
|
|
@@ -2,24 +2,26 @@
|
|
|
2
2
|
* v2.0.0 Person Match Scoring
|
|
3
3
|
*/
|
|
4
4
|
import type { PersonResource } from '../types';
|
|
5
|
-
import type { PersonMatchOptions } from '../modules/people';
|
|
5
|
+
import type { PersonMatchOptions, PeopleModule } from '../modules/people';
|
|
6
6
|
export declare class MatchScorer {
|
|
7
|
+
private peopleModule;
|
|
8
|
+
constructor(peopleModule: PeopleModule);
|
|
7
9
|
/**
|
|
8
10
|
* Score a person match based on various criteria
|
|
9
11
|
*/
|
|
10
|
-
scoreMatch(person: PersonResource, options: PersonMatchOptions): number
|
|
12
|
+
scoreMatch(person: PersonResource, options: PersonMatchOptions): Promise<number>;
|
|
11
13
|
/**
|
|
12
14
|
* Get a human-readable reason for the match
|
|
13
15
|
*/
|
|
14
|
-
getMatchReason(person: PersonResource, options: PersonMatchOptions): string
|
|
16
|
+
getMatchReason(person: PersonResource, options: PersonMatchOptions): Promise<string>;
|
|
15
17
|
/**
|
|
16
|
-
* Score email matching
|
|
18
|
+
* Score email matching - verifies actual email matches
|
|
17
19
|
*/
|
|
18
|
-
|
|
20
|
+
scoreEmailMatch(person: PersonResource, email: string): Promise<number>;
|
|
19
21
|
/**
|
|
20
|
-
* Score phone matching
|
|
22
|
+
* Score phone matching - verifies actual phone matches
|
|
21
23
|
*/
|
|
22
|
-
|
|
24
|
+
scorePhoneMatch(person: PersonResource, phone: string): Promise<number>;
|
|
23
25
|
/**
|
|
24
26
|
* Score name matching - only exact matches
|
|
25
27
|
*/
|
package/dist/matching/scoring.js
CHANGED
|
@@ -6,29 +6,34 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.MatchScorer = void 0;
|
|
7
7
|
const helpers_1 = require("../helpers");
|
|
8
8
|
class MatchScorer {
|
|
9
|
+
constructor(peopleModule) {
|
|
10
|
+
this.peopleModule = peopleModule;
|
|
11
|
+
}
|
|
9
12
|
/**
|
|
10
13
|
* Score a person match based on various criteria
|
|
11
14
|
*/
|
|
12
|
-
scoreMatch(person, options) {
|
|
15
|
+
async scoreMatch(person, options) {
|
|
13
16
|
let totalScore = 0;
|
|
14
17
|
let maxScore = 0;
|
|
15
18
|
// Email matching (highest weight)
|
|
16
19
|
if (options.email) {
|
|
17
|
-
const emailScore = this.scoreEmailMatch(person, options.email);
|
|
20
|
+
const emailScore = await this.scoreEmailMatch(person, options.email);
|
|
18
21
|
totalScore += emailScore * 0.35;
|
|
19
22
|
maxScore += 0.35;
|
|
20
23
|
}
|
|
21
24
|
// Phone matching (high weight)
|
|
22
25
|
if (options.phone) {
|
|
23
|
-
const phoneScore = this.scorePhoneMatch(person, options.phone);
|
|
26
|
+
const phoneScore = await this.scorePhoneMatch(person, options.phone);
|
|
24
27
|
totalScore += phoneScore * 0.25;
|
|
25
28
|
maxScore += 0.25;
|
|
26
29
|
}
|
|
27
|
-
// Name matching (
|
|
30
|
+
// Name matching (increased weight for name-only matches)
|
|
28
31
|
if (options.firstName || options.lastName) {
|
|
29
32
|
const nameScore = this.scoreNameMatch(person, options);
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
// Increase weight to 0.4 for name-only matches, 0.2 when combined with other criteria
|
|
34
|
+
const nameWeight = (!options.email && !options.phone) ? 0.4 : 0.2;
|
|
35
|
+
totalScore += nameScore * nameWeight;
|
|
36
|
+
maxScore += nameWeight;
|
|
32
37
|
}
|
|
33
38
|
// Age matching (medium weight)
|
|
34
39
|
const ageScore = this.scoreAgeMatch(person, options);
|
|
@@ -43,13 +48,19 @@ class MatchScorer {
|
|
|
43
48
|
/**
|
|
44
49
|
* Get a human-readable reason for the match
|
|
45
50
|
*/
|
|
46
|
-
getMatchReason(person, options) {
|
|
51
|
+
async getMatchReason(person, options) {
|
|
47
52
|
const reasons = [];
|
|
48
|
-
if (options.email
|
|
49
|
-
|
|
53
|
+
if (options.email) {
|
|
54
|
+
const emailScore = await this.scoreEmailMatch(person, options.email);
|
|
55
|
+
if (emailScore > 0.8) {
|
|
56
|
+
reasons.push('exact email match');
|
|
57
|
+
}
|
|
50
58
|
}
|
|
51
|
-
if (options.phone
|
|
52
|
-
|
|
59
|
+
if (options.phone) {
|
|
60
|
+
const phoneScore = await this.scorePhoneMatch(person, options.phone);
|
|
61
|
+
if (phoneScore > 0.8) {
|
|
62
|
+
reasons.push('exact phone match');
|
|
63
|
+
}
|
|
53
64
|
}
|
|
54
65
|
if (options.firstName || options.lastName) {
|
|
55
66
|
const nameScore = this.scoreNameMatch(person, options);
|
|
@@ -82,22 +93,42 @@ class MatchScorer {
|
|
|
82
93
|
return reasons.join(', ');
|
|
83
94
|
}
|
|
84
95
|
/**
|
|
85
|
-
* Score email matching
|
|
96
|
+
* Score email matching - verifies actual email matches
|
|
86
97
|
*/
|
|
87
|
-
scoreEmailMatch(person, email) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
async scoreEmailMatch(person, email) {
|
|
99
|
+
try {
|
|
100
|
+
const personEmails = await this.peopleModule.getEmails(person.id);
|
|
101
|
+
const normalizedSearchEmail = email.toLowerCase().trim();
|
|
102
|
+
// Check if any of the person's emails match
|
|
103
|
+
const emails = personEmails.data?.map(e => e.attributes?.address?.toLowerCase().trim()).filter(Boolean) || [];
|
|
104
|
+
return emails.includes(normalizedSearchEmail) ? 1.0 : 0.0;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.warn(`Failed to verify email match for person ${person.id}:`, error);
|
|
108
|
+
return 0.0;
|
|
109
|
+
}
|
|
92
110
|
}
|
|
93
111
|
/**
|
|
94
|
-
* Score phone matching
|
|
112
|
+
* Score phone matching - verifies actual phone matches
|
|
95
113
|
*/
|
|
96
|
-
scorePhoneMatch(person, phone) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
114
|
+
async scorePhoneMatch(person, phone) {
|
|
115
|
+
try {
|
|
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) || [];
|
|
126
|
+
return phones.includes(normalizedSearchPhone) ? 1.0 : 0.0;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
console.warn(`Failed to verify phone match for person ${person.id}:`, error);
|
|
130
|
+
return 0.0;
|
|
131
|
+
}
|
|
101
132
|
}
|
|
102
133
|
/**
|
|
103
134
|
* Score name matching - only exact matches
|
|
@@ -10,6 +10,7 @@ export declare class MatchStrategies {
|
|
|
10
10
|
selectBestMatch(candidates: MatchResult[], strategy: MatchStrategy): MatchResult | null;
|
|
11
11
|
/**
|
|
12
12
|
* Exact matching strategy - only return matches with very high confidence
|
|
13
|
+
* Requires verified email/phone matches unless multiple people share the same contact info
|
|
13
14
|
*/
|
|
14
15
|
private selectExactMatch;
|
|
15
16
|
/**
|