Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 190
0.00% covered (danger)
0.00%
0 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 2
UserMatcher
0.00% covered (danger)
0.00%
0 / 172
0.00% covered (danger)
0.00%
0 / 13
3540
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 findMatch
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
600
 findByExternalId
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 findByEmail
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 findByName
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 findByNameInStore
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 findByPhone
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 calculateMatchScore
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 calculateNameSimilarity
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 stringSimilarity
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 extractEmailDomain
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 buildMatchReason
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 logMatch
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
MatchResult
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 9
110
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 wasMatched
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getMatchType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfidence
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getReason
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isHighConfidence
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 toArray
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
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
16namespace BuyerKiosk\Auth\Services;
17
18class 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 */
537class 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}