Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
27.89% |
53 / 190 |
|
40.91% |
9 / 22 |
CRAP | |
0.00% |
0 / 2 |
| UserMatcher | |
26.74% |
46 / 172 |
|
38.46% |
5 / 13 |
1427.45 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| findMatch | |
20.55% |
15 / 73 |
|
0.00% |
0 / 1 |
312.89 | |||
| findByExternalId | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| findByEmail | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| findByName | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| findByNameInStore | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| findByPhone | |
33.33% |
3 / 9 |
|
0.00% |
0 / 1 |
5.67 | |||
| calculateMatchScore | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
90 | |||
| calculateNameSimilarity | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
| stringSimilarity | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
| extractEmailDomain | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| buildMatchReason | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
42 | |||
| logMatch | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
| MatchResult | |
38.89% |
7 / 18 |
|
44.44% |
4 / 9 |
32.82 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| wasMatched | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getUser | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getUserId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| getMatchType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getConfidence | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getReason | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isHighConfidence | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| toArray | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * User Matcher Service |
| 4 | * |
| 5 | * Handles intelligent matching of incoming employee data to existing users |
| 6 | * to prevent duplicate user creation during external provider syncs. |
| 7 | * |
| 8 | * Matching Strategy (per SDD Section 7.2): |
| 9 | * 1. External ID match (source + externalId) - most reliable, exact match |
| 10 | * 2. Email match - highly reliable for unique identification |
| 11 | * 3. Name match (firstName + lastName) - fallback, with confidence scoring |
| 12 | * |
| 13 | * @package BuyerKiosk\Auth\Services |
| 14 | */ |
| 15 | |
| 16 | namespace BuyerKiosk\Auth\Services; |
| 17 | |
| 18 | class UserMatcher |
| 19 | { |
| 20 | /** |
| 21 | * @var \PDO Database connection (central kiosk_users) |
| 22 | */ |
| 23 | private $db; |
| 24 | |
| 25 | /** |
| 26 | * @var AuditLogger Audit logger for sync operations |
| 27 | */ |
| 28 | private $auditLogger; |
| 29 | |
| 30 | /** |
| 31 | * Minimum confidence score to consider a name-based match valid (0-100) |
| 32 | */ |
| 33 | private const NAME_MATCH_THRESHOLD = 80; |
| 34 | |
| 35 | /** |
| 36 | * Lower threshold for store-scoped name matches (more confident when same store) |
| 37 | */ |
| 38 | private const STORE_NAME_MATCH_THRESHOLD = 60; |
| 39 | |
| 40 | /** |
| 41 | * Constructor |
| 42 | * |
| 43 | * @param \PDO $db Database connection |
| 44 | * @param AuditLogger|null $auditLogger Audit logger |
| 45 | */ |
| 46 | public function __construct(\PDO $db, ?AuditLogger $auditLogger = null) |
| 47 | { |
| 48 | $this->db = $db; |
| 49 | $this->auditLogger = $auditLogger ?? new AuditLogger($db); |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Find existing user that matches the incoming employee data |
| 54 | * |
| 55 | * Performs multi-dimensional matching per SDD Section 7.2: |
| 56 | * 1. External ID (exact match on source + externalId) |
| 57 | * 2. Email (exact match, case-insensitive) |
| 58 | * 3. Name within store (store-scoped, lower threshold - most relevant for sync) |
| 59 | * 4. Name global (higher threshold required) |
| 60 | * 5. Phone (only if name also matches) |
| 61 | * |
| 62 | * @param string $source Provider source ('wheniwork', 'homebase', etc.) |
| 63 | * @param string $externalId External system ID |
| 64 | * @param string|null $email Email address |
| 65 | * @param string|null $firstName First name |
| 66 | * @param string|null $lastName Last name |
| 67 | * @param string|null $phone Phone number (additional matching signal) |
| 68 | * @param string|null $typeNum Store identifier for store-scoped name matching |
| 69 | * @return MatchResult Result containing matched user (if any) and match details |
| 70 | */ |
| 71 | public function findMatch( |
| 72 | string $source, |
| 73 | string $externalId, |
| 74 | ?string $email = null, |
| 75 | ?string $firstName = null, |
| 76 | ?string $lastName = null, |
| 77 | ?string $phone = null, |
| 78 | ?string $typeNum = null |
| 79 | ): MatchResult { |
| 80 | // Priority 1: Exact external ID match (most reliable) |
| 81 | $externalMatch = $this->findByExternalId($source, $externalId); |
| 82 | if ($externalMatch) { |
| 83 | return new MatchResult( |
| 84 | $externalMatch, |
| 85 | 'external_id', |
| 86 | 100, |
| 87 | "Exact match on {$source} ID: {$externalId}" |
| 88 | ); |
| 89 | } |
| 90 | |
| 91 | // Priority 2: Email match (very reliable) |
| 92 | if (!empty($email)) { |
| 93 | $emailMatch = $this->findByEmail($email); |
| 94 | if ($emailMatch) { |
| 95 | return new MatchResult( |
| 96 | $emailMatch, |
| 97 | 'email', |
| 98 | 95, |
| 99 | "Email match: {$email}" |
| 100 | ); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // Priority 3: Store-scoped name match (safer, lower threshold) |
| 105 | // This is critical for sync operations where we want to match existing |
| 106 | // employees in the same store even if they don't have email/phone. |
| 107 | if (!empty($firstName) && !empty($lastName) && !empty($typeNum)) { |
| 108 | $storeNameMatches = $this->findByNameInStore($firstName, $lastName, $typeNum); |
| 109 | |
| 110 | if (!empty($storeNameMatches)) { |
| 111 | // Score each candidate |
| 112 | $bestMatch = null; |
| 113 | $bestScore = 0; |
| 114 | $bestReason = ''; |
| 115 | |
| 116 | foreach ($storeNameMatches as $candidate) { |
| 117 | $score = $this->calculateMatchScore($candidate, $firstName, $lastName, $email, $phone); |
| 118 | |
| 119 | // Use lower threshold for store-scoped matches |
| 120 | if ($score > $bestScore && $score >= self::STORE_NAME_MATCH_THRESHOLD) { |
| 121 | $bestScore = $score; |
| 122 | $bestMatch = $candidate; |
| 123 | $bestReason = $this->buildMatchReason($candidate, $firstName, $lastName, $email, $phone); |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | if ($bestMatch) { |
| 128 | return new MatchResult( |
| 129 | $bestMatch, |
| 130 | 'store_name', |
| 131 | $bestScore, |
| 132 | "Store-scoped name match: {$bestReason}" |
| 133 | ); |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // Priority 4: Global name match with additional signals (higher threshold) |
| 139 | if (!empty($firstName) && !empty($lastName)) { |
| 140 | $nameMatches = $this->findByName($firstName, $lastName); |
| 141 | |
| 142 | if (!empty($nameMatches)) { |
| 143 | // Score each candidate |
| 144 | $bestMatch = null; |
| 145 | $bestScore = 0; |
| 146 | $bestReason = ''; |
| 147 | |
| 148 | foreach ($nameMatches as $candidate) { |
| 149 | $score = $this->calculateMatchScore($candidate, $firstName, $lastName, $email, $phone); |
| 150 | |
| 151 | if ($score > $bestScore && $score >= self::NAME_MATCH_THRESHOLD) { |
| 152 | $bestScore = $score; |
| 153 | $bestMatch = $candidate; |
| 154 | $bestReason = $this->buildMatchReason($candidate, $firstName, $lastName, $email, $phone); |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | if ($bestMatch) { |
| 159 | return new MatchResult( |
| 160 | $bestMatch, |
| 161 | 'name', |
| 162 | $bestScore, |
| 163 | $bestReason |
| 164 | ); |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // Priority 5: Phone match (least reliable, but useful signal) |
| 170 | if (!empty($phone)) { |
| 171 | $phoneMatch = $this->findByPhone($phone); |
| 172 | if ($phoneMatch) { |
| 173 | // Only use phone match if name also matches somewhat |
| 174 | if (!empty($firstName) && !empty($lastName)) { |
| 175 | $nameScore = $this->calculateNameSimilarity( |
| 176 | $firstName, |
| 177 | $lastName, |
| 178 | $phoneMatch['firstName'] ?? '', |
| 179 | $phoneMatch['lastName'] ?? '' |
| 180 | ); |
| 181 | |
| 182 | if ($nameScore >= 60) { // Lower threshold since phone matches |
| 183 | return new MatchResult( |
| 184 | $phoneMatch, |
| 185 | 'phone', |
| 186 | 70 + ($nameScore / 5), // 70-90 range |
| 187 | "Phone match with similar name: {$phone}" |
| 188 | ); |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // No match found |
| 195 | return new MatchResult(null, 'none', 0, 'No matching user found'); |
| 196 | } |
| 197 | |
| 198 | /** |
| 199 | * Find user by external provider ID |
| 200 | * |
| 201 | * @param string $source Provider source |
| 202 | * @param string $externalId External ID |
| 203 | * @return array|null User row or null |
| 204 | */ |
| 205 | public function findByExternalId(string $source, string $externalId): ?array |
| 206 | { |
| 207 | $stmt = $this->db->prepare(" |
| 208 | SELECT * FROM users |
| 209 | WHERE source = :source AND externalId = :externalId |
| 210 | LIMIT 1 |
| 211 | "); |
| 212 | $stmt->execute(['source' => $source, 'externalId' => $externalId]); |
| 213 | $row = $stmt->fetch(\PDO::FETCH_ASSOC); |
| 214 | |
| 215 | return $row ?: null; |
| 216 | } |
| 217 | |
| 218 | /** |
| 219 | * Find user by email (case-insensitive) |
| 220 | * |
| 221 | * @param string $email Email address |
| 222 | * @return array|null User row or null |
| 223 | */ |
| 224 | public function findByEmail(string $email): ?array |
| 225 | { |
| 226 | $stmt = $this->db->prepare(" |
| 227 | SELECT * FROM users |
| 228 | WHERE LOWER(email) = LOWER(:email) |
| 229 | LIMIT 1 |
| 230 | "); |
| 231 | $stmt->execute(['email' => trim($email)]); |
| 232 | $row = $stmt->fetch(\PDO::FETCH_ASSOC); |
| 233 | |
| 234 | return $row ?: null; |
| 235 | } |
| 236 | |
| 237 | /** |
| 238 | * Find users by name (returns all potential matches for scoring) |
| 239 | * |
| 240 | * @param string $firstName First name |
| 241 | * @param string $lastName Last name |
| 242 | * @return array[] Array of user rows |
| 243 | */ |
| 244 | public function findByName(string $firstName, string $lastName): array |
| 245 | { |
| 246 | // Normalize names for matching |
| 247 | $firstName = trim($firstName); |
| 248 | $lastName = trim($lastName); |
| 249 | |
| 250 | // Look for exact and similar matches |
| 251 | $stmt = $this->db->prepare(" |
| 252 | SELECT * FROM users |
| 253 | WHERE ( |
| 254 | -- Exact match (case-insensitive) |
| 255 | (LOWER(firstName) = LOWER(:firstName) AND LOWER(lastName) = LOWER(:lastName)) |
| 256 | -- First name matches, last name similar (handles typos/variations) |
| 257 | OR (LOWER(firstName) = LOWER(:firstName) AND SOUNDEX(lastName) = SOUNDEX(:lastName)) |
| 258 | -- Last name matches, first name similar |
| 259 | OR (SOUNDEX(firstName) = SOUNDEX(:firstName) AND LOWER(lastName) = LOWER(:lastName)) |
| 260 | -- Both names similar (soundex) |
| 261 | OR (SOUNDEX(firstName) = SOUNDEX(:firstName) AND SOUNDEX(lastName) = SOUNDEX(:lastName)) |
| 262 | ) |
| 263 | LIMIT 10 |
| 264 | "); |
| 265 | $stmt->execute(['firstName' => $firstName, 'lastName' => $lastName]); |
| 266 | |
| 267 | return $stmt->fetchAll(\PDO::FETCH_ASSOC); |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Find users by name within a specific store (more reliable for sync) |
| 272 | * |
| 273 | * Only returns users who are already assigned to the given store, |
| 274 | * making name-only matches safe to use with lower thresholds. |
| 275 | * |
| 276 | * @param string $firstName First name |
| 277 | * @param string $lastName Last name |
| 278 | * @param string $typeNum Store identifier |
| 279 | * @return array[] Array of user rows |
| 280 | */ |
| 281 | public function findByNameInStore(string $firstName, string $lastName, string $typeNum): array |
| 282 | { |
| 283 | // Normalize names for matching |
| 284 | $firstName = trim($firstName); |
| 285 | $lastName = trim($lastName); |
| 286 | |
| 287 | // Look for exact and similar matches, but only within the store's assignments |
| 288 | $stmt = $this->db->prepare(" |
| 289 | SELECT u.* FROM users u |
| 290 | INNER JOIN userStoreAssignments usa ON u.id = usa.userId |
| 291 | WHERE usa.typeNum = :typeNum |
| 292 | AND ( |
| 293 | -- Exact match (case-insensitive) |
| 294 | (LOWER(u.firstName) = LOWER(:firstName) AND LOWER(u.lastName) = LOWER(:lastName)) |
| 295 | -- First name matches, last name similar (handles typos/variations) |
| 296 | OR (LOWER(u.firstName) = LOWER(:firstName) AND SOUNDEX(u.lastName) = SOUNDEX(:lastName)) |
| 297 | -- Last name matches, first name similar |
| 298 | OR (SOUNDEX(u.firstName) = SOUNDEX(:firstName) AND LOWER(u.lastName) = LOWER(:lastName)) |
| 299 | -- Both names similar (soundex) |
| 300 | OR (SOUNDEX(u.firstName) = SOUNDEX(:firstName) AND SOUNDEX(u.lastName) = SOUNDEX(:lastName)) |
| 301 | ) |
| 302 | LIMIT 10 |
| 303 | "); |
| 304 | $stmt->execute([ |
| 305 | 'firstName' => $firstName, |
| 306 | 'lastName' => $lastName, |
| 307 | 'typeNum' => $typeNum, |
| 308 | ]); |
| 309 | |
| 310 | return $stmt->fetchAll(\PDO::FETCH_ASSOC); |
| 311 | } |
| 312 | |
| 313 | /** |
| 314 | * Find user by phone number |
| 315 | * |
| 316 | * @param string $phone Phone number |
| 317 | * @return array|null User row or null |
| 318 | */ |
| 319 | public function findByPhone(string $phone): ?array |
| 320 | { |
| 321 | // Normalize phone number (remove non-digits) |
| 322 | $normalizedPhone = preg_replace('/[^0-9]/', '', $phone); |
| 323 | |
| 324 | // Skip if too short |
| 325 | if (strlen($normalizedPhone) < 10) { |
| 326 | return null; |
| 327 | } |
| 328 | |
| 329 | // Get last 10 digits for matching (handles country code variations) |
| 330 | $last10 = substr($normalizedPhone, -10); |
| 331 | |
| 332 | $stmt = $this->db->prepare(" |
| 333 | SELECT * FROM users |
| 334 | WHERE RIGHT(REGEXP_REPLACE(phone, '[^0-9]', ''), 10) = :phone |
| 335 | LIMIT 1 |
| 336 | "); |
| 337 | $stmt->execute(['phone' => $last10]); |
| 338 | $row = $stmt->fetch(\PDO::FETCH_ASSOC); |
| 339 | |
| 340 | return $row ?: null; |
| 341 | } |
| 342 | |
| 343 | /** |
| 344 | * Calculate overall match score for a candidate user |
| 345 | * |
| 346 | * @param array $candidate Candidate user row |
| 347 | * @param string $firstName Incoming first name |
| 348 | * @param string $lastName Incoming last name |
| 349 | * @param string|null $email Incoming email |
| 350 | * @param string|null $phone Incoming phone |
| 351 | * @return int Score 0-100 |
| 352 | */ |
| 353 | private function calculateMatchScore( |
| 354 | array $candidate, |
| 355 | string $firstName, |
| 356 | string $lastName, |
| 357 | ?string $email, |
| 358 | ?string $phone |
| 359 | ): int { |
| 360 | $score = 0; |
| 361 | |
| 362 | // Name similarity (up to 60 points) |
| 363 | $nameScore = $this->calculateNameSimilarity( |
| 364 | $firstName, |
| 365 | $lastName, |
| 366 | $candidate['firstName'] ?? '', |
| 367 | $candidate['lastName'] ?? '' |
| 368 | ); |
| 369 | $score += $nameScore * 0.6; |
| 370 | |
| 371 | // Email domain match bonus (up to 20 points) |
| 372 | if ($email && !empty($candidate['email'])) { |
| 373 | $incomingDomain = $this->extractEmailDomain($email); |
| 374 | $candidateDomain = $this->extractEmailDomain($candidate['email']); |
| 375 | |
| 376 | if ($incomingDomain && $candidateDomain) { |
| 377 | if (strtolower($incomingDomain) === strtolower($candidateDomain)) { |
| 378 | $score += 20; // Same email domain |
| 379 | } |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | // Phone match bonus (up to 20 points) |
| 384 | if ($phone && !empty($candidate['phone'])) { |
| 385 | $incomingPhone = preg_replace('/[^0-9]/', '', $phone); |
| 386 | $candidatePhone = preg_replace('/[^0-9]/', '', $candidate['phone']); |
| 387 | |
| 388 | // Compare last 10 digits |
| 389 | $incomingLast10 = substr($incomingPhone, -10); |
| 390 | $candidateLast10 = substr($candidatePhone, -10); |
| 391 | |
| 392 | if ($incomingLast10 === $candidateLast10) { |
| 393 | $score += 20; // Exact phone match |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | return (int)min(100, $score); |
| 398 | } |
| 399 | |
| 400 | /** |
| 401 | * Calculate name similarity score |
| 402 | * |
| 403 | * @param string $firstName1 First name 1 |
| 404 | * @param string $lastName1 Last name 1 |
| 405 | * @param string $firstName2 First name 2 |
| 406 | * @param string $lastName2 Last name 2 |
| 407 | * @return int Score 0-100 |
| 408 | */ |
| 409 | private function calculateNameSimilarity( |
| 410 | string $firstName1, |
| 411 | string $lastName1, |
| 412 | string $firstName2, |
| 413 | string $lastName2 |
| 414 | ): int { |
| 415 | // Normalize |
| 416 | $fn1 = strtolower(trim($firstName1)); |
| 417 | $fn2 = strtolower(trim($firstName2)); |
| 418 | $ln1 = strtolower(trim($lastName1)); |
| 419 | $ln2 = strtolower(trim($lastName2)); |
| 420 | |
| 421 | // Calculate Levenshtein distance with percentage similarity |
| 422 | $fnSimilarity = $this->stringSimilarity($fn1, $fn2); |
| 423 | $lnSimilarity = $this->stringSimilarity($ln1, $ln2); |
| 424 | |
| 425 | // Last name is weighted more heavily (more unique identifier) |
| 426 | return (int)(($fnSimilarity * 0.4) + ($lnSimilarity * 0.6)); |
| 427 | } |
| 428 | |
| 429 | /** |
| 430 | * Calculate string similarity as percentage |
| 431 | * |
| 432 | * @param string $str1 String 1 |
| 433 | * @param string $str2 String 2 |
| 434 | * @return int Percentage 0-100 |
| 435 | */ |
| 436 | private function stringSimilarity(string $str1, string $str2): int |
| 437 | { |
| 438 | if ($str1 === $str2) { |
| 439 | return 100; |
| 440 | } |
| 441 | |
| 442 | if (empty($str1) || empty($str2)) { |
| 443 | return 0; |
| 444 | } |
| 445 | |
| 446 | // Use similar_text for percentage |
| 447 | similar_text($str1, $str2, $percent); |
| 448 | |
| 449 | // Also check soundex for phonetic similarity |
| 450 | if (soundex($str1) === soundex($str2)) { |
| 451 | $percent = max($percent, 80); // Boost score if sounds similar |
| 452 | } |
| 453 | |
| 454 | return (int)$percent; |
| 455 | } |
| 456 | |
| 457 | /** |
| 458 | * Extract domain from email address |
| 459 | * |
| 460 | * @param string $email Email address |
| 461 | * @return string|null Domain or null |
| 462 | */ |
| 463 | private function extractEmailDomain(string $email): ?string |
| 464 | { |
| 465 | $parts = explode('@', $email); |
| 466 | return isset($parts[1]) ? $parts[1] : null; |
| 467 | } |
| 468 | |
| 469 | /** |
| 470 | * Build human-readable match reason |
| 471 | * |
| 472 | * @param array $candidate Matched user |
| 473 | * @param string $firstName Incoming first name |
| 474 | * @param string $lastName Incoming last name |
| 475 | * @param string|null $email Incoming email |
| 476 | * @param string|null $phone Incoming phone |
| 477 | * @return string |
| 478 | */ |
| 479 | private function buildMatchReason( |
| 480 | array $candidate, |
| 481 | string $firstName, |
| 482 | string $lastName, |
| 483 | ?string $email, |
| 484 | ?string $phone |
| 485 | ): string { |
| 486 | $reasons = []; |
| 487 | |
| 488 | // Name match |
| 489 | $candidateName = trim(($candidate['firstName'] ?? '') . ' ' . ($candidate['lastName'] ?? '')); |
| 490 | $incomingName = trim("$firstName $lastName"); |
| 491 | $reasons[] = "Name: '{$incomingName}' matches '{$candidateName}'"; |
| 492 | |
| 493 | // Email domain if matched |
| 494 | if ($email && !empty($candidate['email'])) { |
| 495 | $incomingDomain = $this->extractEmailDomain($email); |
| 496 | $candidateDomain = $this->extractEmailDomain($candidate['email']); |
| 497 | if ($incomingDomain === $candidateDomain) { |
| 498 | $reasons[] = "Same email domain: @{$incomingDomain}"; |
| 499 | } |
| 500 | } |
| 501 | |
| 502 | // Phone if matched |
| 503 | if ($phone && !empty($candidate['phone'])) { |
| 504 | $reasons[] = "Phone numbers match"; |
| 505 | } |
| 506 | |
| 507 | return implode('; ', $reasons); |
| 508 | } |
| 509 | |
| 510 | /** |
| 511 | * Log a match event to audit log |
| 512 | * |
| 513 | * @param int|null $userId User ID (null if no match) |
| 514 | * @param string $source Provider source |
| 515 | * @param string $externalId External ID |
| 516 | * @param MatchResult $result Match result |
| 517 | */ |
| 518 | public function logMatch(?int $userId, string $source, string $externalId, MatchResult $result): void |
| 519 | { |
| 520 | $this->auditLogger->log( |
| 521 | $result->wasMatched() ? 'user_matched' : 'user_not_matched', |
| 522 | $userId, |
| 523 | [ |
| 524 | 'source' => $source, |
| 525 | 'externalId' => $externalId, |
| 526 | 'matchType' => $result->getMatchType(), |
| 527 | 'confidence' => $result->getConfidence(), |
| 528 | 'reason' => $result->getReason(), |
| 529 | ] |
| 530 | ); |
| 531 | } |
| 532 | } |
| 533 | |
| 534 | /** |
| 535 | * Result of a user matching operation |
| 536 | */ |
| 537 | class MatchResult |
| 538 | { |
| 539 | /** |
| 540 | * @var array|null Matched user data |
| 541 | */ |
| 542 | private $user; |
| 543 | |
| 544 | /** |
| 545 | * @var string Match type: 'external_id', 'email', 'name', 'phone', 'none' |
| 546 | */ |
| 547 | private $matchType; |
| 548 | |
| 549 | /** |
| 550 | * @var int Confidence score 0-100 |
| 551 | */ |
| 552 | private $confidence; |
| 553 | |
| 554 | /** |
| 555 | * @var string Human-readable reason |
| 556 | */ |
| 557 | private $reason; |
| 558 | |
| 559 | /** |
| 560 | * Constructor |
| 561 | * |
| 562 | * @param array|null $user Matched user data |
| 563 | * @param string $matchType Type of match |
| 564 | * @param int $confidence Confidence score |
| 565 | * @param string $reason Match reason |
| 566 | */ |
| 567 | public function __construct(?array $user, string $matchType, int $confidence, string $reason) |
| 568 | { |
| 569 | $this->user = $user; |
| 570 | $this->matchType = $matchType; |
| 571 | $this->confidence = $confidence; |
| 572 | $this->reason = $reason; |
| 573 | } |
| 574 | |
| 575 | /** |
| 576 | * Whether a match was found |
| 577 | * |
| 578 | * @return bool |
| 579 | */ |
| 580 | public function wasMatched(): bool |
| 581 | { |
| 582 | return $this->user !== null; |
| 583 | } |
| 584 | |
| 585 | /** |
| 586 | * Get matched user data |
| 587 | * |
| 588 | * @return array|null |
| 589 | */ |
| 590 | public function getUser(): ?array |
| 591 | { |
| 592 | return $this->user; |
| 593 | } |
| 594 | |
| 595 | /** |
| 596 | * Get user ID if matched |
| 597 | * |
| 598 | * @return int|null |
| 599 | */ |
| 600 | public function getUserId(): ?int |
| 601 | { |
| 602 | return $this->user ? (int)$this->user['id'] : null; |
| 603 | } |
| 604 | |
| 605 | /** |
| 606 | * Get match type |
| 607 | * |
| 608 | * @return string |
| 609 | */ |
| 610 | public function getMatchType(): string |
| 611 | { |
| 612 | return $this->matchType; |
| 613 | } |
| 614 | |
| 615 | /** |
| 616 | * Get confidence score |
| 617 | * |
| 618 | * @return int |
| 619 | */ |
| 620 | public function getConfidence(): int |
| 621 | { |
| 622 | return $this->confidence; |
| 623 | } |
| 624 | |
| 625 | /** |
| 626 | * Get match reason |
| 627 | * |
| 628 | * @return string |
| 629 | */ |
| 630 | public function getReason(): string |
| 631 | { |
| 632 | return $this->reason; |
| 633 | } |
| 634 | |
| 635 | /** |
| 636 | * Whether this is a high-confidence match (>= 90%) |
| 637 | * |
| 638 | * @return bool |
| 639 | */ |
| 640 | public function isHighConfidence(): bool |
| 641 | { |
| 642 | return $this->confidence >= 90; |
| 643 | } |
| 644 | |
| 645 | /** |
| 646 | * Convert to array |
| 647 | * |
| 648 | * @return array |
| 649 | */ |
| 650 | public function toArray(): array |
| 651 | { |
| 652 | return [ |
| 653 | 'matched' => $this->wasMatched(), |
| 654 | 'userId' => $this->getUserId(), |
| 655 | 'matchType' => $this->matchType, |
| 656 | 'confidence' => $this->confidence, |
| 657 | 'reason' => $this->reason, |
| 658 | ]; |
| 659 | } |
| 660 | } |