Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 252
0.00% covered (danger)
0.00%
0 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
HomebaseProvider
0.00% covered (danger)
0.00%
0 / 252
0.00% covered (danger)
0.00%
0 / 23
4830
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getActiveEmployees
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 getActiveEmployeesFromUnified
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getActiveEmployeesFromLegacy
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getEmployee
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getEmployeeFromUnified
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 supportsCreate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 supportsSync
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createEmployee
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateEmployee
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 updateEmployeeUnified
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 updateEmployeeLegacy
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 deactivateEmployee
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 syncEmployees
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
240
 createUserFromHomebase
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 updateUserFromHomebase
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
90
 ensureStoreAssignment
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getUserById
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 logSyncUnified
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 logSync
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 clearCache
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setUseUnifiedUsers
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\Employee;
4
5use BuyerKiosk\Auth\Models\StoreAssignment;
6use BuyerKiosk\Auth\Services\UserMatcher;
7use BuyerKiosk\Auth\Services\AuditLogger;
8
9/**
10 * Provider that syncs employees from Homebase
11 *
12 * This provider reads employee data from the central users database and
13 * supports periodic synchronization from the Homebase API. Employee creation
14 * and most updates must be done in Homebase itself.
15 *
16 * UPDATED for Unified Users (Phase 5):
17 * - Syncs to central `users` table instead of per-store `employees`
18 * - Creates `userStoreAssignments` for store-specific data
19 * - Uses UserMatcher for robust duplicate detection
20 * - Maintains backward compatibility with legacy methods
21 *
22 * TODO: Implement Homebase API client when ready
23 */
24class HomebaseProvider implements EmployeeProviderInterface
25{
26    /**
27     * @var \Store Store object
28     */
29    private $store;
30
31    /**
32     * @var \PDO Store database connection (for legacy methods)
33     */
34    private $db;
35
36    /**
37     * @var \PDO Central database connection (kiosk_users)
38     */
39    private $centralDb;
40
41    /**
42     * @var \Predis\Client Redis client for caching
43     */
44    private $cache;
45
46    /**
47     * @var UserMatcher User matching service
48     */
49    private $userMatcher;
50
51    /**
52     * @var AuditLogger Audit logging service
53     */
54    private $auditLogger;
55
56    /**
57     * @var bool Whether to use unified users table (true) or legacy employees table (false)
58     */
59    private $useUnifiedUsers = true;
60
61    /**
62     * Constructor
63     *
64     * @param \Store $store Store object with Homebase configuration
65     * @param \PDO $db Database connection to the store database
66     * @param \PDO|null $centralDb Central database connection (auto-connected if null)
67     */
68    public function __construct($store, \PDO $db, ?\PDO $centralDb = null)
69    {
70        $this->store = $store;
71        $this->db = $db;
72
73        // Connect to central database if not provided
74        if ($centralDb === null) {
75            $this->centralDb = \dbConnectByName('kiosk_users');
76        } else {
77            $this->centralDb = $centralDb;
78        }
79
80        $this->cache = new \Predis\Client($_ENV['REDIS_URL']);
81        $this->userMatcher = new UserMatcher($this->centralDb);
82        $this->auditLogger = new AuditLogger($this->centralDb);
83    }
84
85    /**
86     * Get all active employees
87     *
88     * Uses Redis cache with 5 minute TTL to reduce database load
89     *
90     * @return Employee[] Array of Employee objects
91     */
92    public function getActiveEmployees(): array
93    {
94        // Try cache first
95        $cacheKey = $this->store->getTypeNum() . '_employees_active';
96
97        try {
98            $cached = $this->cache->get($cacheKey);
99
100            if ($cached !== null) {
101                $cachedData = json_decode($cached, true);
102                if (is_array($cachedData)) {
103                    return array_map(fn($data) => Employee::fromArray($data), $cachedData);
104                }
105            }
106        } catch (\Exception $e) {
107            error_log("HomebaseProvider::getActiveEmployees cache error: " . $e->getMessage());
108        }
109
110        // Fetch from unified users table with store assignments
111        if ($this->useUnifiedUsers) {
112            $employees = $this->getActiveEmployeesFromUnified();
113        } else {
114            $employees = $this->getActiveEmployeesFromLegacy();
115        }
116
117        // Cache for 5 minutes
118        try {
119            $toCache = array_map(fn($emp) => $emp->toArray(), $employees);
120            $this->cache->setex($cacheKey, 300, json_encode($toCache));
121        } catch (\Exception $e) {
122            error_log("HomebaseProvider::getActiveEmployees cache write error: " . $e->getMessage());
123        }
124
125        return $employees;
126    }
127
128    /**
129     * Get active employees from unified users table
130     *
131     * @return Employee[]
132     */
133    private function getActiveEmployeesFromUnified(): array
134    {
135        $typeNum = $this->store->getTypeNum();
136
137        $stmt = $this->centralDb->prepare("
138            SELECT
139                u.id AS employeeID,
140                u.username AS login,
141                u.firstName AS employeeFirstName,
142                u.lastName AS employeeLastName,
143                u.email,
144                u.phone,
145                u.photoUrl,
146                u.position,
147                u.hourlyRate,
148                u.source,
149                u.externalId,
150                u.lastSyncedAt,
151                u.createdAt,
152                usa.clockPin,
153                usa.drsEmployeeId,
154                usa.role,
155                usa.isActive AS active
156            FROM users u
157            INNER JOIN userStoreAssignments usa ON u.id = usa.userId
158            WHERE usa.typeNum = :typeNum AND usa.isActive = 1
159            ORDER BY u.firstName, u.lastName
160        ");
161        $stmt->execute(['typeNum' => $typeNum]);
162
163        return array_map(
164            fn($row) => Employee::fromRow($row),
165            $stmt->fetchAll(\PDO::FETCH_ASSOC)
166        );
167    }
168
169    /**
170     * Get active employees from legacy employees table
171     *
172     * @return Employee[]
173     */
174    private function getActiveEmployeesFromLegacy(): array
175    {
176        $stmt = $this->db->prepare("
177            SELECT * FROM employees
178            WHERE active = 1
179            ORDER BY employeeFirstName, employeeLastName
180        ");
181        $stmt->execute();
182
183        return array_map(
184            fn($row) => Employee::fromRow($row),
185            $stmt->fetchAll(\PDO::FETCH_ASSOC)
186        );
187    }
188
189    /**
190     * Get single employee by ID
191     *
192     * @param int $employeeId The employee ID (user ID in unified system)
193     * @return Employee|null Employee object or null if not found
194     */
195    public function getEmployee(int $employeeId): ?Employee
196    {
197        if ($this->useUnifiedUsers) {
198            return $this->getEmployeeFromUnified($employeeId);
199        }
200
201        $stmt = $this->db->prepare("SELECT * FROM employees WHERE employeeID = :id");
202        $stmt->execute([':id' => $employeeId]);
203        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
204
205        return $row ? Employee::fromRow($row) : null;
206    }
207
208    /**
209     * Get employee from unified users table
210     *
211     * @param int $userId User ID
212     * @return Employee|null
213     */
214    private function getEmployeeFromUnified(int $userId): ?Employee
215    {
216        $typeNum = $this->store->getTypeNum();
217
218        $stmt = $this->centralDb->prepare("
219            SELECT
220                u.id AS employeeID,
221                u.username AS login,
222                u.firstName AS employeeFirstName,
223                u.lastName AS employeeLastName,
224                u.email,
225                u.phone,
226                u.photoUrl,
227                u.position,
228                u.hourlyRate,
229                u.source,
230                u.externalId,
231                u.lastSyncedAt,
232                u.createdAt,
233                usa.clockPin,
234                usa.drsEmployeeId,
235                usa.role,
236                usa.isActive AS active
237            FROM users u
238            LEFT JOIN userStoreAssignments usa ON u.id = usa.userId AND usa.typeNum = :typeNum
239            WHERE u.id = :id
240        ");
241        $stmt->execute(['id' => $userId, 'typeNum' => $typeNum]);
242        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
243
244        return $row ? Employee::fromRow($row) : null;
245    }
246
247    /**
248     * Whether this provider supports creating new employees
249     *
250     * @return bool False - employees must be created in Homebase
251     */
252    public function supportsCreate(): bool
253    {
254        return false;
255    }
256
257    /**
258     * Whether this provider supports syncing from external source
259     *
260     * @return bool True - provider supports syncing from Homebase
261     */
262    public function supportsSync(): bool
263    {
264        return true;
265    }
266
267    /**
268     * Create new employee
269     *
270     * @throws \Exception Always throws - employees must be created in Homebase
271     */
272    public function createEmployee(array $data): Employee
273    {
274        throw new \Exception('Create employees in Homebase, then sync');
275    }
276
277    /**
278     * Update employee data
279     *
280     * Only local-only fields can be updated (fields not managed by Homebase):
281     * - emergencyContactName
282     * - emergencyContactPhone
283     * - drsEmployeeId
284     * - dailyEmailEnabled
285     *
286     * @param int $employeeId The employee ID (user ID in unified system)
287     * @param array $data Fields to update
288     * @return Employee Updated employee object
289     */
290    public function updateEmployee(int $employeeId, array $data): Employee
291    {
292        if ($this->useUnifiedUsers) {
293            return $this->updateEmployeeUnified($employeeId, $data);
294        }
295
296        return $this->updateEmployeeLegacy($employeeId, $data);
297    }
298
299    /**
300     * Update employee in unified users table
301     *
302     * @param int $userId User ID
303     * @param array $data Fields to update
304     * @return Employee
305     */
306    private function updateEmployeeUnified(int $userId, array $data): Employee
307    {
308        $typeNum = $this->store->getTypeNum();
309
310        // Fields that go on the user record
311        $userFields = ['emergencyContactName', 'emergencyContactPhone', 'position', 'hourlyRate', 'hireDate'];
312
313        // Fields that go on the store assignment
314        $assignmentFields = ['drsEmployeeId', 'clockPin', 'role'];
315
316        // Update user fields
317        $userUpdates = [];
318        $userParams = ['id' => $userId];
319        foreach ($userFields as $field) {
320            if (array_key_exists($field, $data)) {
321                $userUpdates[] = "{$field} = :{$field}";
322                $userParams[$field] = $data[$field];
323            }
324        }
325        if (!empty($userUpdates)) {
326            $sql = "UPDATE users SET " . implode(', ', $userUpdates) . " WHERE id = :id";
327            $stmt = $this->centralDb->prepare($sql);
328            $stmt->execute($userParams);
329        }
330
331        // Update assignment fields
332        $assignmentData = [];
333        foreach ($assignmentFields as $field) {
334            if (array_key_exists($field, $data)) {
335                $assignmentData[$field] = $data[$field];
336            }
337        }
338        if (!empty($assignmentData)) {
339            StoreAssignment::upsert($this->centralDb, $userId, $typeNum, $assignmentData);
340        }
341
342        $this->clearCache();
343
344        return $this->getEmployee($userId);
345    }
346
347    /**
348     * Update employee in legacy employees table
349     *
350     * @param int $employeeId Employee ID
351     * @param array $data Fields to update
352     * @return Employee
353     */
354    private function updateEmployeeLegacy(int $employeeId, array $data): Employee
355    {
356        $localOnlyFields = [
357            'emergencyContactName', 'emergencyContactPhone',
358            'drsEmployeeId', 'dailyEmailEnabled',
359            'position', 'hourlyRate', 'hireDate'
360        ];
361
362        $updates = [];
363        $params = [':id' => $employeeId];
364
365        foreach ($data as $field => $value) {
366            if (in_array($field, $localOnlyFields)) {
367                $updates[] = "`$field` = :$field";
368                $params[":$field"] = $value;
369            }
370        }
371
372        if (!empty($updates)) {
373            $sql = "UPDATE employees SET " . implode(', ', $updates) . " WHERE employeeID = :id";
374            $stmt = $this->db->prepare($sql);
375            $stmt->execute($params);
376
377            $this->clearCache();
378        }
379
380        return $this->getEmployee($employeeId);
381    }
382
383    /**
384     * Deactivate employee
385     *
386     * Deactivates locally - will be reconciled on next sync from Homebase
387     *
388     * @param int $employeeId The employee ID (user ID in unified system)
389     * @param string|null $reason Deactivation reason (logged but not stored on employee)
390     * @return bool True if successful
391     */
392    public function deactivateEmployee(int $employeeId, ?string $reason = null): bool
393    {
394        if ($this->useUnifiedUsers) {
395            $typeNum = $this->store->getTypeNum();
396            $assignment = StoreAssignment::findByUserAndStore($this->centralDb, $employeeId, $typeNum);
397
398            if ($assignment) {
399                $result = $assignment->deactivate();
400                if ($result) {
401                    $this->logSyncUnified('deactivate', $employeeId, null, $reason);
402                    $this->clearCache();
403                }
404                return $result;
405            }
406            return false;
407        }
408
409        // Legacy
410        $stmt = $this->db->prepare("UPDATE employees SET active = 0 WHERE employeeID = :id");
411        $result = $stmt->execute([':id' => $employeeId]);
412
413        if ($result) {
414            $this->logSync('homebase', 'deactivate', $employeeId, null, $reason);
415            $this->clearCache();
416        }
417
418        return $result;
419    }
420
421    /**
422     * Sync employees from Homebase API
423     *
424     * Uses UserMatcher for robust duplicate detection to prevent creating
425     * duplicate users across multiple stores.
426     *
427     * Process:
428     * 1. Fetch all employees from Homebase API for this location
429     * 2. For each employee, use UserMatcher to find existing user
430     * 3. Create new user OR update existing + create/update store assignment
431     * 4. Deactivate store assignments for employees removed from Homebase
432     * 5. Log all operations to userSyncLog
433     * 6. Clear cache
434     *
435     * @return SyncResult Summary of sync operation
436     */
437    public function syncEmployees(): SyncResult
438    {
439        $result = new SyncResult();
440
441        try {
442            // TODO: Replace with actual Homebase API call when implemented
443            // For now, check if store has Homebase configured
444            $homebaseApiKey = $this->store->getHomebaseApiKey ?? null;
445            $homebaseLocationId = $this->store->getHomebaseLocationId ?? null;
446
447            if (empty($homebaseApiKey) || empty($homebaseLocationId)) {
448                $result->errors[] = 'Homebase API not configured for this store';
449                return $result;
450            }
451
452            // TODO: Initialize Homebase API client
453            // $homebaseClient = new HomebaseApiClient($homebaseApiKey);
454            // $homebaseEmployees = $homebaseClient->getEmployees($homebaseLocationId);
455
456            // Placeholder - in production, this would be the API response
457            $homebaseEmployees = [];
458
459            if (empty($homebaseEmployees)) {
460                $result->errors[] = 'No employees returned from Homebase API (or API not yet implemented)';
461                return $result;
462            }
463
464            $typeNum = $this->store->getTypeNum();
465
466            // Get current store assignments for this store
467            $currentAssignments = StoreAssignment::findByStore($this->centralDb, $typeNum, false);
468            $assignmentsByUserId = [];
469            foreach ($currentAssignments as $assignment) {
470                $assignmentsByUserId[$assignment->getUserId()] = $assignment;
471            }
472
473            // Track which user IDs we've processed
474            $processedUserIds = [];
475
476            foreach ($homebaseEmployees as $hbEmp) {
477                $externalId = (string) $hbEmp->id;
478                $firstName = $hbEmp->first_name ?? '';
479                $lastName = $hbEmp->last_name ?? '';
480                $email = $hbEmp->email ?? null;
481                $phone = $hbEmp->phone ?? null;
482
483                // Use UserMatcher to find existing user (robust duplicate detection!)
484                $matchResult = $this->userMatcher->findMatch(
485                    'homebase',
486                    $externalId,
487                    $email,
488                    $firstName,
489                    $lastName,
490                    $phone
491                );
492
493                if ($matchResult->wasMatched()) {
494                    // Found existing user - update and ensure store assignment
495                    $userId = $matchResult->getUserId();
496                    $this->updateUserFromHomebase($userId, $hbEmp, $matchResult);
497                    $this->ensureStoreAssignment($userId, $typeNum, $hbEmp);
498
499                    $processedUserIds[] = $userId;
500
501                    // Log merge if matched via non-external-id
502                    if ($matchResult->getMatchType() !== 'external_id') {
503                        $this->logSyncUnified(
504                            'merge',
505                            $userId,
506                            $externalId,
507                            "Matched via {$matchResult->getMatchType()} (confidence: {$matchResult->getConfidence()}%): {$matchResult->getReason()}"
508                        );
509                        $result->merged++;
510                    }
511
512                    $result->updated++;
513                } else {
514                    // No existing user - create new
515                    $userId = $this->createUserFromHomebase($hbEmp);
516                    $this->ensureStoreAssignment($userId, $typeNum, $hbEmp);
517
518                    $processedUserIds[] = $userId;
519                    $result->created++;
520
521                    $this->logSyncUnified('create', $userId, $externalId, null);
522                }
523            }
524
525            // Deactivate assignments for users no longer in Homebase for this store
526            foreach ($assignmentsByUserId as $userId => $assignment) {
527                if (!in_array($userId, $processedUserIds)) {
528                    $user = $this->getUserById($userId);
529                    if ($user && $user['source'] === 'homebase') {
530                        if ($assignment->isActive()) {
531                            $assignment->deactivate();
532                            $this->logSyncUnified('deactivate', $userId, $user['externalId'] ?? null, 'Removed from Homebase');
533                            $result->deactivated++;
534                        }
535                    }
536                } elseif (!$assignment->isActive()) {
537                    $assignment->reactivate();
538                    $user = $this->getUserById($userId);
539                    $this->logSyncUnified('reactivate', $userId, $user['externalId'] ?? null, 'Reappeared in Homebase');
540                    $result->reactivated++;
541                }
542            }
543
544            // Log successful sync
545            $this->logSyncUnified('sync_complete', null, null, json_encode($result->toArray()));
546
547            // Clear cache
548            $this->clearCache();
549
550        } catch (\Exception $e) {
551            $result->errors[] = $e->getMessage();
552            $this->logSyncUnified('error', null, null, $e->getMessage());
553            error_log("HomebaseProvider::syncEmployees error: " . $e->getMessage());
554        }
555
556        return $result;
557    }
558
559    /**
560     * Create a new user from Homebase data
561     *
562     * @param object $hbEmp Homebase employee object
563     * @return int New user ID
564     */
565    private function createUserFromHomebase($hbEmp): int
566    {
567        $stmt = $this->centralDb->prepare("
568            INSERT INTO users (
569                firstName, lastName, email, phone, photoUrl,
570                position, hourlyRate, source, externalId,
571                canLogin, accountType, enabled, active,
572                lastSyncedAt, createdAt, updatedAt
573            ) VALUES (
574                :firstName, :lastName, :email, :phone, :photoUrl,
575                :position, :hourlyRate, 'homebase', :externalId,
576                0, 'employee', 1, 1,
577                NOW(), NOW(), NOW()
578            )
579        ");
580
581        $stmt->execute([
582            'firstName' => $hbEmp->first_name ?? '',
583            'lastName' => $hbEmp->last_name ?? '',
584            'email' => $hbEmp->email ?? null,
585            'phone' => $hbEmp->phone ?? null,
586            'photoUrl' => $hbEmp->photo_url ?? null,
587            'position' => $hbEmp->role ?? $hbEmp->position ?? null,
588            'hourlyRate' => $hbEmp->wage ?? $hbEmp->hourly_rate ?? null,
589            'externalId' => (string) $hbEmp->id,
590        ]);
591
592        return (int)$this->centralDb->lastInsertId();
593    }
594
595    /**
596     * Update existing user with Homebase data
597     *
598     * @param int $userId User ID
599     * @param object $hbEmp Homebase employee object
600     * @param \BuyerKiosk\Auth\Services\MatchResult $matchResult How the user was matched
601     */
602    private function updateUserFromHomebase(int $userId, $hbEmp, $matchResult): void
603    {
604        // Check for avatar override
605        $stmt = $this->centralDb->prepare("SELECT avatarOverride, photoUrl FROM users WHERE id = :id");
606        $stmt->execute(['id' => $userId]);
607        $currentUser = $stmt->fetch(\PDO::FETCH_ASSOC);
608
609        $photoUrl = $hbEmp->photo_url ?? null;
610        $updatePhoto = true;
611
612        if ($currentUser && !empty($currentUser['avatarOverride'])) {
613            $updatePhoto = false;
614            $photoUrl = $currentUser['photoUrl'];
615        }
616
617        $sql = "
618            UPDATE users SET
619                firstName = :firstName,
620                lastName = :lastName,
621                email = COALESCE(:email, email),
622                phone = COALESCE(:phone, phone),
623                position = COALESCE(:position, position),
624                hourlyRate = COALESCE(:hourlyRate, hourlyRate),
625                lastSyncedAt = NOW(),
626                updatedAt = NOW()";
627
628        if ($updatePhoto && $photoUrl) {
629            $sql .= ", photoUrl = :photoUrl";
630        }
631
632        // Link records if matched via non-external-id
633        if ($matchResult->getMatchType() !== 'external_id') {
634            $sql .= ", source = 'homebase', externalId = :externalId";
635        }
636
637        $sql .= " WHERE id = :id";
638
639        $stmt = $this->centralDb->prepare($sql);
640
641        $params = [
642            'firstName' => $hbEmp->first_name ?? '',
643            'lastName' => $hbEmp->last_name ?? '',
644            'email' => $hbEmp->email ?? null,
645            'phone' => $hbEmp->phone ?? null,
646            'position' => $hbEmp->role ?? $hbEmp->position ?? null,
647            'hourlyRate' => $hbEmp->wage ?? $hbEmp->hourly_rate ?? null,
648            'id' => $userId,
649        ];
650
651        if ($updatePhoto && $photoUrl) {
652            $params['photoUrl'] = $photoUrl;
653        }
654
655        if ($matchResult->getMatchType() !== 'external_id') {
656            $params['externalId'] = (string) $hbEmp->id;
657        }
658
659        $stmt->execute($params);
660    }
661
662    /**
663     * Ensure a store assignment exists for user
664     *
665     * @param int $userId User ID
666     * @param string $typeNum Store identifier
667     * @param object $hbEmp Homebase employee object
668     */
669    private function ensureStoreAssignment(int $userId, string $typeNum, $hbEmp): void
670    {
671        $data = [];
672        // Homebase might provide clock PIN in custom fields
673        // $data['clockPin'] = $hbEmp->clock_pin ?? null;
674
675        StoreAssignment::upsert($this->centralDb, $userId, $typeNum, $data);
676    }
677
678    /**
679     * Get user by ID from central database
680     *
681     * @param int $userId User ID
682     * @return array|null
683     */
684    private function getUserById(int $userId): ?array
685    {
686        $stmt = $this->centralDb->prepare("SELECT * FROM users WHERE id = :id");
687        $stmt->execute(['id' => $userId]);
688        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
689        return $row ?: null;
690    }
691
692    /**
693     * Log a sync operation to userSyncLog table (unified)
694     *
695     * @param string $action Action type
696     * @param int|null $userId User ID
697     * @param string|null $externalId Homebase employee ID
698     * @param string|null $details Additional details
699     */
700    private function logSyncUnified(string $action, ?int $userId, ?string $externalId, ?string $details): void
701    {
702        $typeNum = $this->store->getTypeNum();
703
704        $stmt = $this->centralDb->prepare("
705            INSERT INTO userSyncLog (userId, typeNum, provider, action, externalId, details, createdAt)
706            VALUES (:userId, :typeNum, 'homebase', :action, :externalId, :details, NOW())
707        ");
708        $stmt->execute([
709            'userId' => $userId,
710            'typeNum' => $typeNum,
711            'action' => $action,
712            'externalId' => $externalId,
713            'details' => $details,
714        ]);
715    }
716
717    /**
718     * Log a sync operation to employee_sync_log table (legacy)
719     *
720     * @param string $provider Provider name
721     * @param string $action Action type
722     * @param int|null $employeeId Local employee ID
723     * @param string|null $externalId Homebase employee ID
724     * @param string|null $details Additional details
725     */
726    private function logSync(string $provider, string $action, ?int $employeeId, ?string $externalId, ?string $details): void
727    {
728        $stmt = $this->db->prepare("
729            INSERT INTO employee_sync_log (provider, action, employeeId, externalId, details)
730            VALUES (:provider, :action, :employeeId, :externalId, :details)
731        ");
732        $stmt->execute([
733            ':provider' => $provider,
734            ':action' => $action,
735            ':employeeId' => $employeeId,
736            ':externalId' => $externalId,
737            ':details' => $details,
738        ]);
739    }
740
741    /**
742     * Clear the Redis cache for active employees
743     */
744    private function clearCache(): void
745    {
746        $cacheKey = $this->store->getTypeNum() . '_employees_active';
747        $this->cache->del($cacheKey);
748    }
749
750    /**
751     * Enable or disable unified users mode
752     *
753     * @param bool $enabled True to use unified users table
754     */
755    public function setUseUnifiedUsers(bool $enabled): void
756    {
757        $this->useUnifiedUsers = $enabled;
758    }
759
760    /**
761     * Set the cache client (for testing purposes)
762     *
763     * @param object $cache Cache client implementing get/setex methods
764     */
765    public function setCache(object $cache): void
766    {
767        $this->cache = $cache;
768    }
769}