Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.61% covered (warning)
88.61%
140 / 158
28.57% covered (danger)
28.57%
4 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
StoreConfigController
88.61% covered (warning)
88.61%
140 / 158
28.57% covered (danger)
28.57%
4 / 14
58.31
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getBuykioskDb
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getHoursService
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getHolidayRepo
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 checkAuth
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
7.10
 sendJsonResponse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 sendErrorResponse
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 getCurrentUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConfig
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 updateHours
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 createHoliday
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
9.03
 updateHoliday
94.12% covered (success)
94.12%
32 / 34
0.00% covered (danger)
0.00%
0 / 1
10.02
 deleteHoliday
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 invalidateStoreCache
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
1<?php
2
3namespace BuyerKiosk\StoreConfig\Controllers;
4
5use BuyerKiosk\Core\Store;
6use BuyerKiosk\StoreConfig\Repositories\StoreHolidayRepository;
7use BuyerKiosk\StoreConfig\Services\StoreHoursService;
8use BuyerKiosk\StoreController;
9use Exception;
10use PDO;
11
12/**
13 * StoreConfigController - REST API for Store Configuration
14 *
15 * Provides endpoints for managing store configuration including operating hours
16 * and holiday overrides.
17 *
18 * Endpoints:
19 * 1. GET    /api/:typeNum/store/config                  - Get store configuration
20 * 2. PUT    /api/:typeNum/store/hours                   - Update store hours
21 * 3. POST   /api/:typeNum/store/holidays                - Create holiday
22 * 4. PUT    /api/:typeNum/store/holidays/:holidayId     - Update holiday
23 * 5. DELETE /api/:typeNum/store/holidays/:holidayId     - Delete holiday
24 *
25 * Authentication: All endpoints require session authentication + uri_store_settings permission
26 *
27 * @package BuyerKiosk\StoreConfig\Controllers
28 * @see docs/specs/018-store-configuration/solution-design.md
29 */
30class StoreConfigController
31{
32    /**
33     * @var object Slim application instance
34     */
35    private $app;
36
37    /**
38     * @var Store Store object
39     */
40    private Store $store;
41
42    /**
43     * @var string Store type number
44     */
45    private string $typeNum;
46
47    /**
48     * @var StoreHoursService|null
49     */
50    private ?StoreHoursService $hoursService = null;
51
52    /**
53     * @var StoreHolidayRepository|null
54     */
55    private ?StoreHolidayRepository $holidayRepo = null;
56
57    /**
58     * @var PDO|null Central database connection (kiosk_buykiosk)
59     */
60    private ?PDO $buykioskDb = null;
61
62    /**
63     * @var null|callable(string): void
64     */
65    private $cacheInvalidator;
66
67    /**
68     * Constructor
69     *
70     * @param object $app Slim application instance
71     * @param Store $store Store object (validated)
72     * @param StoreHoursService|null $hoursService Optional service for dependency injection
73     * @param PDO|null $buykioskDb Optional DB connection for testing
74     * @param StoreHolidayRepository|null $holidayRepo Optional repository for testing
75     * @param callable|null $cacheInvalidator Optional cache invalidator for testing
76     */
77    public function __construct(
78        $app,
79        Store $store,
80        ?StoreHoursService $hoursService = null,
81        ?PDO $buykioskDb = null,
82        ?StoreHolidayRepository $holidayRepo = null,
83        ?callable $cacheInvalidator = null
84    ) {
85        $this->app = $app;
86        $this->store = $store;
87        $this->typeNum = $store->getTypeNum();
88        $this->hoursService = $hoursService;
89        $this->buykioskDb = $buykioskDb;
90        $this->holidayRepo = $holidayRepo;
91        $this->cacheInvalidator = $cacheInvalidator;
92    }
93
94    // =========================================================================
95    // LAZY INITIALIZATION
96    // =========================================================================
97
98    /**
99     * Get central database connection (kiosk_buykiosk)
100     */
101    private function getBuykioskDb(): PDO
102    {
103        if ($this->buykioskDb === null) {
104            $this->buykioskDb = dbConnectByName('kiosk_buykiosk');
105        }
106        return $this->buykioskDb;
107    }
108
109    /**
110     * Get StoreHoursService instance
111     */
112    private function getHoursService(): StoreHoursService
113    {
114        if ($this->hoursService === null) {
115            $this->hoursService = new StoreHoursService($this->getBuykioskDb());
116        }
117        return $this->hoursService;
118    }
119
120    /**
121     * Get StoreHolidayRepository instance
122     */
123    private function getHolidayRepo(): StoreHolidayRepository
124    {
125        if ($this->holidayRepo === null) {
126            $this->holidayRepo = new StoreHolidayRepository($this->getBuykioskDb());
127        }
128        return $this->holidayRepo;
129    }
130
131    // =========================================================================
132    // AUTHENTICATION & PERMISSION CHECKS
133    // =========================================================================
134
135    /**
136     * Check session authentication and uri_store_settings permission
137     *
138     * @return bool True if authorized
139     */
140    private function checkAuth(): bool
141    {
142        if (!isset($this->app->user) || !$this->app->user) {
143            $this->sendErrorResponse('Authentication required', 401, 'UNAUTHORIZED');
144            return false;
145        }
146
147        // Check permission
148        $hasPermission = is_callable([$this->app->user, 'checkAccess'])
149            ? $this->app->user->checkAccess('uri_store_settings')
150            : ($this->app->user->checkAccess ?? false);
151
152        if (!$hasPermission) {
153            $this->sendErrorResponse('Access denied. Requires uri_store_settings permission', 403, 'FORBIDDEN');
154            return false;
155        }
156
157        // Check store group access
158        $hasStoreAccess = is_callable([$this->app->user, 'checkStoreGroup'])
159            ? $this->app->user->checkStoreGroup($this->typeNum)
160            : ($this->app->user->checkStoreGroup ?? false);
161
162        if (!$hasStoreAccess) {
163            $this->sendErrorResponse('Access denied. You do not have access to this store', 403, 'FORBIDDEN');
164            return false;
165        }
166
167        return true;
168    }
169
170    // =========================================================================
171    // RESPONSE HELPERS
172    // =========================================================================
173
174    /**
175     * Send JSON success response
176     *
177     * @param array $data Response data
178     * @param int $status HTTP status code
179     */
180    private function sendJsonResponse(array $data, int $status = 200): void
181    {
182        $this->app->response->headers->set('Content-Type', 'application/json');
183        $this->app->response->setStatus($status);
184        $this->app->response->setBody(json_encode(array_merge(['success' => true], $data)));
185    }
186
187    /**
188     * Send JSON error response
189     *
190     * @param string $message Error message
191     * @param int $status HTTP status code
192     * @param string $code Error code
193     * @param array $validationErrors Optional validation errors
194     */
195    private function sendErrorResponse(
196        string $message,
197        int $status = 400,
198        string $code = 'ERROR',
199        array $validationErrors = []
200    ): void {
201        $this->app->response->headers->set('Content-Type', 'application/json');
202        $this->app->response->setStatus($status);
203
204        $response = [
205            'success' => false,
206            'error' => $message,
207            'code' => $code,
208        ];
209
210        if (!empty($validationErrors)) {
211            $response['validationErrors'] = $validationErrors;
212        }
213
214        $this->app->response->setBody(json_encode($response));
215    }
216
217    /**
218     * Get current user ID
219     */
220    private function getCurrentUserId(): int
221    {
222        return (int) ($this->app->user->id ?? 0);
223    }
224
225    // =========================================================================
226    // API ENDPOINTS
227    // =========================================================================
228
229    /**
230     * GET /api/:typeNum/store/config
231     *
232     * Get store configuration including hours and holidays.
233     *
234     * @see SDD Interface Specifications - Get Store Configuration
235     */
236    public function getConfig(): void
237    {
238        if (!$this->checkAuth()) {
239            return;
240        }
241
242        try {
243            $config = $this->getHoursService()->getConfig($this->store);
244
245            $this->sendJsonResponse($config);
246        } catch (Exception $e) {
247            error_log("StoreConfigController::getConfig error: " . $e->getMessage());
248            $this->sendErrorResponse('Failed to retrieve store configuration', 500, 'SERVER_ERROR');
249        }
250    }
251
252    /**
253     * PUT /api/:typeNum/store/hours
254     *
255     * Update store hours (default and/or per-day).
256     *
257     * @see SDD Interface Specifications - Update Store Hours
258     */
259    public function updateHours(): void
260    {
261        if (!$this->checkAuth()) {
262            return;
263        }
264
265        $data = json_decode($this->app->request->getBody(), true);
266
267        if (!$data || !isset($data['default'])) {
268            $this->sendErrorResponse('Request body must include "default" hours', 400, 'MISSING_BODY');
269            return;
270        }
271
272        try {
273            $result = $this->getHoursService()->updateHours(
274                $this->store,
275                $data,
276                $this->getCurrentUserId()
277            );
278
279            $this->sendJsonResponse([
280                'message' => 'Store hours updated successfully',
281                'hours' => $result['hours'] ?? $result,
282            ]);
283        } catch (\InvalidArgumentException $e) {
284            $this->sendErrorResponse($e->getMessage(), 400, 'VALIDATION_ERROR');
285        } catch (Exception $e) {
286            error_log("StoreConfigController::updateHours error: " . $e->getMessage());
287            $this->sendErrorResponse('Failed to update store hours', 500, 'SERVER_ERROR');
288        }
289    }
290
291    /**
292     * POST /api/:typeNum/store/holidays
293     *
294     * Create a new holiday override.
295     *
296     * @see SDD Interface Specifications - Create Holiday
297     */
298    public function createHoliday(): void
299    {
300        if (!$this->checkAuth()) {
301            return;
302        }
303
304        $data = json_decode($this->app->request->getBody(), true);
305
306        if (!$data || !isset($data['date'])) {
307            $this->sendErrorResponse('date is required', 400, 'MISSING_BODY');
308            return;
309        }
310
311        // Validate hours if not closed
312        $isClosed = $data['isClosed'] ?? false;
313        if (!$isClosed) {
314            $errors = $this->getHoursService()->validateHours(
315                $data['openTime'] ?? null,
316                $data['closeTime'] ?? null,
317                false
318            );
319
320            if (!empty($errors)) {
321                $this->sendErrorResponse(implode('; ', $errors), 400, 'VALIDATION_ERROR');
322                return;
323            }
324        }
325
326        try {
327            $holiday = $this->getHolidayRepo()->create($this->typeNum, $data);
328
329            // Invalidate store cache
330            $this->invalidateStoreCache();
331
332            $this->sendJsonResponse([
333                'holiday' => $holiday,
334            ]);
335        } catch (\RuntimeException $e) {
336            if (strpos($e->getMessage(), 'DUPLICATE_DATE') !== false) {
337                $this->sendErrorResponse('A holiday already exists for this date', 400, 'DUPLICATE_DATE');
338            } else {
339                error_log("StoreConfigController::createHoliday error: " . $e->getMessage());
340                $this->sendErrorResponse('Failed to create holiday', 500, 'SERVER_ERROR');
341            }
342        } catch (Exception $e) {
343            error_log("StoreConfigController::createHoliday error: " . $e->getMessage());
344            $this->sendErrorResponse('Failed to create holiday', 500, 'SERVER_ERROR');
345        }
346    }
347
348    /**
349     * PUT /api/:typeNum/store/holidays/:holidayId
350     *
351     * Update an existing holiday override.
352     *
353     * @param int $holidayId Holiday ID
354     * @see SDD Interface Specifications - Update Holiday
355     */
356    public function updateHoliday(int $holidayId): void
357    {
358        if (!$this->checkAuth()) {
359            return;
360        }
361
362        $data = json_decode($this->app->request->getBody(), true);
363
364        if (!$data) {
365            $this->sendErrorResponse('Request body is required', 400, 'MISSING_BODY');
366            return;
367        }
368
369        try {
370            // Check if holiday exists and belongs to this store
371            $existing = $this->getHolidayRepo()->findById($holidayId);
372
373            if (!$existing) {
374                $this->sendErrorResponse('Holiday not found', 404, 'NOT_FOUND');
375                return;
376            }
377
378            if ($existing['typeNum'] !== $this->typeNum) {
379                $this->sendErrorResponse('Holiday does not belong to this store', 403, 'FORBIDDEN');
380                return;
381            }
382
383            // Validate hours if not closed
384            $isClosed = $data['isClosed'] ?? $existing['isClosed'];
385            if (!$isClosed) {
386                $openTime = $data['openTime'] ?? $existing['openTime'];
387                $closeTime = $data['closeTime'] ?? $existing['closeTime'];
388
389                $errors = $this->getHoursService()->validateHours($openTime, $closeTime, false);
390
391                if (!empty($errors)) {
392                    $this->sendErrorResponse(implode('; ', $errors), 400, 'VALIDATION_ERROR');
393                    return;
394                }
395            }
396
397            $holiday = $this->getHolidayRepo()->update($holidayId, $data);
398
399            // Invalidate store cache
400            $this->invalidateStoreCache();
401
402            $this->sendJsonResponse([
403                'holiday' => $holiday,
404            ]);
405        } catch (\RuntimeException $e) {
406            if (strpos($e->getMessage(), 'NOT_FOUND') !== false) {
407                $this->sendErrorResponse('Holiday not found', 404, 'NOT_FOUND');
408            } else {
409                error_log("StoreConfigController::updateHoliday error: " . $e->getMessage());
410                $this->sendErrorResponse('Failed to update holiday', 500, 'SERVER_ERROR');
411            }
412        } catch (Exception $e) {
413            error_log("StoreConfigController::updateHoliday error: " . $e->getMessage());
414            $this->sendErrorResponse('Failed to update holiday', 500, 'SERVER_ERROR');
415        }
416    }
417
418    /**
419     * DELETE /api/:typeNum/store/holidays/:holidayId
420     *
421     * Delete a holiday override.
422     *
423     * @param int $holidayId Holiday ID
424     * @see SDD Interface Specifications - Delete Holiday
425     */
426    public function deleteHoliday(int $holidayId): void
427    {
428        if (!$this->checkAuth()) {
429            return;
430        }
431
432        try {
433            // Check if holiday exists and belongs to this store
434            $existing = $this->getHolidayRepo()->findById($holidayId);
435
436            if (!$existing) {
437                $this->sendErrorResponse('Holiday not found', 404, 'NOT_FOUND');
438                return;
439            }
440
441            if ($existing['typeNum'] !== $this->typeNum) {
442                $this->sendErrorResponse('Holiday does not belong to this store', 403, 'FORBIDDEN');
443                return;
444            }
445
446            $this->getHolidayRepo()->delete($holidayId);
447
448            // Invalidate store cache
449            $this->invalidateStoreCache();
450
451            $this->sendJsonResponse([]);
452        } catch (Exception $e) {
453            error_log("StoreConfigController::deleteHoliday error: " . $e->getMessage());
454            $this->sendErrorResponse('Failed to delete holiday', 500, 'SERVER_ERROR');
455        }
456    }
457
458    // =========================================================================
459    // HELPER METHODS
460    // =========================================================================
461
462    /**
463     * Invalidate the store cache after configuration changes
464     */
465    private function invalidateStoreCache(): void
466    {
467        if ($this->cacheInvalidator !== null) {
468            ($this->cacheInvalidator)($this->typeNum);
469            return;
470        }
471
472        try {
473            $storeController = new StoreController($this->typeNum);
474            $storeController->clearCache();
475        } catch (Exception $e) {
476            // Log but don't fail on cache invalidation errors
477            error_log("Failed to invalidate cache for store {$this->typeNum}" . $e->getMessage());
478        }
479    }
480}