# Digital Signage Technical Patterns

> **Last Updated**: December 2025
> **Related**: [Overview](./digital-signage-overview.md)

## Architecture Patterns

### MVC Architecture

The digital signage system follows Model-View-Controller separation:

| Layer | Location | Purpose |
|-------|----------|---------|
| Models | `src/BuyerKiosk/DigitalSign/*.php` | Data entities and persistence |
| Views | `templates/themes/default/ds/*.html` | Twig templates |
| Controllers | `src/BuyerKiosk/DigitalSign/Controllers/*.php` | Request handling |
| Routes | `routes/groups/digitalsign.php` | URL routing |

### PSR-4 Autoloading

All classes use PSR-4 autoloading under the `BuyerKiosk\DigitalSign` namespace:

```php
namespace BuyerKiosk\DigitalSign;
namespace BuyerKiosk\DigitalSign\Controllers;
```

No manual includes required - Composer handles autoloading.

### Multi-Store Database Pattern

The system implements the platform's central-plus-store database pattern:

```php
// Store-specific connection
$this->storeDB = dbConnectByName($this->store->getDbName());

// Global connection for cross-store data
$this->globalDB = dbConnectByName($_ENV['DB_NAME']);
```

**Complex Multi-Database Query** (`StoreLoop.php:83-106`):
```sql
SELECT dsLoop.id, dsLoop.position,
    IF(slideUploader = 0, dsSlides.fileName,
       IF(slideUploader = 1, `kiosk_buykiosk`.corpSlides.fileName,
          `kiosk_buykiosk`.hbSlides.fileName)) as fileName
FROM dsLoop
    LEFT JOIN `kiosk_buykiosk`.hbSlides
        ON (`kiosk_buykiosk`.hbSlides.id = dsLoop.slideID AND dsLoop.slideUploader = 2)
    LEFT JOIN `kiosk_buykiosk`.corpSlides
        ON (`kiosk_buykiosk`.corpSlides.id = dsLoop.slideID AND dsLoop.slideUploader = 1)
    LEFT JOIN dsSlides
        ON (dsSlides.id = dsLoop.slideID AND dsLoop.slideUploader = 0)
ORDER BY position ASC
```

---

## Controller Patterns

### Base Controller Inheritance

All controllers extend `BaseController`:

```php
class LoopController extends \BuyerKiosk\Core\Controllers\BaseController
{
    protected $store;
    protected $storeDB;
    protected $global_db;
    protected $log;

    public function __construct($app, \Store $store) {
        parent::__construct($app);
        $this->store = $store;
        $this->storeDB = dbConnectByName($this->store->getDbName());
        $this->log = new \KLogger($_ENV['LOG_DIR']."digital_sign.log", \KLogger::DEBUG);
    }
}
```

**Pattern Benefits**:
- Store context enforced at instantiation
- Dual database connections ready
- Consistent logging setup

### Request Handling Pattern

**Recommended Pattern** (using Slim request wrapper):
```php
public function addSlideToLoop() {
    $slideID = filter_var(
        $this->_app->request->post("slideID"),
        FILTER_SANITIZE_NUMBER_INT
    );
    // ... process
}
```

### Response Pattern

Controllers return boolean success/failure:

```php
public function addSlideToLoop() {
    if ($loopItem->addLoopItem()) {
        return true;
    }
    error_log("Error adding slide to loop");
    return false;
}
```

Route handlers translate to HTTP status:
```php
if ($controller->addSlideToLoop()) {
    $app->response->setStatus(200);
} else {
    $app->response->setStatus(403);
}
```

---

## Model Patterns

### Active Record Pattern

Models encapsulate both data and persistence logic:

```php
class Slide {
    public $id;
    public $slideName;
    public $fileName;
    public $type;

    public function save() {
        if ($this->id) {
            return $this->update();
        }
        return $this->insert();
    }

    public function insert() {
        $query = $this->db->prepare("INSERT INTO dsSlides ...");
        $query->execute();
        $this->id = $this->db->lastInsertId();
        return true;
    }
}
```

### Collection/Aggregate Pattern

`StoreLoop` manages collections of slides:

```php
class StoreLoop {
    private $loopArray = [];

    public function getCurrentSignLoop() {
        $this->fetchLoopFromDataBase();
        foreach ($this->loopArray as $loopItem) {
            $Slide = new Slide($this->store);
            $Slide->populateFromArray($loopItem);
            $tempArray[] = $Slide;
        }
        return $tempArray;
    }
}
```

### Factory/Aggregator Pattern

`AvailableSlides` aggregates slides from multiple sources:

```php
class AvailableSlides {
    public function getAvailableSlides() {
        return [
            "corpSlides" => [
                "images" => $this->getCorpSlides('image'),
                "videos" => $this->getCorpSlides('video')
            ],
            "storeSlides" => [
                "images" => $this->getStoreSlides('image'),
                "videos" => $this->getStoreSlides('video')
            ],
            "hbSlides" => [
                "images" => $this->getHipboneSlides('image'),
                "videos" => $this->getHipboneSlides('video')
            ]
        ];
    }
}
```

---

## Data Access Patterns

### PDO Prepared Statements

All queries use parameterized statements for SQL injection protection:

```php
$insert = $this->storeDB->prepare(
    "INSERT INTO dsLoop
    (startDate, expireDate, slideID, position, duration, animation, slideUploader, scheduled)
    VALUES (:startDate, :expireDate, :slideID, :position, :duration, :animation, :slideUploader, :scheduled)"
);
$insert->bindParam(":startDate", $this->startDate);
$insert->bindParam(":slideID", $this->slideID);
// ... more bindings
$insert->execute();
```

### Query Result Handling

**Single Row**:
```php
$query->execute();
$result = $query->fetch(\PDO::FETCH_ASSOC);
```

**Multiple Rows**:
```php
$query->execute();
while ($row = $query->fetch()) {
    $slides[] = $row;
}
```

### Last Insert ID Pattern

```php
if ($query->execute()) {
    $this->id = $this->storeDB->lastInsertId();
    return true;
}
```

---

## Error Handling Patterns

### Exception Handling

```php
try {
    $db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
    $query = $db->prepare("...");
    $query->execute();
    return true;
} catch (\PDOException $e) {
    error_log($e->getMessage());
    return false;
}
```

### Return Value Checking

```php
if ($insertQuery->execute() === false) {
    error_log("Error: " . print_r($insertQuery->errorInfo(), true));
    return false;
}
return true;
```

### Logging Patterns

**KLogger** (preferred for this module):
```php
$this->log = new \KLogger($_ENV['LOG_DIR']."digital_sign.log", \KLogger::DEBUG);
$this->log->LogDebug("Operation completed");
$this->log->LogError($e->getMessage());
```

**error_log** (fallback):
```php
error_log("Error adding slide: " . print_r($query->errorInfo(), true));
```

---

## File Upload Patterns

### Upload Handler Strategy

The system uses jQuery File Upload backend with custom extensions:

```php
public function upload() {
    $options = [
        "upload_dir" => $this->uploadDir,
        "accept_file_types" => "/\.(gif|jpe?g|png|mp4|webm)$/i",
        "image_versions" => [
            "thumbs" => ["max_width" => 300, "max_height" => 300]
        ]
    ];
    $handler = new UploadHandler($options);
    // Process response
    return $handler;
}
```

### Random Filename Generation

```php
protected function generate_random_filename($original_filename) {
    $extension = pathinfo($original_filename, PATHINFO_EXTENSION);
    $random_string = bin2hex(random_bytes(16));
    return $random_string . '.' . $extension;
}
```

### File Type Routing

```php
$mimeTopLevel = explode('/', $mime_type)[0];

switch ($mimeTopLevel) {
    case 'video':
        $this->handle_video_file($file_path, $file);
        break;
    case 'image':
        $this->handle_image_file($file_path, $file);
        break;
}
```

---

## Video Processing Patterns

### FFMpeg Integration

```php
use FFMpeg\FFMpeg;
use FFMpeg\FFProbe;
use FFMpeg\Coordinate\TimeCode;

class UploadHandler {
    private $ffmpeg;

    public function __construct() {
        $this->ffmpeg = FFMpeg::create();
    }
}
```

### Thumbnail Extraction

```php
protected function handle_video_file($file_path, $file) {
    $video = $this->ffmpeg->open($file_path);
    $frame = $video->frame(TimeCode::fromSeconds(5));
    $thumbfile = $file->name . '.jpg';
    $frame->save($this->upload_path . '/thumbs/' . $thumbfile);
}
```

### Duration Detection

```php
$ffprobe = FFProbe::create();
$duration = $ffprobe
    ->streams($file_path)
    ->videos()
    ->first()
    ->get('duration');
$file->duration = floor($duration);
```

---

## Template Patterns

### Twig Conditional Rendering

```twig
{% for slide in slidesArray %}
    {% if slide.scheduled == 1 %}
        {% continue %}
    {% endif %}

    {% if slide.type == 0 %}
        {% include 'ds/snips/img.html' %}
    {% elseif slide.type == 1 %}
        {% include 'ds/snips/vid.html' %}
    {% elseif slide.type == 3 %}
        {% if isFirstQueue == 1 %}
            {% include 'ds/snips/queue.html' %}
            {% set isFirstQueue = 0 %}
        {% endif %}
    {% endif %}
{% endfor %}
```

### Data Attribute Pattern

Passing data to JavaScript via HTML attributes:

```html
<div id="loop" loopArray="{% for slide in slidesArray %}{{ slide.id }}({{slide.duration}}) {% endfor %}"></div>
```

---

## Routing Patterns

### Route Group Pattern

```php
$app->group('/:typeNum', function() use ($app) {
    $app->post('/upload', function($typeNum) use ($app) {
        // Handle upload
    });

    $app->group("/loop", function() use ($app) {
        $app->post('/', function($typeNum) use ($app) {
            // Add to loop
        });

        $app->delete('/:slideID', function($typeNum, $slideID) use ($app) {
            // Remove from loop
        });
    });
});
```

### Controller Instantiation Pattern

```php
$app->post('/', function($typeNum) use ($app) {
    $storeController = new BuyerKiosk\StoreController($typeNum);
    $store = $storeController->getStore();
    $controller = new \BuyerKiosk\DigitalSign\Controllers\LoopController($app, $store);

    if ($controller->addSlideToLoop()) {
        $app->response->setStatus(200);
    } else {
        $app->response->setStatus(403);
    }
});
```

---

## Real-time Update Pattern

### Ably Publishing

```php
use Ably\AblyRest;

public function activateSlide($loopID, $typeNum) {
    // ... database updates ...

    $ably = new AblyRest($_ENV['ABLY_KEY']);
    $channel = $ably->channels->get($typeNum);
    $channel->publish('refresh', ['action' => 'refresh']);
}
```

### Client-Side Subscription (conceptual)

```javascript
const ably = new Ably.Realtime(apiKey);
const channel = ably.channels.get(typeNum);

channel.subscribe('refresh', (message) => {
    location.reload(); // or fetch new loop data
});
```

---

## Typical Request Flows

### Add Slide to Loop

```
POST /:typeNum/loop
  ↓
Route handler instantiates StoreController
  ↓
Retrieves Store entity
  ↓
Instantiates LoopController with $app, $store
  ↓
LoopController->addSlideToLoop()
  ↓
Creates LoopItem instance
  ↓
Populates properties from POST (sanitized)
  ↓
LoopItem->addLoopItem() → INSERT to dsLoop
  ↓
If scheduled: addToGlobalSchedule() → INSERT to digitalSignSchedule
  ↓
Returns boolean
  ↓
Route handler sets HTTP status
```

### Upload File

```
POST /api/upload-media/:typeNum
  ↓
UploadController->upload()
  ↓
Instantiate UploadHandler with options
  ↓
UploadHandler validates (type, size, dimensions)
  ↓
Moves file with random filename
  ↓
If video: FFMpeg extracts thumbnail + duration
  ↓
If image: Create thumbnail version
  ↓
Instantiate Slide model
  ↓
Slide->save() → INSERT into dsSlides
  ↓
Return UploadHandler response
```
