Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 230 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
| TeamMemberService | |
0.00% |
0 / 230 |
|
0.00% |
0 / 13 |
2256 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| getTeamMembers | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
12 | |||
| getTeamMember | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| createTeamMember | |
0.00% |
0 / 50 |
|
0.00% |
0 / 1 |
12 | |||
| updateTeamMember | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
182 | |||
| deactivateTeamMember | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
| reactivateTeamMember | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
| setClockPin | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
| removeClockPin | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
| syncFromProvider | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
12 | |||
| applyFilters | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
132 | |||
| applySearch | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| sanitizeSortColumn | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BuyerKiosk\TeamMember\Services; |
| 4 | |
| 5 | use BuyerKiosk\TeamMember\DTOs\TeamMemberDTO; |
| 6 | use PDO; |
| 7 | use 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 | */ |
| 28 | class 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 | } |