Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 196
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoopController
0.00% covered (danger)
0.00%
0 / 196
0.00% covered (danger)
0.00%
0 / 8
2256
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 addSlideToLoop
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
210
 updateLoop
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
156
 deleteSlide
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
42
 getMaxPosition
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 addToGlobalSchedule
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
12
 deleteFromGlobalSchedule
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 sanitizeInt
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Loop Controller
4 *
5 * Manages slide loops/playlists for digital signage displays.
6 * Handles adding, removing, and reordering slides in the loop.
7 *
8 * @package BuyerKiosk\DigitalSign\Controllers
9 * @since December 2025 - Fixed updateLoop bug, added transactions, consolidated logging
10 */
11
12namespace BuyerKiosk\DigitalSign\Controllers;
13
14use BuyerKiosk\DigitalSign\LoopItem;
15use BuyerKiosk\DigitalSign\StoreLoop;
16use BuyerKiosk\DigitalSign\Constants;
17
18class LoopController extends \BuyerKiosk\Core\Controllers\BaseController
19{
20    /** @var \Store */
21    private $store;
22
23    /** @var \PDO */
24    private $storeDB;
25
26    /** @var \KLogger */
27    private $log;
28
29    /** @var \PDO */
30    private $global_db;
31
32    /**
33     * Constructor
34     *
35     * @param \Slim\Slim $app The Slim application instance
36     * @param \Store $store The store entity
37     */
38    public function __construct($app, \Store $store)
39    {
40        parent::__construct($app);
41        $this->store = $store;
42        global $db_name;
43        $this->global_db = dbConnectByName($db_name);
44        $this->storeDB = dbConnectByName($this->store->getDbName());
45        $this->log = new \KLogger($_ENV['LOG_DIR'] . "digital_sign.log", \KLogger::DEBUG);
46    }
47
48    /**
49     * Add a slide to the loop/playlist
50     *
51     * @return bool True on success, false on failure
52     */
53    public function addSlideToLoop()
54    {
55        $loopItem = new LoopItem($this->store);
56
57        // Sanitize and validate inputs
58        $loopItem->slideID = $this->sanitizeInt($this->_app->request->post("slideID"));
59        if ($loopItem->slideID === null || $loopItem->slideID <= 0) {
60            $this->log->LogWarning("addSlideToLoop: Invalid slideID");
61            return false;
62        }
63
64        $loopItem->position = $this->getMaxPosition() + 1;
65        $loopItem->enabled = Constants::ENABLED_YES;
66
67        // Duration with default fallback
68        $duration = $this->sanitizeInt($this->_app->request->post("duration"));
69        $loopItem->duration = $duration !== null && $duration > 0 ? $duration : Constants::DEFAULT_DURATION;
70
71        // Handle scheduling dates
72        $startDate = $this->_app->request->post("startDate");
73        $loopItem->startDate = !empty($startDate) ? $startDate : null;
74
75        $expireDate = $this->_app->request->post("expireDate");
76        $loopItem->expireDate = !empty($expireDate) ? $expireDate : null;
77
78        // Set scheduled flag based on start date
79        $loopItem->scheduled = ($loopItem->startDate !== null)
80            ? Constants::SCHEDULE_PENDING
81            : Constants::SCHEDULE_ACTIVE;
82
83        // Validate and set slide uploader (source)
84        $slideUploader = $this->sanitizeInt($this->_app->request->post("slideUploader"));
85        if (!in_array($slideUploader, [Constants::SOURCE_STORE, Constants::SOURCE_CORPORATE, Constants::SOURCE_HIPBONE], true)) {
86            $this->log->LogWarning("addSlideToLoop: Invalid slideUploader", ['slideUploader' => $slideUploader]);
87            return false;
88        }
89        $loopItem->slideUploader = $slideUploader;
90
91        // Use transaction for multi-table operations
92        try {
93            $this->storeDB->beginTransaction();
94
95            if (!$loopItem->addLoopItem()) {
96                $this->storeDB->rollBack();
97                $this->log->LogError("addSlideToLoop: Failed to add loop item", [
98                    'slideID' => $loopItem->slideID
99                ]);
100                return false;
101            }
102
103            // Add to global schedule if scheduled or has expiration
104            if ($loopItem->scheduled || $loopItem->expireDate !== null) {
105                if (!$this->addToGlobalSchedule($loopItem)) {
106                    $this->storeDB->rollBack();
107                    $this->log->LogError("addSlideToLoop: Failed to add to global schedule", [
108                        'slideID' => $loopItem->slideID
109                    ]);
110                    return false;
111                }
112            }
113
114            $this->storeDB->commit();
115            $this->log->LogDebug("addSlideToLoop: Slide added successfully", [
116                'slideID' => $loopItem->slideID,
117                'position' => $loopItem->position,
118                'scheduled' => $loopItem->scheduled
119            ]);
120            return true;
121
122        } catch (\Exception $e) {
123            $this->storeDB->rollBack();
124            $this->log->LogError("addSlideToLoop: Exception occurred", [
125                'message' => $e->getMessage(),
126                'slideID' => $loopItem->slideID
127            ]);
128            return false;
129        }
130    }
131
132    /**
133     * Update loop positions (reorder slides)
134     *
135     * @return bool True on success, false on failure
136     */
137    public function updateLoop()
138    {
139        $data = $this->_app->request->post("data");
140
141        if (!is_array($data) || empty($data)) {
142            $this->log->LogWarning("updateLoop: No data provided or invalid format");
143            return false;
144        }
145
146        try {
147            $this->storeDB->beginTransaction();
148
149            foreach ($data as $item) {
150                // Validate item structure
151                if (!isset($item['id']) || !isset($item['position'])) {
152                    $this->storeDB->rollBack();
153                    $this->log->LogWarning("updateLoop: Invalid item structure", [
154                        'hasId' => isset($item['id']),
155                        'hasPosition' => isset($item['position'])
156                    ]);
157                    return false;
158                }
159
160                $id = $this->sanitizeInt($item['id']);
161                $position = $this->sanitizeInt($item['position']);
162
163                if ($id === null || $id <= 0 || $position === null || $position < 0) {
164                    $this->storeDB->rollBack();
165                    $this->log->LogWarning("updateLoop: Invalid id or position values", [
166                        'id' => $id,
167                        'position' => $position
168                    ]);
169                    return false;
170                }
171
172                $updateQuery = $this->storeDB->prepare(
173                    "UPDATE dsLoop SET position = :position WHERE id = :id"
174                );
175                $updateQuery->bindParam(":id", $id, \PDO::PARAM_INT);
176                $updateQuery->bindParam(":position", $position, \PDO::PARAM_INT);
177
178                if ($updateQuery->execute() === false) {
179                    $this->storeDB->rollBack();
180                    $this->log->LogError("updateLoop: Failed to update position", [
181                        'error_code' => $updateQuery->errorInfo()[0],
182                        'id' => $id,
183                        'position' => $position
184                    ]);
185                    return false;
186                }
187
188                $this->log->LogDebug("updateLoop: Updated position", [
189                    'id' => $id,
190                    'position' => $position
191                ]);
192            }
193
194            $this->storeDB->commit();
195            $this->log->LogDebug("updateLoop: All positions updated successfully", [
196                'count' => count($data)
197            ]);
198            return true;
199
200        } catch (\PDOException $e) {
201            $this->storeDB->rollBack();
202            $this->log->LogError("updateLoop: PDO exception", [
203                'message' => $e->getMessage()
204            ]);
205            return false;
206        }
207    }
208
209    /**
210     * Delete a slide from the loop
211     *
212     * @param int $slideID The loop entry ID to delete
213     * @return bool True on success, false on failure
214     */
215    public function deleteSlide($slideID)
216    {
217        $slideID = $this->sanitizeInt($slideID);
218
219        if ($slideID === null || $slideID <= 0) {
220            $this->log->LogWarning("deleteSlide: Invalid slideID", ['slideID' => $slideID]);
221            return false;
222        }
223
224        try {
225            $this->storeDB->beginTransaction();
226
227            $storeLoop = new StoreLoop($this->store);
228            if (!$storeLoop->deleteSlideFromLoop($slideID)) {
229                $this->storeDB->rollBack();
230                $this->log->LogError("deleteSlide: Failed to delete from loop", [
231                    'slideID' => $slideID
232                ]);
233                return false;
234            }
235
236            if (!$this->deleteFromGlobalSchedule($slideID)) {
237                // Note: This is not a rollback case - the slide might not be in the schedule
238                $this->log->LogDebug("deleteSlide: No global schedule entry to delete", [
239                    'slideID' => $slideID
240                ]);
241            }
242
243            $this->storeDB->commit();
244            $this->log->LogDebug("deleteSlide: Slide deleted from loop", [
245                'slideID' => $slideID
246            ]);
247            return true;
248
249        } catch (\Exception $e) {
250            $this->storeDB->rollBack();
251            $this->log->LogError("deleteSlide: Exception occurred", [
252                'message' => $e->getMessage(),
253                'slideID' => $slideID
254            ]);
255            return false;
256        }
257    }
258
259    /**
260     * Get the maximum position value in the current loop
261     *
262     * @return int The maximum position, or 0 if loop is empty
263     */
264    private function getMaxPosition()
265    {
266        try {
267            $select = $this->storeDB->prepare("SELECT MAX(position) as maxPos FROM dsLoop");
268            $select->execute();
269            $result = $select->fetch(\PDO::FETCH_ASSOC);
270            return $result['maxPos'] !== null ? (int) $result['maxPos'] : -1;
271        } catch (\Exception $e) {
272            $this->log->LogError("getMaxPosition: Exception occurred", [
273                'message' => $e->getMessage()
274            ]);
275            return -1;
276        }
277    }
278
279    /**
280     * Add a loop item to the global schedule for timed activation/expiration
281     *
282     * @param LoopItem $loopItem The loop item to schedule
283     * @return bool True on success, false on failure
284     */
285    private function addToGlobalSchedule(LoopItem $loopItem)
286    {
287        try {
288            $globalDB = dbConnectByName($_ENV['DB_NAME']);
289            $typeNum = $this->store->getTypeNum();
290
291            $insert = $globalDB->prepare(
292                "INSERT INTO digitalSignSchedule (startDate, expireDate, loopID, typeNum, finished)
293                 VALUES (:startDate, :expireDate, :loopID, :typeNum, 0)"
294            );
295            $insert->bindParam(":startDate", $loopItem->startDate);
296            $insert->bindParam(":expireDate", $loopItem->expireDate);
297            $insert->bindParam(":loopID", $loopItem->id, \PDO::PARAM_INT);
298            $insert->bindParam(":typeNum", $typeNum, \PDO::PARAM_STR);
299
300            if ($insert->execute() === false) {
301                $this->log->LogError("addToGlobalSchedule: Insert failed", [
302                    'error_code' => $insert->errorInfo()[0],
303                    'loopID' => $loopItem->id
304                ]);
305                return false;
306            }
307
308            $this->log->LogDebug("addToGlobalSchedule: Added to schedule", [
309                'loopID' => $loopItem->id,
310                'startDate' => $loopItem->startDate,
311                'expireDate' => $loopItem->expireDate
312            ]);
313            return true;
314
315        } catch (\PDOException $e) {
316            $this->log->LogError("addToGlobalSchedule: PDO exception", [
317                'message' => $e->getMessage(),
318                'loopID' => $loopItem->id
319            ]);
320            return false;
321        }
322    }
323
324    /**
325     * Delete a loop item from the global schedule
326     *
327     * @param int $loopID The loop entry ID
328     * @return bool True on success, false on failure
329     */
330    private function deleteFromGlobalSchedule($loopID)
331    {
332        try {
333            $globalDB = dbConnectByName($_ENV['DB_NAME']);
334            $typeNum = $this->store->getTypeNum();
335
336            $delete = $globalDB->prepare(
337                "DELETE FROM digitalSignSchedule WHERE loopID = :loopID AND typeNum = :typeNum"
338            );
339            $delete->bindParam(":loopID", $loopID, \PDO::PARAM_INT);
340            $delete->bindParam(":typeNum", $typeNum, \PDO::PARAM_STR);
341
342            if ($delete->execute() === false) {
343                $this->log->LogError("deleteFromGlobalSchedule: Delete failed", [
344                    'error_code' => $delete->errorInfo()[0],
345                    'loopID' => $loopID
346                ]);
347                return false;
348            }
349
350            return true;
351
352        } catch (\PDOException $e) {
353            $this->log->LogError("deleteFromGlobalSchedule: PDO exception", [
354                'message' => $e->getMessage(),
355                'loopID' => $loopID
356            ]);
357            return false;
358        }
359    }
360
361    /**
362     * Sanitize an integer input
363     *
364     * @param mixed $value The value to sanitize
365     * @return int|null Sanitized integer or null
366     */
367    private function sanitizeInt($value): ?int
368    {
369        if ($value === null || $value === '') {
370            return null;
371        }
372        $sanitized = filter_var($value, FILTER_SANITIZE_NUMBER_INT);
373        if ($sanitized === false || $sanitized === '') {
374            return null;
375        }
376        return (int) $sanitized;
377    }
378}