Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 230
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
TeamMemberService
0.00% covered (danger)
0.00%
0 / 230
0.00% covered (danger)
0.00%
0 / 13
2256
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getTeamMembers
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
12
 getTeamMember
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 createTeamMember
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
12
 updateTeamMember
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
182
 deactivateTeamMember
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 reactivateTeamMember
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 setClockPin
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 removeClockPin
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 syncFromProvider
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 applyFilters
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
132
 applySearch
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 sanitizeSortColumn
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\TeamMember\Services;
4
5use BuyerKiosk\TeamMember\DTOs\TeamMemberDTO;
6use PDO;
7use InvalidArgumentException;
8
9/**
10 * TeamMemberService - Core business logic for team member operations
11 *
12 * Provides CRUD operations for team members using the unified users table
13 * and store assignments. Handles field-level protection for externally-synced
14 * members (WhenIWork, Homebase).
15 *
16 * Key responsibilities:
17 * - List team members with pagination, search, and filters
18 * - Fetch individual team member details
19 * - Create new team members (homegrown stores only)
20 * - Update team member data with field protection
21 * - Activate/deactivate team members
22 * - Manage clock PINs
23 *
24 * @see docs/specs/014-manage-employees-unified/solution-design.md
25 *
26 * @package BuyerKiosk\TeamMember\Services
27 */
28class TeamMemberService
29{
30    /**
31     * Default number of items per page
32     */
33    private const DEFAULT_PER_PAGE = 25;
34
35    /**
36     * Maximum number of items per page
37     */
38    private const MAX_PER_PAGE = 100;
39
40    /**
41     * Fields that are always editable regardless of source
42     * These fields can be updated even for WhenIWork/external employees
43     */
44    private const LOCAL_ONLY_FIELDS = [
45        'clockPin',
46        'drsEmployeeId',
47        'dailyReport',
48        'emergencyContactName',
49        'emergencyContactPhone',
50        'role',
51        'position',
52        'hourlyRate',
53        'hireDate',
54        'terminationDate',
55    ];
56
57    /**
58     * User fields that can be updated (goes to users table)
59     */
60    private const USER_FIELDS = [
61        'firstName',
62        'lastName',
63        'email',
64        'phone',
65        'photoUrl',
66        'position',
67        'hireDate',
68        'terminationDate',
69        'hourlyRate',
70        'leaveStartDate',
71        'leaveEndDate',
72        'emergencyContactName',
73        'emergencyContactPhone',
74        'dailyReport',
75    ];
76
77    /**
78     * Store assignment fields that can be updated
79     */
80    private const ASSIGNMENT_FIELDS = [
81        'clockPin',
82        'drsEmployeeId',
83        'role',
84    ];
85
86    /**
87     * @var PDO Central database connection (kiosk_users)
88     */
89    private PDO $db;
90
91    /**
92     * @var string Store identifier (e.g., 'ou00', 'pa00')
93     */
94    private string $typeNum;
95
96    /**
97     * @var \BuyerKiosk\Core\Store|null Store object for sync operations
98     */
99    private $store;
100
101    /**
102     * Constructor
103     *
104     * @param PDO $centralDb Central database connection (kiosk_users)
105     * @param string $typeNum Store identifier
106     * @param \BuyerKiosk\Core\Store|null $store Store object (optional, needed for sync)
107     */
108    public function __construct(PDO $centralDb, string $typeNum, $store = null)
109    {
110        $this->db = $centralDb;
111        $this->typeNum = $typeNum;
112        $this->store = $store;
113    }
114
115    // =========================================================================
116    // Public API Methods
117    // =========================================================================
118
119    /**
120     * Get paginated list of team members for the store
121     *
122     * @param int $page Page number (1-based)
123     * @param int $perPage Number of items per page
124     * @param array $filters Filters: status, hasLogin, hasPin, source
125     * @param string|null $search Search term for name/email
126     * @param string $sortBy Field to sort by
127     * @param string $sortDir Sort direction (asc/desc)
128     * @return array{data: TeamMemberDTO[], total: int, page: int, perPage: int, totalPages: int}
129     */
130    public function getTeamMembers(
131        int $page = 1,
132        int $perPage = self::DEFAULT_PER_PAGE,
133        array $filters = [],
134        ?string $search = null,
135        string $sortBy = 'lastName',
136        string $sortDir = 'asc'
137    ): array {
138        // Sanitize pagination
139        $page = max(1, $page);
140        $perPage = min(max(1, $perPage), self::MAX_PER_PAGE);
141        $offset = ($page - 1) * $perPage;
142
143        // Build query conditions
144        $conditions = ['usa.typeNum = :typeNum'];
145        $params = ['typeNum' => $this->typeNum];
146
147        // Apply filters
148        $this->applyFilters($conditions, $params, $filters);
149
150        // Apply search
151        if (!empty($search)) {
152            $this->applySearch($conditions, $params, $search);
153        }
154
155        $whereClause = 'WHERE ' . implode(' AND ', $conditions);
156
157        // Get total count
158        $countSql = "SELECT COUNT(*) as count
159            FROM users u
160            INNER JOIN userStoreAssignments usa ON u.id = usa.userId
161            {$whereClause}";
162
163        $countStmt = $this->db->prepare($countSql);
164        $countStmt->execute($params);
165        $total = (int) $countStmt->fetch(PDO::FETCH_ASSOC)['count'];
166
167        // Calculate total pages
168        $totalPages = (int) ceil($total / $perPage);
169
170        // Get data
171        $sortColumn = $this->sanitizeSortColumn($sortBy);
172        $sortDirection = strtoupper($sortDir) === 'DESC' ? 'DESC' : 'ASC';
173
174        // Note: LIMIT and OFFSET are sanitized integers, safe for direct interpolation
175        $dataSql = "SELECT u.*, usa.clockPin, usa.drsEmployeeId, usa.role, usa.isActive,
176                           usa.assignedAt, usa.deactivatedAt
177            FROM users u
178            INNER JOIN userStoreAssignments usa ON u.id = usa.userId
179            {$whereClause}
180            ORDER BY {$sortColumn} {$sortDirection}
181            LIMIT {$perPage} OFFSET {$offset}";
182
183        $dataStmt = $this->db->prepare($dataSql);
184        $dataStmt->execute($params);
185
186        $rows = $dataStmt->fetchAll(PDO::FETCH_ASSOC);
187
188        // Convert to DTOs
189        $data = array_map(fn($row) => TeamMemberDTO::fromArray($row), $rows);
190
191        return [
192            'data' => $data,
193            'total' => $total,
194            'page' => $page,
195            'perPage' => $perPage,
196            'totalPages' => $totalPages,
197        ];
198    }
199
200    /**
201     * Get a single team member by ID
202     *
203     * @param int $userId User ID
204     * @return TeamMemberDTO|null
205     */
206    public function getTeamMember(int $userId): ?TeamMemberDTO
207    {
208        $sql = "SELECT u.*, usa.clockPin, usa.drsEmployeeId, usa.role, usa.isActive,
209                       usa.assignedAt, usa.deactivatedAt
210            FROM users u
211            INNER JOIN userStoreAssignments usa ON u.id = usa.userId
212            WHERE u.id = :userId AND usa.typeNum = :typeNum
213            LIMIT 1";
214
215        $stmt = $this->db->prepare($sql);
216        $stmt->execute(['userId' => $userId, 'typeNum' => $this->typeNum]);
217        $row = $stmt->fetch(PDO::FETCH_ASSOC);
218
219        if (!$row) {
220            return null;
221        }
222
223        return TeamMemberDTO::fromArray($row);
224    }
225
226    /**
227     * Create a new team member
228     *
229     * @param array $data Team member data
230     * @return TeamMemberDTO
231     * @throws InvalidArgumentException If source is external
232     */
233    public function createTeamMember(array $data): TeamMemberDTO
234    {
235        // Validate source - only homegrown can create
236        $source = $data['source'] ?? 'homegrown';
237        if ($source !== 'homegrown') {
238            throw new InvalidArgumentException(
239                'Cannot create team members for external source stores. Use sync instead.'
240            );
241        }
242
243        // Build user insert
244        $userFields = [
245            'firstName' => $data['firstName'] ?? null,
246            'lastName' => $data['lastName'] ?? null,
247            'email' => $data['email'] ?? null,
248            'displayName' => $data['displayName'] ?? null,
249            'phone' => $data['phone'] ?? null,
250            'photoUrl' => $data['photoUrl'] ?? null,
251            'position' => $data['position'] ?? null,
252            'hireDate' => $data['hireDate'] ?? null,
253            'hourlyRate' => $data['hourlyRate'] ?? null,
254            'emergencyContactName' => $data['emergencyContactName'] ?? null,
255            'emergencyContactPhone' => $data['emergencyContactPhone'] ?? null,
256            'source' => 'homegrown',
257            'accountType' => 'employee',
258            'enabled' => 1,
259            'active' => 0, // Not active until login is set up
260            'canLogin' => 0,
261            'createdAt' => date('Y-m-d H:i:s'),
262            'updatedAt' => date('Y-m-d H:i:s'),
263        ];
264
265        // Build display name if not provided
266        if (empty($userFields['displayName'])) {
267            $userFields['displayName'] = trim(
268                ($userFields['firstName'] ?? '') . ' ' . ($userFields['lastName'] ?? '')
269            );
270        }
271
272        // Insert user
273        $columns = array_keys($userFields);
274        $placeholders = array_map(fn($c) => ':' . $c, $columns);
275
276        $userSql = "INSERT INTO users (" . implode(', ', $columns) . ")
277                    VALUES (" . implode(', ', $placeholders) . ")";
278
279        $userStmt = $this->db->prepare($userSql);
280        $userStmt->execute($userFields);
281        $userId = (int) $this->db->lastInsertId();
282
283        // Insert store assignment
284        $assignmentData = [
285            'userId' => $userId,
286            'typeNum' => $this->typeNum,
287            'clockPin' => $data['clockPin'] ?? null,
288            'drsEmployeeId' => $data['drsEmployeeId'] ?? null,
289            'role' => $data['role'] ?? 4, // Default to Buyer role
290            'isActive' => 1,
291            'assignedAt' => date('Y-m-d H:i:s'),
292        ];
293
294        $assignmentSql = "INSERT INTO userStoreAssignments
295                          (userId, typeNum, clockPin, drsEmployeeId, role, isActive, assignedAt)
296                          VALUES (:userId, :typeNum, :clockPin, :drsEmployeeId, :role, :isActive, :assignedAt)";
297
298        $assignmentStmt = $this->db->prepare($assignmentSql);
299        $assignmentStmt->execute($assignmentData);
300
301        // Fetch and return created member
302        return $this->getTeamMember($userId);
303    }
304
305    /**
306     * Update a team member
307     *
308     * Respects field-level protection based on source. External source members
309     * can only have local-only fields updated.
310     *
311     * @param int $userId User ID
312     * @param array $data Fields to update
313     * @return TeamMemberDTO|null Updated member or null if not found
314     */
315    public function updateTeamMember(int $userId, array $data): ?TeamMemberDTO
316    {
317        // Fetch existing member
318        $existing = $this->getTeamMember($userId);
319        if (!$existing) {
320            return null;
321        }
322
323        // Filter allowed fields based on source
324        $allowedFields = $existing->isEditable
325            ? array_merge(self::USER_FIELDS, self::ASSIGNMENT_FIELDS)
326            : self::LOCAL_ONLY_FIELDS;
327
328        $filteredData = array_intersect_key($data, array_flip($allowedFields));
329
330        if (empty($filteredData)) {
331            return $existing; // Nothing to update
332        }
333
334        // Separate user fields from assignment fields
335        $userUpdates = array_intersect_key($filteredData, array_flip(self::USER_FIELDS));
336        $assignmentUpdates = array_intersect_key($filteredData, array_flip(self::ASSIGNMENT_FIELDS));
337
338        // Update user table
339        if (!empty($userUpdates)) {
340            // Filter out empty strings for non-nullable or problematic fields
341            // But keep empty strings for text fields that can be cleared
342            foreach ($userUpdates as $key => $value) {
343                // Convert empty strings to null for date fields
344                if ($value === '' && in_array($key, ['hireDate', 'terminationDate', 'leaveStartDate', 'leaveEndDate'])) {
345                    $userUpdates[$key] = null;
346                }
347            }
348
349            $userUpdates['updatedAt'] = date('Y-m-d H:i:s');
350            $setClauses = array_map(fn($col) => "{$col} = :{$col}", array_keys($userUpdates));
351
352            $userSql = "UPDATE users SET " . implode(', ', $setClauses) . " WHERE id = :id";
353            $userStmt = $this->db->prepare($userSql);
354            $userStmt->execute(array_merge($userUpdates, ['id' => $userId]));
355        }
356
357        // Update store assignment table
358        if (!empty($assignmentUpdates)) {
359            // Filter out empty strings - convert to null for integer columns
360            foreach ($assignmentUpdates as $key => $value) {
361                if ($value === '' || $value === null) {
362                    // Remove empty values to avoid SQL errors on integer columns
363                    unset($assignmentUpdates[$key]);
364                }
365            }
366
367            // Only proceed if there are still updates after filtering
368            if (!empty($assignmentUpdates)) {
369                $setClauses = array_map(fn($col) => "{$col} = :{$col}", array_keys($assignmentUpdates));
370
371                $assignmentSql = "UPDATE userStoreAssignments SET " . implode(', ', $setClauses) .
372                                " WHERE userId = :userId AND typeNum = :typeNum";
373                $assignmentStmt = $this->db->prepare($assignmentSql);
374                $assignmentStmt->execute(array_merge($assignmentUpdates, [
375                    'userId' => $userId,
376                    'typeNum' => $this->typeNum,
377                ]));
378            }
379        }
380
381        // Fetch and return updated member
382        return $this->getTeamMember($userId);
383    }
384
385    /**
386     * Deactivate a team member (soft delete)
387     *
388     * @param int $userId User ID
389     * @return bool Success status
390     */
391    public function deactivateTeamMember(int $userId): bool
392    {
393        // Verify member exists
394        $existing = $this->getTeamMember($userId);
395        if (!$existing) {
396            return false;
397        }
398
399        $sql = "UPDATE userStoreAssignments
400                SET isActive = 0, deactivatedAt = :deactivatedAt
401                WHERE userId = :userId AND typeNum = :typeNum";
402
403        $stmt = $this->db->prepare($sql);
404        return $stmt->execute([
405            'userId' => $userId,
406            'typeNum' => $this->typeNum,
407            'deactivatedAt' => date('Y-m-d H:i:s'),
408        ]);
409    }
410
411    /**
412     * Reactivate a team member
413     *
414     * @param int $userId User ID
415     * @return bool Success status
416     */
417    public function reactivateTeamMember(int $userId): bool
418    {
419        // Verify member exists
420        $existing = $this->getTeamMember($userId);
421        if (!$existing) {
422            return false;
423        }
424
425        $sql = "UPDATE userStoreAssignments
426                SET isActive = 1, deactivatedAt = NULL
427                WHERE userId = :userId AND typeNum = :typeNum";
428
429        $stmt = $this->db->prepare($sql);
430        return $stmt->execute([
431            'userId' => $userId,
432            'typeNum' => $this->typeNum,
433        ]);
434    }
435
436    /**
437     * Set clock PIN for a team member
438     *
439     * @param int $userId User ID
440     * @param string $pin PIN (4-6 digits)
441     * @return bool Success status
442     * @throws InvalidArgumentException If PIN format is invalid
443     */
444    public function setClockPin(int $userId, string $pin): bool
445    {
446        // Verify member exists
447        $existing = $this->getTeamMember($userId);
448        if (!$existing) {
449            return false;
450        }
451
452        // Validate PIN format
453        if (!preg_match('/^\d{4,6}$/', $pin)) {
454            throw new InvalidArgumentException('PIN must be 4-6 digits');
455        }
456
457        $sql = "UPDATE userStoreAssignments
458                SET clockPin = :clockPin
459                WHERE userId = :userId AND typeNum = :typeNum";
460
461        $stmt = $this->db->prepare($sql);
462        return $stmt->execute([
463            'userId' => $userId,
464            'typeNum' => $this->typeNum,
465            'clockPin' => $pin,
466        ]);
467    }
468
469    /**
470     * Remove clock PIN for a team member
471     *
472     * @param int $userId User ID
473     * @return bool Success status
474     */
475    public function removeClockPin(int $userId): bool
476    {
477        // Verify member exists
478        $existing = $this->getTeamMember($userId);
479        if (!$existing) {
480            return false;
481        }
482
483        $sql = "UPDATE userStoreAssignments
484                SET clockPin = NULL
485                WHERE userId = :userId AND typeNum = :typeNum";
486
487        $stmt = $this->db->prepare($sql);
488        return $stmt->execute([
489            'userId' => $userId,
490            'typeNum' => $this->typeNum,
491        ]);
492    }
493
494    /**
495     * Sync team members from external provider
496     *
497     * Delegates to SyncService for WhenIWork/Homebase synchronization.
498     * Implements business rules SR-1 through SR-7 from the PRD.
499     *
500     * @return array{added: int, updated: int, deactivated: int, merged: int, reactivated: int, errors: array}
501     * @throws InvalidArgumentException If store doesn't use external provider or store not set
502     */
503    public function syncFromProvider(): array
504    {
505        // Ensure we have a Store object for sync operations
506        if ($this->store === null) {
507            throw new InvalidArgumentException(
508                'Store object required for sync. Pass Store to constructor.'
509            );
510        }
511
512        // Check if store uses external provider
513        $source = $this->store->getEmployeeSource();
514        if ($source === 'homegrown') {
515            throw new InvalidArgumentException('Store does not use external provider');
516        }
517
518        // Create SyncService and delegate
519        $syncService = new SyncService($this->db, $this->store);
520        $result = $syncService->sync();
521
522        // Convert SyncResult to array for API response
523        return [
524            'added' => $result->created,
525            'updated' => $result->updated,
526            'deactivated' => $result->deactivated,
527            'merged' => $result->merged,
528            'reactivated' => $result->reactivated,
529            'errors' => $result->errors,
530            'success' => $result->isSuccess(),
531        ];
532    }
533
534    // =========================================================================
535    // Private Helper Methods
536    // =========================================================================
537
538    /**
539     * Apply filter conditions to query
540     *
541     * @param array &$conditions Query conditions (modified in place)
542     * @param array &$params Query parameters (modified in place)
543     * @param array $filters Filter values
544     */
545    private function applyFilters(array &$conditions, array &$params, array $filters): void
546    {
547        // Status filter
548        if (isset($filters['status'])) {
549            switch ($filters['status']) {
550                case 'active':
551                    $conditions[] = '(u.enabled = 1 AND usa.isActive = 1)';
552                    break;
553                case 'inactive':
554                    $conditions[] = '(u.enabled = 0 OR usa.isActive = 0)';
555                    break;
556                case 'on_leave':
557                    $conditions[] = '(u.leaveStartDate IS NOT NULL AND u.leaveStartDate <= NOW() AND (u.leaveEndDate IS NULL OR u.leaveEndDate >= NOW()))';
558                    break;
559            }
560        }
561
562        // Has login filter
563        if (isset($filters['hasLogin'])) {
564            $conditions[] = $filters['hasLogin']
565                ? '(u.canLogin = 1 AND u.username IS NOT NULL)'
566                : '(u.canLogin = 0 OR u.username IS NULL)';
567        }
568
569        // Has PIN filter
570        if (isset($filters['hasPin'])) {
571            $conditions[] = $filters['hasPin']
572                ? "(usa.clockPin IS NOT NULL AND usa.clockPin != '')"
573                : "(usa.clockPin IS NULL OR usa.clockPin = '')";
574        }
575
576        // Source filter
577        if (isset($filters['source'])) {
578            $conditions[] = 'u.source = :filterSource';
579            $params['filterSource'] = $filters['source'];
580        }
581
582        // Role filter
583        if (isset($filters['role'])) {
584            $conditions[] = 'usa.role = :filterRole';
585            $params['filterRole'] = (int) $filters['role'];
586        }
587    }
588
589    /**
590     * Apply search term to query
591     *
592     * @param array &$conditions Query conditions (modified in place)
593     * @param array &$params Query parameters (modified in place)
594     * @param string $search Search term
595     */
596    private function applySearch(array &$conditions, array &$params, string $search): void
597    {
598        $searchPattern = '%' . $search . '%';
599        $conditions[] = "(u.firstName LIKE :search OR u.lastName LIKE :search OR u.email LIKE :search OR u.displayName LIKE :search OR CONCAT(u.firstName, ' ', u.lastName) LIKE :search)";
600        $params['search'] = $searchPattern;
601    }
602
603    /**
604     * Sanitize sort column to prevent SQL injection
605     *
606     * @param string $sortBy Requested sort column
607     * @return string Safe column name
608     */
609    private function sanitizeSortColumn(string $sortBy): string
610    {
611        $allowedColumns = [
612            'firstName' => 'u.firstName',
613            'lastName' => 'u.lastName',
614            'email' => 'u.email',
615            'displayName' => 'u.displayName',
616            'position' => 'u.position',
617            'hireDate' => 'u.hireDate',
618            'role' => 'usa.role',
619            'status' => 'usa.isActive',
620            'lastLoginAt' => 'u.lastLoginAt',
621            'createdAt' => 'u.createdAt',
622        ];
623
624        return $allowedColumns[$sortBy] ?? 'u.lastName';
625    }
626}