Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 260
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserEmployeeLinkManager
0.00% covered (danger)
0.00%
0 / 260
0.00% covered (danger)
0.00%
0 / 13
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLinkedEmployee
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 getLinkedUser
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 hasLinkedUser
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 createLink
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
20
 removeLink
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 autoLinkByName
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
56
 autoLinkByLogin
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
42
 getUnlinkedEmployees
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getUnlinkedUsers
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getLinksForStore
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 syncEmployeeFlags
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 parseDisplayName
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace BuyerKiosk\UserEmployee;
4
5use PDO;
6use Exception;
7
8/**
9 * Manages user-employee links including CRUD and auto-linking
10 *
11 * This class handles the relationship between uf_user accounts (central database)
12 * and employee records (store databases). It provides methods for creating, querying,
13 * and automatically linking accounts based on name matching.
14 */
15class UserEmployeeLinkManager
16{
17    private PDO $centralDb;
18
19    /**
20     * Constructor
21     *
22     * @param PDO $centralDb Connection to kiosk_users database
23     */
24    public function __construct(PDO $centralDb)
25    {
26        $this->centralDb = $centralDb;
27    }
28
29    /**
30     * Get the linked employee for a user in a specific store
31     *
32     * @param int $userId User ID from uf_user table
33     * @param string $typeNum Store identifier
34     * @return array|null Employee data array or null if no link exists
35     */
36    public function getLinkedEmployee(int $userId, string $typeNum): ?array
37    {
38        $stmt = $this->centralDb->prepare(
39            "SELECT * FROM user_employee_links WHERE userId = :userId AND typeNum = :typeNum"
40        );
41        $stmt->bindValue(':userId', $userId, PDO::PARAM_INT);
42        $stmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
43        $stmt->execute();
44
45        $link = $stmt->fetch(PDO::FETCH_ASSOC);
46        if (!$link) {
47            return null;
48        }
49
50        // Get employee data from store database
51        $storeDb = dbConnectByName('kiosk_' . $typeNum);
52        $empStmt = $storeDb->prepare(
53            "SELECT * FROM employees WHERE employeeID = :employeeId"
54        );
55        $empStmt->bindValue(':employeeId', $link['employeeId'], PDO::PARAM_INT);
56        $empStmt->execute();
57
58        $employee = $empStmt->fetch(PDO::FETCH_ASSOC);
59        if (!$employee) {
60            return null;
61        }
62
63        // Merge link info with employee data
64        $employee['linkId'] = $link['id'];
65        $employee['linkType'] = $link['linkType'];
66        $employee['linkedAt'] = $link['linkedAt'];
67        $employee['linkedBy'] = $link['linkedBy'];
68
69        return $employee;
70    }
71
72    /**
73     * Get the linked user for an employee
74     *
75     * @param string $typeNum Store identifier
76     * @param int $employeeId Employee ID from employees table
77     * @return array|null User data array or null if no link exists
78     */
79    public function getLinkedUser(string $typeNum, int $employeeId): ?array
80    {
81        $stmt = $this->centralDb->prepare(
82            "SELECT * FROM user_employee_links WHERE typeNum = :typeNum AND employeeId = :employeeId"
83        );
84        $stmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
85        $stmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
86        $stmt->execute();
87
88        $link = $stmt->fetch(PDO::FETCH_ASSOC);
89        if (!$link) {
90            return null;
91        }
92
93        // Get user data from uf_user table
94        $userStmt = $this->centralDb->prepare(
95            "SELECT * FROM uf_user WHERE id = :userId"
96        );
97        $userStmt->bindValue(':userId', $link['userId'], PDO::PARAM_INT);
98        $userStmt->execute();
99
100        $user = $userStmt->fetch(PDO::FETCH_ASSOC);
101        if (!$user) {
102            return null;
103        }
104
105        // Merge link info with user data
106        $user['linkId'] = $link['id'];
107        $user['linkType'] = $link['linkType'];
108        $user['linkedAt'] = $link['linkedAt'];
109        $user['linkedBy'] = $link['linkedBy'];
110
111        return $user;
112    }
113
114    /**
115     * Check if employee has a linked user account
116     *
117     * @param string $typeNum Store identifier
118     * @param int $employeeId Employee ID
119     * @return bool True if employee has linked user account
120     */
121    public function hasLinkedUser(string $typeNum, int $employeeId): bool
122    {
123        $stmt = $this->centralDb->prepare(
124            "SELECT COUNT(*) as count FROM user_employee_links WHERE typeNum = :typeNum AND employeeId = :employeeId"
125        );
126        $stmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
127        $stmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
128        $stmt->execute();
129
130        $result = $stmt->fetch(PDO::FETCH_ASSOC);
131        return $result['count'] > 0;
132    }
133
134    /**
135     * Create a manual link between user and employee
136     *
137     * @param int $userId User ID from uf_user
138     * @param string $typeNum Store identifier
139     * @param int $employeeId Employee ID from employees table
140     * @param string $linkType Link type (auto_name, auto_login, manual, promotion)
141     * @param int|null $linkedBy User ID who created the link (null for auto)
142     * @return int The ID of the created link
143     * @throws Exception If link creation fails or duplicates exist
144     */
145    public function createLink(
146        int $userId,
147        string $typeNum,
148        int $employeeId,
149        string $linkType = 'manual',
150        ?int $linkedBy = null
151    ): int {
152        // Check if user is already linked in this store
153        $checkStmt = $this->centralDb->prepare(
154            "SELECT COUNT(*) as count FROM user_employee_links WHERE userId = :userId AND typeNum = :typeNum"
155        );
156        $checkStmt->bindValue(':userId', $userId, PDO::PARAM_INT);
157        $checkStmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
158        $checkStmt->execute();
159        $result = $checkStmt->fetch(PDO::FETCH_ASSOC);
160
161        if ($result['count'] > 0) {
162            throw new Exception("User is already linked to an employee in this store");
163        }
164
165        // Check if employee is already linked
166        $checkStmt = $this->centralDb->prepare(
167            "SELECT COUNT(*) as count FROM user_employee_links WHERE typeNum = :typeNum AND employeeId = :employeeId"
168        );
169        $checkStmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
170        $checkStmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
171        $checkStmt->execute();
172        $result = $checkStmt->fetch(PDO::FETCH_ASSOC);
173
174        if ($result['count'] > 0) {
175            throw new Exception("Employee is already linked to a user account");
176        }
177
178        // Create the link
179        $stmt = $this->centralDb->prepare(
180            "INSERT INTO user_employee_links (userId, typeNum, employeeId, linkType, linkedBy)
181             VALUES (:userId, :typeNum, :employeeId, :linkType, :linkedBy)"
182        );
183        $stmt->bindValue(':userId', $userId, PDO::PARAM_INT);
184        $stmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
185        $stmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
186        $stmt->bindValue(':linkType', $linkType, PDO::PARAM_STR);
187        $stmt->bindValue(':linkedBy', $linkedBy, $linkedBy !== null ? PDO::PARAM_INT : PDO::PARAM_NULL);
188        $stmt->execute();
189
190        $linkId = (int) $this->centralDb->lastInsertId();
191
192        // Update employee hasUserAccount flag
193        $storeDb = dbConnectByName('kiosk_' . $typeNum);
194        $updateStmt = $storeDb->prepare(
195            "UPDATE employees SET hasUserAccount = 1 WHERE employeeID = :employeeId"
196        );
197        $updateStmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
198        $updateStmt->execute();
199
200        return $linkId;
201    }
202
203    /**
204     * Remove a link between user and employee
205     *
206     * @param int $userId User ID
207     * @param string $typeNum Store identifier
208     * @return bool True if link was removed
209     */
210    public function removeLink(int $userId, string $typeNum): bool
211    {
212        // Get employee ID before deleting
213        $stmt = $this->centralDb->prepare(
214            "SELECT employeeId FROM user_employee_links WHERE userId = :userId AND typeNum = :typeNum"
215        );
216        $stmt->bindValue(':userId', $userId, PDO::PARAM_INT);
217        $stmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
218        $stmt->execute();
219        $link = $stmt->fetch(PDO::FETCH_ASSOC);
220
221        if (!$link) {
222            return false;
223        }
224
225        $employeeId = $link['employeeId'];
226
227        // Delete the link
228        $deleteStmt = $this->centralDb->prepare(
229            "DELETE FROM user_employee_links WHERE userId = :userId AND typeNum = :typeNum"
230        );
231        $deleteStmt->bindValue(':userId', $userId, PDO::PARAM_INT);
232        $deleteStmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
233        $deleteStmt->execute();
234
235        // Update employee hasUserAccount flag
236        $storeDb = dbConnectByName('kiosk_' . $typeNum);
237        $updateStmt = $storeDb->prepare(
238            "UPDATE employees SET hasUserAccount = 0 WHERE employeeID = :employeeId"
239        );
240        $updateStmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
241        $updateStmt->execute();
242
243        return true;
244    }
245
246    /**
247     * Auto-link users to employees by matching names
248     *
249     * Matches uf_user.display_name (parsed as "FirstName LastName") with
250     * employees.employeeFirstName + employeeLastName.
251     *
252     * @param string $typeNum Store identifier
253     * @return array Stats array with 'linked', 'skipped', and 'errors' keys
254     */
255    public function autoLinkByName(string $typeNum): array
256    {
257        $stats = [
258            'linked' => 0,
259            'skipped' => 0,
260            'errors' => []
261        ];
262
263        // Get all users in this store's group
264        $storeDb = dbConnectByName('kiosk_' . $typeNum);
265        $stmt = $this->centralDb->prepare(
266            "SELECT u.id, u.display_name
267             FROM uf_user u
268             JOIN uf_user_group ug ON u.id = ug.user_id
269             JOIN uf_group g ON ug.group_id = g.id
270             WHERE g.name = :storeName"
271        );
272        $stmt->bindValue(':storeName', $typeNum, PDO::PARAM_STR);
273        $stmt->execute();
274        $users = $stmt->fetchAll(PDO::FETCH_ASSOC);
275
276        foreach ($users as $user) {
277            // Check if user is already linked in this store
278            if ($this->getLinkedEmployee($user['id'], $typeNum)) {
279                $stats['skipped']++;
280                continue;
281            }
282
283            // Parse display name into first and last name
284            $nameParts = $this->parseDisplayName($user['display_name']);
285            if (!$nameParts) {
286                $stats['skipped']++;
287                continue;
288            }
289
290            // Look for matching employee
291            $empStmt = $storeDb->prepare(
292                "SELECT employeeID FROM employees
293                 WHERE employeeFirstName = :firstName
294                 AND employeeLastName = :lastName
295                 AND active = 1
296                 LIMIT 1"
297            );
298            $empStmt->bindValue(':firstName', $nameParts['firstName'], PDO::PARAM_STR);
299            $empStmt->bindValue(':lastName', $nameParts['lastName'], PDO::PARAM_STR);
300            $empStmt->execute();
301            $employee = $empStmt->fetch(PDO::FETCH_ASSOC);
302
303            if (!$employee) {
304                $stats['skipped']++;
305                continue;
306            }
307
308            // Check if employee is already linked
309            if ($this->hasLinkedUser($typeNum, $employee['employeeID'])) {
310                $stats['skipped']++;
311                continue;
312            }
313
314            // Create the link
315            try {
316                $this->createLink(
317                    $user['id'],
318                    $typeNum,
319                    $employee['employeeID'],
320                    'auto_name',
321                    null
322                );
323                $stats['linked']++;
324            } catch (Exception $e) {
325                $stats['errors'][] = "User {$user['id']} -> Employee {$employee['employeeID']}" . $e->getMessage();
326            }
327        }
328
329        return $stats;
330    }
331
332    /**
333     * Auto-link users to employees by matching login/username
334     *
335     * Matches uf_user.user_name with employees.login.
336     *
337     * @param string $typeNum Store identifier
338     * @return array Stats array with 'linked', 'skipped', and 'errors' keys
339     */
340    public function autoLinkByLogin(string $typeNum): array
341    {
342        $stats = [
343            'linked' => 0,
344            'skipped' => 0,
345            'errors' => []
346        ];
347
348        $storeDb = dbConnectByName('kiosk_' . $typeNum);
349
350        // Get all users in this store's group
351        $stmt = $this->centralDb->prepare(
352            "SELECT u.id, u.user_name
353             FROM uf_user u
354             JOIN uf_user_group ug ON u.id = ug.user_id
355             JOIN uf_group g ON ug.group_id = g.id
356             WHERE g.name = :storeName"
357        );
358        $stmt->bindValue(':storeName', $typeNum, PDO::PARAM_STR);
359        $stmt->execute();
360        $users = $stmt->fetchAll(PDO::FETCH_ASSOC);
361
362        foreach ($users as $user) {
363            // Check if user is already linked
364            if ($this->getLinkedEmployee($user['id'], $typeNum)) {
365                $stats['skipped']++;
366                continue;
367            }
368
369            // Look for matching employee by login
370            $empStmt = $storeDb->prepare(
371                "SELECT employeeID FROM employees
372                 WHERE login = :login
373                 AND active = 1
374                 LIMIT 1"
375            );
376            $empStmt->bindValue(':login', $user['user_name'], PDO::PARAM_STR);
377            $empStmt->execute();
378            $employee = $empStmt->fetch(PDO::FETCH_ASSOC);
379
380            if (!$employee) {
381                $stats['skipped']++;
382                continue;
383            }
384
385            // Check if employee is already linked
386            if ($this->hasLinkedUser($typeNum, $employee['employeeID'])) {
387                $stats['skipped']++;
388                continue;
389            }
390
391            // Create the link
392            try {
393                $this->createLink(
394                    $user['id'],
395                    $typeNum,
396                    $employee['employeeID'],
397                    'auto_login',
398                    null
399                );
400                $stats['linked']++;
401            } catch (Exception $e) {
402                $stats['errors'][] = "User {$user['id']} -> Employee {$employee['employeeID']}" . $e->getMessage();
403            }
404        }
405
406        return $stats;
407    }
408
409    /**
410     * Get all unlinked employees for a store
411     *
412     * Returns employees who don't have linked uf_user accounts.
413     * These are candidates for invitation or direct account creation.
414     *
415     * @param string $typeNum Store identifier
416     * @return array Array of employee data
417     */
418    public function getUnlinkedEmployees(string $typeNum): array
419    {
420        $storeDb = dbConnectByName('kiosk_' . $typeNum);
421
422        // Get all active employees
423        $stmt = $storeDb->prepare(
424            "SELECT * FROM employees WHERE active = 1 ORDER BY employeeLastName, employeeFirstName"
425        );
426        $stmt->execute();
427        $employees = $stmt->fetchAll(PDO::FETCH_ASSOC);
428
429        // Filter out those with links
430        $unlinked = [];
431        foreach ($employees as $employee) {
432            if (!$this->hasLinkedUser($typeNum, $employee['employeeID'])) {
433                $employee['fullName'] = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
434                $unlinked[] = $employee;
435            }
436        }
437
438        return $unlinked;
439    }
440
441    /**
442     * Get all unlinked users for a store
443     *
444     * Returns users in the store's group who don't have linked employee records.
445     *
446     * @param string $typeNum Store identifier
447     * @return array Array of user data
448     */
449    public function getUnlinkedUsers(string $typeNum): array
450    {
451        $stmt = $this->centralDb->prepare(
452            "SELECT u.*
453             FROM uf_user u
454             JOIN uf_user_group ug ON u.id = ug.user_id
455             JOIN uf_group g ON ug.group_id = g.id
456             WHERE g.name = :storeName
457             ORDER BY u.display_name"
458        );
459        $stmt->bindValue(':storeName', $typeNum, PDO::PARAM_STR);
460        $stmt->execute();
461        $users = $stmt->fetchAll(PDO::FETCH_ASSOC);
462
463        // Filter out those with links
464        $unlinked = [];
465        foreach ($users as $user) {
466            if (!$this->getLinkedEmployee($user['id'], $typeNum)) {
467                $unlinked[] = $user;
468            }
469        }
470
471        return $unlinked;
472    }
473
474    /**
475     * Get all links for a store
476     *
477     * @param string $typeNum Store identifier
478     * @return array Array of UserEmployeeLink objects
479     */
480    public function getLinksForStore(string $typeNum): array
481    {
482        $stmt = $this->centralDb->prepare(
483            "SELECT * FROM user_employee_links WHERE typeNum = :typeNum ORDER BY linkedAt DESC"
484        );
485        $stmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
486        $stmt->execute();
487        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
488
489        $links = [];
490        foreach ($rows as $row) {
491            $links[] = UserEmployeeLink::fromRow($row);
492        }
493
494        return $links;
495    }
496
497    /**
498     * Sync hasUserAccount flag on employees table
499     *
500     * Updates the hasUserAccount flag for all employees in a store based on
501     * whether they have links in user_employee_links.
502     *
503     * @param string $typeNum Store identifier
504     * @return void
505     */
506    public function syncEmployeeFlags(string $typeNum): void
507    {
508        $storeDb = dbConnectByName('kiosk_' . $typeNum);
509
510        // Get all linked employee IDs for this store
511        $stmt = $this->centralDb->prepare(
512            "SELECT employeeId FROM user_employee_links WHERE typeNum = :typeNum"
513        );
514        $stmt->bindValue(':typeNum', $typeNum, PDO::PARAM_STR);
515        $stmt->execute();
516        $linkedIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
517
518        // Set all to 0 first
519        $storeDb->exec("UPDATE employees SET hasUserAccount = 0");
520
521        // Set linked ones to 1
522        if (!empty($linkedIds)) {
523            $placeholders = implode(',', array_fill(0, count($linkedIds), '?'));
524            $updateStmt = $storeDb->prepare(
525                "UPDATE employees SET hasUserAccount = 1 WHERE employeeID IN ($placeholders)"
526            );
527            $updateStmt->execute($linkedIds);
528        }
529    }
530
531    /**
532     * Parse display name into first and last name
533     *
534     * @param string $displayName Full name in "FirstName LastName" format
535     * @return array|null Array with 'firstName' and 'lastName' keys, or null if parse fails
536     */
537    private function parseDisplayName(string $displayName): ?array
538    {
539        $displayName = trim($displayName);
540        if (empty($displayName)) {
541            return null;
542        }
543
544        // Split on space
545        $parts = preg_split('/\s+/', $displayName);
546        if (count($parts) < 2) {
547            return null;
548        }
549
550        // First part is firstName, rest is lastName
551        $firstName = $parts[0];
552        $lastName = implode(' ', array_slice($parts, 1));
553
554        return [
555            'firstName' => $firstName,
556            'lastName' => $lastName
557        ];
558    }
559}