Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 291
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
WhenIWorkProvider
0.00% covered (danger)
0.00%
0 / 291
0.00% covered (danger)
0.00%
0 / 24
8010
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 / 18
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 / 66
0.00% covered (danger)
0.00%
0 / 1
210
 createUserFromWhenIWork
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 updateUserFromWhenIWork
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
110
 ensureStoreAssignment
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getUserById
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 downloadAndCacheAvatar
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
306
 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 WhenIWork
11 *
12 * This provider reads employee data from the local database (synced from WhenIWork)
13 * and supports periodic synchronization from the WhenIWork API. Employee creation
14 * and most updates must be done in WhenIWork 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 */
22class WhenIWorkProvider implements EmployeeProviderInterface
23{
24    /**
25     * @var \Store Store object
26     */
27    private $store;
28
29    /**
30     * @var \PDO Store database connection (for legacy methods)
31     */
32    private $db;
33
34    /**
35     * @var \PDO Central database connection (kiosk_users)
36     */
37    private $centralDb;
38
39    /**
40     * @var \Predis\Client Redis client for caching
41     */
42    private $cache;
43
44    /**
45     * @var UserMatcher User matching service
46     */
47    private $userMatcher;
48
49    /**
50     * @var AuditLogger Audit logging service
51     */
52    private $auditLogger;
53
54    /**
55     * @var bool Whether to use unified users table (true) or legacy employees table (false)
56     */
57    private $useUnifiedUsers = true;
58
59    /**
60     * Constructor
61     *
62     * @param \Store $store Store object with WhenIWork configuration
63     * @param \PDO $db Database connection to the store database
64     * @param \PDO|null $centralDb Central database connection (auto-connected if null)
65     */
66    public function __construct($store, \PDO $db, ?\PDO $centralDb = null)
67    {
68        $this->store = $store;
69        $this->db = $db;
70
71        // Connect to central database if not provided
72        if ($centralDb === null) {
73            $this->centralDb = \dbConnectByName('kiosk_users');
74        } else {
75            $this->centralDb = $centralDb;
76        }
77
78        $this->cache = new \Predis\Client($_ENV['REDIS_URL']);
79        $this->userMatcher = new UserMatcher($this->centralDb);
80        $this->auditLogger = new AuditLogger($this->centralDb);
81    }
82
83    /**
84     * Get all active employees
85     *
86     * Uses Redis cache with 5 minute TTL to reduce database load
87     *
88     * @return Employee[] Array of Employee objects
89     */
90    public function getActiveEmployees(): array
91    {
92        // Try cache first
93        $cacheKey = $this->store->getTypeNum() . '_employees_active';
94
95        try {
96            $cached = $this->cache->get($cacheKey);
97
98            if ($cached !== null) {
99                // Convert cached arrays back to Employee objects
100                $cachedData = json_decode($cached, true);
101                if (is_array($cachedData)) {
102                    return array_map(fn($data) => Employee::fromArray($data), $cachedData);
103                }
104            }
105        } catch (\Exception $e) {
106            error_log("WhenIWorkProvider::getActiveEmployees cache error: " . $e->getMessage());
107            // Continue to fetch from database
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 (store toArray format for proper restoration)
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("WhenIWorkProvider::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
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 WhenIWork
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 syncs from WhenIWork API
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 WhenIWork
271     */
272    public function createEmployee(array $data): Employee
273    {
274        throw new \Exception('Create employees in WhenIWork, then sync');
275    }
276
277    /**
278     * Update employee data
279     *
280     * Only local-only fields can be updated (fields not managed by WhenIWork):
281     * - emergencyContactName
282     * - emergencyContactPhone
283     * - drsEmployeeId
284     * - dailyEmailEnabled
285     *
286     * Other fields (name, email, phone, position, etc.) must be updated in WhenIWork
287     *
288     * @param int $employeeId The employee ID (user ID in unified system)
289     * @param array $data Fields to update
290     * @return Employee Updated employee object
291     */
292    public function updateEmployee(int $employeeId, array $data): Employee
293    {
294        if ($this->useUnifiedUsers) {
295            return $this->updateEmployeeUnified($employeeId, $data);
296        }
297
298        return $this->updateEmployeeLegacy($employeeId, $data);
299    }
300
301    /**
302     * Update employee in unified users table
303     *
304     * @param int $userId User ID
305     * @param array $data Fields to update
306     * @return Employee
307     */
308    private function updateEmployeeUnified(int $userId, array $data): Employee
309    {
310        $typeNum = $this->store->getTypeNum();
311
312        // Fields that go on the user record
313        $userFields = ['emergencyContactName', 'emergencyContactPhone', 'position', 'hourlyRate', 'hireDate'];
314
315        // Fields that go on the store assignment
316        $assignmentFields = ['drsEmployeeId', 'clockPin', 'role'];
317
318        // Update user fields
319        $userUpdates = [];
320        $userParams = ['id' => $userId];
321        foreach ($userFields as $field) {
322            if (array_key_exists($field, $data)) {
323                $userUpdates[] = "{$field} = :{$field}";
324                $userParams[$field] = $data[$field];
325            }
326        }
327        if (!empty($userUpdates)) {
328            $sql = "UPDATE users SET " . implode(', ', $userUpdates) . " WHERE id = :id";
329            $stmt = $this->centralDb->prepare($sql);
330            $stmt->execute($userParams);
331        }
332
333        // Update assignment fields
334        $assignmentData = [];
335        foreach ($assignmentFields as $field) {
336            if (array_key_exists($field, $data)) {
337                $assignmentData[$field] = $data[$field];
338            }
339        }
340        if (!empty($assignmentData)) {
341            StoreAssignment::upsert($this->centralDb, $userId, $typeNum, $assignmentData);
342        }
343
344        $this->clearCache();
345
346        return $this->getEmployee($userId);
347    }
348
349    /**
350     * Update employee in legacy employees table
351     *
352     * @param int $employeeId Employee ID
353     * @param array $data Fields to update
354     * @return Employee
355     */
356    private function updateEmployeeLegacy(int $employeeId, array $data): Employee
357    {
358        $localOnlyFields = [
359            'emergencyContactName', 'emergencyContactPhone',
360            'drsEmployeeId', 'dailyEmailEnabled',
361            'position', 'hourlyRate', 'hireDate',
362            'mappedRole', 'roleColor'
363        ];
364
365        $updates = [];
366        $params = [':id' => $employeeId];
367
368        foreach ($data as $field => $value) {
369            if (in_array($field, $localOnlyFields)) {
370                $updates[] = "`$field` = :$field";
371                $params[":$field"] = $value;
372            }
373        }
374
375        if (!empty($updates)) {
376            $sql = "UPDATE employees SET " . implode(', ', $updates) . " WHERE employeeID = :id";
377            $stmt = $this->db->prepare($sql);
378            $stmt->execute($params);
379
380            $this->clearCache();
381        }
382
383        return $this->getEmployee($employeeId);
384    }
385
386    /**
387     * Deactivate employee
388     *
389     * Deactivates locally - will be reconciled on next sync from WhenIWork
390     *
391     * @param int $employeeId The employee ID (user ID in unified system)
392     * @param string|null $reason Deactivation reason (logged but not stored on employee)
393     * @return bool True if successful
394     */
395    public function deactivateEmployee(int $employeeId, ?string $reason = null): bool
396    {
397        if ($this->useUnifiedUsers) {
398            $typeNum = $this->store->getTypeNum();
399            $assignment = StoreAssignment::findByUserAndStore($this->centralDb, $employeeId, $typeNum);
400
401            if ($assignment) {
402                $result = $assignment->deactivate();
403                if ($result) {
404                    $this->logSyncUnified('deactivate', $employeeId, null, $reason);
405                    $this->clearCache();
406                }
407                return $result;
408            }
409            return false;
410        }
411
412        // Legacy
413        $stmt = $this->db->prepare("UPDATE employees SET active = 0 WHERE employeeID = :id");
414        $result = $stmt->execute([':id' => $employeeId]);
415
416        if ($result) {
417            $this->logSync('wheniwork', 'deactivate', $employeeId, null, $reason);
418            $this->clearCache();
419        }
420
421        return $result;
422    }
423
424    /**
425     * Sync employees from WhenIWork API
426     *
427     * UPDATED: Now syncs to central users table with robust duplicate detection.
428     *
429     * Process:
430     * 1. Fetch all employees from WhenIWork API for this location
431     * 2. For each employee, use UserMatcher to find existing user
432     * 3. Create new user OR update existing + create/update store assignment
433     * 4. Deactivate store assignments for employees removed from WhenIWork
434     * 5. Log all operations to userSyncLog
435     * 6. Clear cache
436     *
437     * @return SyncResult Summary of sync operation
438     */
439    public function syncEmployees(): SyncResult
440    {
441        $result = new SyncResult();
442
443        try {
444            // Fetch from WhenIWork API
445            $wiw = new \Wheniwork($this->store->getWiwToken());
446            $wiwResponse = $wiw->get('users', array('location_id' => $this->store->getWiwLocationID()));
447
448            if (!isset($wiwResponse->users) || empty($wiwResponse->users)) {
449                $result->errors[] = 'No employees returned from WhenIWork API';
450                return $result;
451            }
452
453            $wiwEmployees = $wiwResponse->users;
454            $typeNum = $this->store->getTypeNum();
455
456            // Get current store assignments for this store
457            $currentAssignments = StoreAssignment::findByStore($this->centralDb, $typeNum, false); // Include inactive
458            $assignmentsByUserId = [];
459            foreach ($currentAssignments as $assignment) {
460                $assignmentsByUserId[$assignment->getUserId()] = $assignment;
461            }
462
463            // Track which user IDs we've processed
464            $processedUserIds = [];
465
466            foreach ($wiwEmployees as $wiwEmp) {
467                $externalId = (string) $wiwEmp->id;
468                $firstName = $wiwEmp->first_name ?? '';
469                $lastName = $wiwEmp->last_name ?? '';
470                $email = $wiwEmp->email ?? null;
471                $phone = $wiwEmp->phone_number ?? null;
472
473                // Use UserMatcher to find existing user (robust duplicate detection!)
474                $matchResult = $this->userMatcher->findMatch(
475                    'wheniwork',
476                    $externalId,
477                    $email,
478                    $firstName,
479                    $lastName,
480                    $phone
481                );
482
483                if ($matchResult->wasMatched()) {
484                    // Found existing user - update and ensure store assignment
485                    $userId = $matchResult->getUserId();
486                    $this->updateUserFromWhenIWork($userId, $wiwEmp, $matchResult);
487                    $this->ensureStoreAssignment($userId, $typeNum, $wiwEmp);
488
489                    $processedUserIds[] = $userId;
490
491                    // Log the match type for analytics
492                    if ($matchResult->getMatchType() !== 'external_id') {
493                        // Interesting match - log it
494                        $this->logSyncUnified(
495                            'merge',
496                            $userId,
497                            $externalId,
498                            "Matched via {$matchResult->getMatchType()} (confidence: {$matchResult->getConfidence()}%): {$matchResult->getReason()}"
499                        );
500                        $result->merged = ($result->merged ?? 0) + 1;
501                    }
502
503                    $result->updated++;
504                } else {
505                    // No existing user - create new
506                    $userId = $this->createUserFromWhenIWork($wiwEmp);
507                    $this->ensureStoreAssignment($userId, $typeNum, $wiwEmp);
508
509                    $processedUserIds[] = $userId;
510                    $result->created++;
511
512                    $this->logSyncUnified('create', $userId, $externalId, null);
513                }
514            }
515
516            // Deactivate assignments for users no longer in WhenIWork for this store
517            // IMPORTANT: Only deactivate the STORE ASSIGNMENT, not the user record!
518            foreach ($assignmentsByUserId as $userId => $assignment) {
519                if (!in_array($userId, $processedUserIds)) {
520                    // Check if this user was WhenIWork-sourced for THIS store
521                    $user = $this->getUserById($userId);
522                    if ($user && $user['source'] === 'wheniwork') {
523                        if ($assignment->isActive()) {
524                            $assignment->deactivate();
525                            $this->logSyncUnified('deactivate', $userId, $user['externalId'] ?? null, 'Removed from WhenIWork');
526                            $result->deactivated++;
527                        }
528                    }
529                } elseif (!$assignment->isActive()) {
530                    // Reactivate if user is back in WhenIWork
531                    $assignment->reactivate();
532                    $user = $this->getUserById($userId);
533                    $this->logSyncUnified('reactivate', $userId, $user['externalId'] ?? null, 'Reappeared in WhenIWork');
534                    $result->reactivated++;
535                }
536            }
537
538            // Log successful sync
539            $this->logSyncUnified('sync_complete', null, null, json_encode($result->toArray()));
540
541            // Clear cache
542            $this->clearCache();
543
544        } catch (\Exception $e) {
545            $result->errors[] = $e->getMessage();
546            $this->logSyncUnified('error', null, null, $e->getMessage());
547            error_log("WhenIWorkProvider::syncEmployees error: " . $e->getMessage());
548        }
549
550        return $result;
551    }
552
553    /**
554     * Create a new user from WhenIWork data
555     *
556     * @param object $wiwEmp WhenIWork employee object
557     * @return int New user ID
558     */
559    private function createUserFromWhenIWork($wiwEmp): int
560    {
561        // Download and cache avatar locally
562        $photoUrl = $this->downloadAndCacheAvatar($wiwEmp);
563
564        $stmt = $this->centralDb->prepare("
565            INSERT INTO users (
566                firstName, lastName, email, phone, photoUrl,
567                position, hourlyRate, source, externalId,
568                canLogin, accountType, enabled, active,
569                lastSyncedAt, createdAt, updatedAt
570            ) VALUES (
571                :firstName, :lastName, :email, :phone, :photoUrl,
572                :position, :hourlyRate, 'wheniwork', :externalId,
573                0, 'employee', 1, 1,
574                NOW(), NOW(), NOW()
575            )
576        ");
577
578        $stmt->execute([
579            'firstName' => $wiwEmp->first_name ?? '',
580            'lastName' => $wiwEmp->last_name ?? '',
581            'email' => $wiwEmp->email ?? null,
582            'phone' => $wiwEmp->phone_number ?? null,
583            'photoUrl' => $photoUrl,
584            'position' => isset($wiwEmp->positions[0]->name) ? $wiwEmp->positions[0]->name : null,
585            'hourlyRate' => $wiwEmp->hourly_rate ?? null,
586            'externalId' => (string) $wiwEmp->id,
587        ]);
588
589        return (int)$this->centralDb->lastInsertId();
590    }
591
592    /**
593     * Update existing user with WhenIWork data
594     *
595     * @param int $userId User ID
596     * @param object $wiwEmp WhenIWork employee object
597     * @param \BuyerKiosk\Auth\Services\MatchResult $matchResult How the user was matched
598     */
599    private function updateUserFromWhenIWork(int $userId, $wiwEmp, $matchResult): void
600    {
601        // Check if this user has a manual avatar override
602        $stmt = $this->centralDb->prepare("SELECT avatarOverride, photoUrl, source, externalId FROM users WHERE id = :id");
603        $stmt->execute(['id' => $userId]);
604        $currentUser = $stmt->fetch(\PDO::FETCH_ASSOC);
605
606        $photoUrl = null;
607        $updatePhoto = true;
608
609        // If avatar is overridden, don't update the photo
610        if ($currentUser && !empty($currentUser['avatarOverride'])) {
611            $updatePhoto = false;
612            $photoUrl = $currentUser['photoUrl'];
613        } else {
614            $photoUrl = $this->downloadAndCacheAvatar($wiwEmp);
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 = :position,
624                hourlyRate = COALESCE(:hourlyRate, hourlyRate),
625                lastSyncedAt = NOW(),
626                updatedAt = NOW()";
627
628        // Update photo if not overridden
629        if ($updatePhoto && $photoUrl) {
630            $sql .= ", photoUrl = :photoUrl";
631        }
632
633        // If this was a non-external-id match, update source/externalId to link records
634        if ($matchResult->getMatchType() !== 'external_id') {
635            $sql .= ", source = 'wheniwork', externalId = :externalId";
636        }
637
638        $sql .= " WHERE id = :id";
639
640        $stmt = $this->centralDb->prepare($sql);
641
642        $params = [
643            'firstName' => $wiwEmp->first_name ?? '',
644            'lastName' => $wiwEmp->last_name ?? '',
645            'email' => $wiwEmp->email ?? null,
646            'phone' => $wiwEmp->phone_number ?? null,
647            'position' => isset($wiwEmp->positions[0]->name) ? $wiwEmp->positions[0]->name : null,
648            'hourlyRate' => $wiwEmp->hourly_rate ?? null,
649            'id' => $userId,
650        ];
651
652        if ($updatePhoto && $photoUrl) {
653            $params['photoUrl'] = $photoUrl;
654        }
655
656        if ($matchResult->getMatchType() !== 'external_id') {
657            $params['externalId'] = (string) $wiwEmp->id;
658        }
659
660        $stmt->execute($params);
661    }
662
663    /**
664     * Ensure a store assignment exists for user
665     *
666     * @param int $userId User ID
667     * @param string $typeNum Store identifier
668     * @param object $wiwEmp WhenIWork employee object (for store-specific data)
669     */
670    private function ensureStoreAssignment(int $userId, string $typeNum, $wiwEmp): void
671    {
672        // Clock PIN might come from WhenIWork custom fields in the future
673        // For now, preserve any existing PIN
674        $existing = StoreAssignment::findByUserAndStore($this->centralDb, $userId, $typeNum);
675
676        $data = [];
677
678        // Only set clockPin if we have one and there isn't an existing one
679        if (!$existing || !$existing->getClockPin()) {
680            // In the future, we might get this from WhenIWork
681            // $data['clockPin'] = $wiwEmp->clock_pin ?? null;
682        }
683
684        StoreAssignment::upsert($this->centralDb, $userId, $typeNum, $data);
685    }
686
687    /**
688     * Get user by ID from central database
689     *
690     * @param int $userId User ID
691     * @return array|null
692     */
693    private function getUserById(int $userId): ?array
694    {
695        $stmt = $this->centralDb->prepare("SELECT * FROM users WHERE id = :id");
696        $stmt->execute(['id' => $userId]);
697        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
698        return $row ?: null;
699    }
700
701    /**
702     * Download and cache employee avatar locally
703     *
704     * WhenIWork returns avatar as an object with URL templates:
705     * {
706     *   "url": "https://platform.api.wheniwork.com/avatar/{uuid}/%s",
707     *   "cacheUrl": "https://images.wheniwork.com/avatars/{uuid}/%s"
708     * }
709     * The %s placeholder should be replaced with a size name (small, medium, large)
710     *
711     * @param object $wiwEmp WhenIWork employee object
712     * @param string $size Avatar size name (small, medium, large) - default 'medium'
713     * @param bool $forceDownload Force re-download even if local file exists
714     * @return string|null Local URL path or null if download failed
715     */
716    private function downloadAndCacheAvatar($wiwEmp, string $size = 'medium', bool $forceDownload = false): ?string
717    {
718        // Generate filename from WhenIWork user ID
719        $wiwUserId = (string) $wiwEmp->id;
720        $filename = 'avatar_' . $wiwUserId . '.jpg';
721        $uploadDir = $_SERVER['DOCUMENT_ROOT'] . '/uploads/employees/' . $this->store->getTypeNum();
722        $localPath = $uploadDir . '/' . $filename;
723        $localUrl = '/uploads/employees/' . $this->store->getTypeNum() . '/' . $filename;
724
725        // Check if we already have a local copy
726        if (!$forceDownload && file_exists($localPath) && filesize($localPath) > 100) {
727            return $localUrl;
728        }
729
730        // Get the remote avatar URL
731        $remoteUrl = null;
732
733        if (isset($wiwEmp->avatar) && is_object($wiwEmp->avatar)) {
734            if (isset($wiwEmp->avatar->cacheUrl)) {
735                $remoteUrl = str_replace('%s', $size, $wiwEmp->avatar->cacheUrl);
736            } elseif (isset($wiwEmp->avatar->url)) {
737                $remoteUrl = str_replace('%s', $size, $wiwEmp->avatar->url);
738            }
739        } elseif (isset($wiwEmp->avatar_url) && is_string($wiwEmp->avatar_url)) {
740            $remoteUrl = $wiwEmp->avatar_url;
741        }
742
743        if (!$remoteUrl) {
744            return null;
745        }
746
747        // Create local storage directory
748        if (!is_dir($uploadDir)) {
749            mkdir($uploadDir, 0755, true);
750        }
751
752        // Download the image
753        try {
754            $context = stream_context_create([
755                'http' => [
756                    'timeout' => 10,
757                    'user_agent' => 'BuyerKiosk/1.0'
758                ]
759            ]);
760
761            $imageData = @file_get_contents($remoteUrl, false, $context);
762
763            if ($imageData === false || strlen($imageData) < 100) {
764                // Image doesn't exist or is too small (likely a 404 response)
765                return null;
766            }
767
768            // Verify it's actually an image
769            $finfo = new \finfo(FILEINFO_MIME_TYPE);
770            $mimeType = $finfo->buffer($imageData);
771
772            if (!in_array($mimeType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'])) {
773                return null;
774            }
775
776            // Save locally
777            if (file_put_contents($localPath, $imageData) !== false) {
778                return $localUrl;
779            }
780        } catch (\Exception $e) {
781            error_log("WhenIWorkProvider::downloadAndCacheAvatar error for user {$wiwUserId}" . $e->getMessage());
782        }
783
784        return null;
785    }
786
787    /**
788     * Log a sync operation to userSyncLog table (unified)
789     *
790     * @param string $action Action type (create, update, merge, deactivate, reactivate, error, sync_complete)
791     * @param int|null $userId User ID
792     * @param string|null $externalId WhenIWork user ID
793     * @param string|null $details Additional details (JSON or text)
794     */
795    private function logSyncUnified(string $action, ?int $userId, ?string $externalId, ?string $details): void
796    {
797        $typeNum = $this->store->getTypeNum();
798
799        $stmt = $this->centralDb->prepare("
800            INSERT INTO userSyncLog (userId, typeNum, provider, action, externalId, details, createdAt)
801            VALUES (:userId, :typeNum, 'wheniwork', :action, :externalId, :details, NOW())
802        ");
803        $stmt->execute([
804            'userId' => $userId,
805            'typeNum' => $typeNum,
806            'action' => $action,
807            'externalId' => $externalId,
808            'details' => $details,
809        ]);
810    }
811
812    /**
813     * Log a sync operation to employee_sync_log table (legacy)
814     *
815     * @param string $provider Provider name ('wheniwork')
816     * @param string $action Action type (create, update, deactivate, reactivate, error, sync_complete)
817     * @param int|null $employeeId Local employee ID
818     * @param string|null $externalId WhenIWork user ID
819     * @param string|null $details Additional details (JSON or text)
820     * @return void
821     */
822    private function logSync(string $provider, string $action, ?int $employeeId, ?string $externalId, ?string $details): void
823    {
824        $stmt = $this->db->prepare("
825            INSERT INTO employee_sync_log (provider, action, employeeId, externalId, details)
826            VALUES (:provider, :action, :employeeId, :externalId, :details)
827        ");
828        $stmt->execute([
829            ':provider' => $provider,
830            ':action' => $action,
831            ':employeeId' => $employeeId,
832            ':externalId' => $externalId,
833            ':details' => $details,
834        ]);
835    }
836
837    /**
838     * Clear the Redis cache for active employees
839     *
840     * @return void
841     */
842    private function clearCache(): void
843    {
844        $cacheKey = $this->store->getTypeNum() . '_employees_active';
845        $this->cache->del($cacheKey);
846    }
847
848    /**
849     * Enable or disable unified users mode
850     *
851     * @param bool $enabled True to use unified users table
852     */
853    public function setUseUnifiedUsers(bool $enabled): void
854    {
855        $this->useUnifiedUsers = $enabled;
856    }
857
858    /**
859     * Set the cache client (for testing purposes)
860     *
861     * @param object $cache Cache client implementing get/setex methods
862     */
863    public function setCache(object $cache): void
864    {
865        $this->cache = $cache;
866    }
867}