Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 196 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
| LoopController | |
0.00% |
0 / 196 |
|
0.00% |
0 / 8 |
2256 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| addSlideToLoop | |
0.00% |
0 / 49 |
|
0.00% |
0 / 1 |
210 | |||
| updateLoop | |
0.00% |
0 / 50 |
|
0.00% |
0 / 1 |
156 | |||
| deleteSlide | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
42 | |||
| getMaxPosition | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
| addToGlobalSchedule | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
12 | |||
| deleteFromGlobalSchedule | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
12 | |||
| sanitizeInt | |
0.00% |
0 / 6 |
|
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 | |
| 12 | namespace BuyerKiosk\DigitalSign\Controllers; |
| 13 | |
| 14 | use BuyerKiosk\DigitalSign\LoopItem; |
| 15 | use BuyerKiosk\DigitalSign\StoreLoop; |
| 16 | use BuyerKiosk\DigitalSign\Constants; |
| 17 | |
| 18 | class 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 | } |