Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 290
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmployeeApiController
0.00% covered (danger)
0.00%
0 / 290
0.00% covered (danger)
0.00%
0 / 9
4422
0.00% covered (danger)
0.00%
0 / 1
 getEmployees
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 getEmployee
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
30
 createEmployee
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
 updateEmployee
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
72
 deactivateEmployee
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 setEmployeePin
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
110
 syncEmployees
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 uploadEmployeePhoto
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
240
 deleteEmployeePhoto
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
110
1<?php
2
3namespace BuyerKiosk\Core\Controllers;
4
5use BuyerKiosk\Employee\EmployeeManager;
6use Exception;
7
8/**
9 * Employee API Controller
10 *
11 * Handles REST API endpoints for employee management operations.
12 * Routes to EmployeeManager which handles provider-specific logic.
13 *
14 * @package UserFrosting
15 */
16class EmployeeApiController extends BaseController
17{
18    /**
19     * GET /:typeNum/api/employees
20     * Get all active employees
21     *
22     * @param string $typeNum Store identifier
23     */
24    public function getEmployees($typeNum)
25    {
26        // Check store access permission
27        if (!$this->_app->user->checkStoreGroup($typeNum)) {
28            $this->_app->halt(403, json_encode([
29                'success' => false,
30                'error' => 'Access denied to this store'
31            ]));
32        }
33
34        try {
35            $manager = new EmployeeManager($typeNum);
36            $employees = $manager->getActiveEmployees();
37
38            // Convert to array format
39            $employeeData = array_map(function($employee) {
40                return $employee->toArray();
41            }, $employees);
42
43            echo json_encode([
44                'success' => true,
45                'employees' => $employeeData
46            ]);
47
48            $this->_app->halt(200);
49        } catch (\Throwable $e) {
50            error_log("EmployeeApiController::getEmployees error: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
51            echo json_encode([
52                'success' => false,
53                'error' => $e->getMessage(),
54                'file' => $e->getFile(),
55                'line' => $e->getLine()
56            ]);
57            $this->_app->halt(500);
58        }
59    }
60
61    /**
62     * GET /:typeNum/api/employees/:id
63     * Get single employee by ID
64     *
65     * @param string $typeNum Store identifier
66     * @param int $employeeId Employee ID
67     */
68    public function getEmployee($typeNum, $employeeId)
69    {
70        // Check store access permission
71        if (!$this->_app->user->checkStoreGroup($typeNum)) {
72            $this->_app->halt(403, json_encode([
73                'success' => false,
74                'error' => 'Access denied to this store'
75            ]));
76        }
77
78        try {
79            $manager = new EmployeeManager($typeNum);
80            $employee = $manager->getEmployee((int) $employeeId);
81
82            if (!$employee) {
83                echo json_encode([
84                    'success' => false,
85                    'error' => 'Employee not found'
86                ]);
87                $this->_app->halt(404);
88            }
89
90            header('Content-Type: application/json');
91            echo json_encode([
92                'success' => true,
93                'employee' => $employee->toArray()
94            ]);
95            return;
96        } catch (\Slim\Exception\Stop $e) {
97            // Re-throw Slim Stop exceptions (from halt())
98            throw $e;
99        } catch (\Throwable $e) {
100            error_log("EmployeeApiController::getEmployee error: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
101            $this->_app->halt(500, json_encode([
102                'success' => false,
103                'error' => $e->getMessage(),
104                'file' => $e->getFile(),
105                'line' => $e->getLine()
106            ]));
107        }
108    }
109
110    /**
111     * POST /:typeNum/api/employees
112     * Create new employee (homegrown only)
113     *
114     * @param string $typeNum Store identifier
115     */
116    public function createEmployee($typeNum)
117    {
118        // Check store access permission
119        if (!$this->_app->user->checkStoreGroup($typeNum)) {
120            $this->_app->halt(403, json_encode([
121                'success' => false,
122                'error' => 'Access denied to this store'
123            ]));
124        }
125
126        // Check CSRF token
127        if (!\NoCSRF::check('csrf_token', $this->_app->request->post())) {
128            $this->_app->halt(403, json_encode([
129                'success' => false,
130                'error' => 'Invalid or missing CSRF token'
131            ]));
132        }
133
134        try {
135            $manager = new EmployeeManager($typeNum);
136            $data = $this->_app->request->post();
137
138            // Remove CSRF token from data
139            unset($data['csrf_token']);
140
141            $employee = $manager->createEmployee($data);
142
143            echo json_encode([
144                'success' => true,
145                'employee' => $employee->toArray()
146            ]);
147
148            $this->_app->halt(201);
149        } catch (Exception $e) {
150            error_log("EmployeeApiController::createEmployee error: " . $e->getMessage());
151            echo json_encode([
152                'success' => false,
153                'error' => $e->getMessage()
154            ]);
155            $this->_app->halt(400);
156        }
157    }
158
159    /**
160     * PUT/POST /:typeNum/api/employees/:id
161     * Update employee
162     *
163     * @param string $typeNum Store identifier
164     * @param int $employeeId Employee ID
165     */
166    public function updateEmployee($typeNum, $employeeId)
167    {
168        // Check store access permission
169        if (!$this->_app->user->checkStoreGroup($typeNum)) {
170            $this->_app->halt(403, json_encode([
171                'success' => false,
172                'error' => 'Access denied to this store'
173            ]));
174        }
175
176        // Get data from POST (FormData) or JSON body
177        $data = $this->_app->request->post();
178        if (empty($data)) {
179            $requestBody = $this->_app->request->getBody();
180            $data = json_decode($requestBody, true) ?: [];
181        }
182
183        // Check CSRF token
184        if (!isset($data['csrf_token']) || !\NoCSRF::check('csrf_token', $data, false)) {
185            $this->_app->halt(403, json_encode([
186                'success' => false,
187                'error' => 'Invalid or missing CSRF token'
188            ]));
189        }
190
191        // Remove CSRF token and _method from data
192        unset($data['csrf_token']);
193        unset($data['_method']);
194
195        try {
196            $manager = new EmployeeManager($typeNum);
197            $employee = $manager->updateEmployee((int) $employeeId, $data);
198
199            header('Content-Type: application/json');
200            echo json_encode([
201                'success' => true,
202                'employee' => $employee->toArray()
203            ]);
204            return;
205        } catch (\Slim\Exception\Stop $e) {
206            throw $e;
207        } catch (\Throwable $e) {
208            error_log("EmployeeApiController::updateEmployee error: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
209            $this->_app->halt(400, json_encode([
210                'success' => false,
211                'error' => $e->getMessage()
212            ]));
213        }
214    }
215
216    /**
217     * DELETE /:typeNum/api/employees/:id
218     * Deactivate employee
219     *
220     * @param string $typeNum Store identifier
221     * @param int $employeeId Employee ID
222     */
223    public function deactivateEmployee($typeNum, $employeeId)
224    {
225        // Check store access permission
226        if (!$this->_app->user->checkStoreGroup($typeNum)) {
227            $this->_app->halt(403, json_encode([
228                'success' => false,
229                'error' => 'Access denied to this store'
230            ]));
231        }
232
233        // Check CSRF token (can be in POST data for DELETE requests)
234        $csrfToken = $this->_app->request->post('csrf_token');
235        if (!$csrfToken) {
236            // Try to get from request body
237            $requestBody = $this->_app->request->getBody();
238            $data = json_decode($requestBody, true);
239            $csrfToken = isset($data['csrf_token']) ? $data['csrf_token'] : null;
240        }
241
242        if (!$csrfToken || !\NoCSRF::check('csrf_token', ['csrf_token' => $csrfToken], false)) {
243            $this->_app->halt(403, json_encode([
244                'success' => false,
245                'error' => 'Invalid or missing CSRF token'
246            ]));
247        }
248
249        try {
250            $manager = new EmployeeManager($typeNum);
251            $success = $manager->deactivateEmployee((int) $employeeId);
252
253            echo json_encode([
254                'success' => $success
255            ]);
256
257            $this->_app->halt(200);
258        } catch (Exception $e) {
259            error_log("EmployeeApiController::deactivateEmployee error: " . $e->getMessage());
260            echo json_encode([
261                'success' => false,
262                'error' => 'Failed to deactivate employee'
263            ]);
264            $this->_app->halt(500);
265        }
266    }
267
268    /**
269     * POST /:typeNum/api/employees/:id/pin
270     * Set or update employee clock PIN
271     *
272     * @param string $typeNum Store identifier
273     * @param int $employeeId Employee ID
274     */
275    public function setEmployeePin($typeNum, $employeeId)
276    {
277        // Check store access permission
278        if (!$this->_app->user->checkStoreGroup($typeNum)) {
279            $this->_app->halt(403, json_encode([
280                'success' => false,
281                'error' => 'Access denied to this store'
282            ]));
283        }
284
285        // Check CSRF token (multiple=true for AJAX-heavy pages)
286        if (!\NoCSRF::check('csrf_token', $this->_app->request->post(), false, null, true)) {
287            $this->_app->halt(403, json_encode([
288                'success' => false,
289                'error' => 'Invalid or missing CSRF token'
290            ]));
291        }
292
293        $pin = $this->_app->request->post('pin');
294
295        // Validate PIN format (4-6 digits or empty to remove)
296        if ($pin !== '' && $pin !== null) {
297            if (!preg_match('/^\d{4,6}$/', $pin)) {
298                $this->_app->response->setStatus(400);
299                $this->_app->response->headers->set('Content-Type', 'application/json');
300                $this->_app->response->setBody(json_encode([
301                    'success' => false,
302                    'error' => 'PIN must be 4-6 digits'
303                ]));
304                return;
305            }
306        }
307
308        try {
309            $manager = new EmployeeManager($typeNum);
310            $success = $manager->setEmployeePin((int) $employeeId, $pin ?: null);
311
312            $this->_app->response->setStatus(200);
313            $this->_app->response->headers->set('Content-Type', 'application/json');
314            $this->_app->response->setBody(json_encode([
315                'success' => $success,
316                'message' => $pin ? 'PIN updated successfully' : 'PIN removed successfully'
317            ]));
318        } catch (\Throwable $e) {
319            $errorMsg = $e->getMessage() ?: 'Unknown error occurred';
320            error_log("EmployeeApiController::setEmployeePin error: " . $errorMsg . " in " . $e->getFile() . ":" . $e->getLine());
321            $this->_app->response->setStatus(400);
322            $this->_app->response->headers->set('Content-Type', 'application/json');
323            $this->_app->response->setBody(json_encode([
324                'success' => false,
325                'error' => $errorMsg
326            ]));
327        }
328    }
329
330    /**
331     * POST /:typeNum/api/employees/sync
332     * Trigger sync from external provider (WhenIWork/Homebase)
333     *
334     * @param string $typeNum Store identifier
335     */
336    public function syncEmployees($typeNum)
337    {
338        // Check store access permission
339        if (!$this->_app->user->checkStoreGroup($typeNum)) {
340            $this->_app->halt(403, json_encode([
341                'success' => false,
342                'error' => 'Access denied to this store'
343            ]));
344        }
345
346        // Check CSRF token
347        if (!\NoCSRF::check('csrf_token', $this->_app->request->post())) {
348            $this->_app->halt(403, json_encode([
349                'success' => false,
350                'error' => 'Invalid or missing CSRF token'
351            ]));
352        }
353
354        try {
355            $manager = new EmployeeManager($typeNum);
356            $result = $manager->syncFromProvider();
357
358            echo json_encode([
359                'success' => $result->isSuccess(),
360                'result' => $result->toArray()
361            ]);
362
363            $this->_app->halt(200);
364        } catch (Exception $e) {
365            error_log("EmployeeApiController::syncEmployees error: " . $e->getMessage());
366            echo json_encode([
367                'success' => false,
368                'error' => $e->getMessage()
369            ]);
370            $this->_app->halt(400);
371        }
372    }
373
374    /**
375     * POST /:typeNum/api/employees/:id/photo
376     * Upload a custom photo for an employee (sets avatarOverride)
377     *
378     * @param string $typeNum Store identifier
379     * @param int $employeeId Employee ID
380     */
381    public function uploadEmployeePhoto($typeNum, $employeeId)
382    {
383        // Check store access permission
384        if (!$this->_app->user->checkStoreGroup($typeNum)) {
385            $this->_app->halt(403, json_encode([
386                'success' => false,
387                'error' => 'Access denied to this store'
388            ]));
389        }
390
391        // Check CSRF token
392        if (!\NoCSRF::check('csrf_token', $this->_app->request->post())) {
393            $this->_app->halt(403, json_encode([
394                'success' => false,
395                'error' => 'Invalid or missing CSRF token'
396            ]));
397        }
398
399        try {
400            // Check if file was uploaded
401            if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
402                $errorMessages = [
403                    UPLOAD_ERR_INI_SIZE => 'File exceeds maximum upload size',
404                    UPLOAD_ERR_FORM_SIZE => 'File exceeds maximum form size',
405                    UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
406                    UPLOAD_ERR_NO_FILE => 'No file was uploaded',
407                    UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
408                    UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
409                    UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload'
410                ];
411                $errorCode = isset($_FILES['photo']) ? $_FILES['photo']['error'] : UPLOAD_ERR_NO_FILE;
412                $errorMsg = isset($errorMessages[$errorCode]) ? $errorMessages[$errorCode] : 'Unknown upload error';
413                throw new Exception($errorMsg);
414            }
415
416            $file = $_FILES['photo'];
417
418            // Validate file type
419            $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
420            $finfo = new \finfo(FILEINFO_MIME_TYPE);
421            $mimeType = $finfo->file($file['tmp_name']);
422
423            if (!in_array($mimeType, $allowedTypes)) {
424                throw new Exception('Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.');
425            }
426
427            // Validate file size (max 5MB)
428            $maxSize = 5 * 1024 * 1024;
429            if ($file['size'] > $maxSize) {
430                throw new Exception('File size exceeds maximum limit of 5MB');
431            }
432
433            // Create upload directory
434            $uploadDir = $_SERVER['DOCUMENT_ROOT'] . '/uploads/employees/' . $typeNum;
435            if (!is_dir($uploadDir)) {
436                mkdir($uploadDir, 0755, true);
437            }
438
439            // Generate filename
440            $extension = $mimeType === 'image/png' ? 'png' : ($mimeType === 'image/gif' ? 'gif' : ($mimeType === 'image/webp' ? 'webp' : 'jpg'));
441            $filename = 'avatar_' . $employeeId . '_custom.' . $extension;
442            $localPath = $uploadDir . '/' . $filename;
443            $localUrl = '/uploads/employees/' . $typeNum . '/' . $filename;
444
445            // Move uploaded file
446            if (!move_uploaded_file($file['tmp_name'], $localPath)) {
447                throw new Exception('Failed to save uploaded file');
448            }
449
450            // Update employee record with new photo and set override flag
451            $manager = new EmployeeManager($typeNum);
452            $manager->setEmployeePhoto((int) $employeeId, $localUrl);
453
454            echo json_encode([
455                'success' => true,
456                'photoUrl' => $localUrl,
457                'message' => 'Photo uploaded successfully'
458            ]);
459
460            $this->_app->halt(200);
461        } catch (Exception $e) {
462            error_log("EmployeeApiController::uploadEmployeePhoto error: " . $e->getMessage());
463            echo json_encode([
464                'success' => false,
465                'error' => $e->getMessage()
466            ]);
467            $this->_app->halt(400);
468        }
469    }
470
471    /**
472     * DELETE /:typeNum/api/employees/:id/photo
473     * Delete employee's custom photo (clears avatarOverride so next sync will pull new photo)
474     *
475     * @param string $typeNum Store identifier
476     * @param int $employeeId Employee ID
477     */
478    public function deleteEmployeePhoto($typeNum, $employeeId)
479    {
480        // Check store access permission
481        if (!$this->_app->user->checkStoreGroup($typeNum)) {
482            $this->_app->halt(403, json_encode([
483                'success' => false,
484                'error' => 'Access denied to this store'
485            ]));
486        }
487
488        // Check CSRF token
489        $csrfToken = $this->_app->request->post('csrf_token');
490        if (!$csrfToken) {
491            $requestBody = $this->_app->request->getBody();
492            $data = json_decode($requestBody, true);
493            $csrfToken = isset($data['csrf_token']) ? $data['csrf_token'] : null;
494        }
495
496        if (!$csrfToken || !\NoCSRF::check('csrf_token', ['csrf_token' => $csrfToken], false)) {
497            $this->_app->halt(403, json_encode([
498                'success' => false,
499                'error' => 'Invalid or missing CSRF token'
500            ]));
501        }
502
503        try {
504            $manager = new EmployeeManager($typeNum);
505
506            // Get current photo URL
507            $photoUrl = $manager->getEmployeePhotoUrl((int) $employeeId);
508
509            if ($photoUrl) {
510                // Delete the custom photo file if it exists
511                $photoPath = $_SERVER['DOCUMENT_ROOT'] . $photoUrl;
512                if (file_exists($photoPath) && strpos($photoUrl, '_custom.') !== false) {
513                    unlink($photoPath);
514                }
515            }
516
517            // Clear photo and override flag (next sync will pull fresh image)
518            $manager->clearEmployeePhoto((int) $employeeId);
519
520            echo json_encode([
521                'success' => true,
522                'message' => 'Photo deleted. A new photo will be pulled on next sync.'
523            ]);
524
525            $this->_app->halt(200);
526        } catch (Exception $e) {
527            error_log("EmployeeApiController::deleteEmployeePhoto error: " . $e->getMessage());
528            echo json_encode([
529                'success' => false,
530                'error' => $e->getMessage()
531            ]);
532            $this->_app->halt(500);
533        }
534    }
535}