diff --git a/.github/workflows/php-prod.yml b/.github/workflows/php-prod.yml index e67696f..6b0ece0 100644 --- a/.github/workflows/php-prod.yml +++ b/.github/workflows/php-prod.yml @@ -78,6 +78,6 @@ jobs: run : | sshpass -p ${{ secrets.PROD_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }} "cd /home/socialgo/www && tar xzvf artifact.tar.gz -C twitch-widget && rm artifact.tar.gz" -# - name: Launch sql migrations -# run : | -# sshpass -p ${{ secrets.PROD_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }} "cd /home/socialgo/www/twitch-widget && php migrations/run.php" + - name: Launch sql migrations + run : | + sshpass -p ${{ secrets.PROD_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }} "cd /home/socialgo/www/twitch-widget && php migrations/run.php" diff --git a/.github/workflows/php-sandbox.yml b/.github/workflows/php-sandbox.yml index 0ac0cc3..2a83b97 100644 --- a/.github/workflows/php-sandbox.yml +++ b/.github/workflows/php-sandbox.yml @@ -79,7 +79,7 @@ jobs: run : | sshpass -p ${{ secrets.SANDBOX_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.SANDBOX_SSH_USER }}@${{ secrets.SANDBOX_SSH_HOST }} "cd /home/socialgo/www && tar xzvf artifact.tar.gz -C twitch-widget-sandbox && rm artifact.tar.gz" -# - name: Launch sql migrations -# run : | -# sshpass -p ${{ secrets.SANDBOX_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.SANDBOX_SSH_USER }}@${{ secrets.SANDBOX_SSH_HOST }} "cd /home/socialgo/www/twitch-widget-sandbox && php migrations/run.php" + - name: Launch sql migrations + run : | + sshpass -p ${{ secrets.SANDBOX_SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.SANDBOX_SSH_USER }}@${{ secrets.SANDBOX_SSH_HOST }} "cd /home/socialgo/www/twitch-widget-sandbox && php migrations/run.php" diff --git a/README.md b/README.md index 657d4a0..7062703 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,23 @@ The application provides two main widget types: - Real-time goal tracking - Animated progress bars +3. **Card Widget** (`/widget-stream-card/{id}` / `/widget-event-card/{id}`) + - Donation card with progress bar, amount and donor count + +#### Cache & mise à jour des widgets + +Les widgets se mettent à jour via un **polling AJAX toutes les 10 secondes** côté navigateur (appel aux endpoints `/fetch`). + +Pour éviter de surcharger l'API HelloAsso, un **cache serveur avec TTL** est en place : + +- Chaque widget stocke ses données en cache dans la colonne `cache_data` (JSON) et un horodatage `cache_updated_at` en base de données. +- Lors d'un appel `/fetch`, le serveur vérifie d'abord si le cache est encore **frais** (âge < TTL). Si oui, les données en cache sont retournées directement **sans aucun appel à l'API HelloAsso**. +- Si le cache est expiré, l'API HelloAsso est interrogée, puis le cache est mis à jour avec les nouvelles données et un nouveau timestamp. + +**Le TTL est configurable** via la variable d'environnement `WIDGET_CACHE_TTL` (en secondes, défaut : **15 secondes**). + +Cela garantit qu'**un seul appel API HelloAsso** est effectué par widget et par période de TTL, quel que soit le nombre de visiteurs simultanés. + ### Database Migrations Database schema is managed through SQL migration files in the `migrations/` directory: - `00-init-db.sql`: Initial database setup @@ -230,3 +247,4 @@ Key environment variables include: - Azure Blob Storage (BLOB_CONNECTION_STRING, BLOB_URL) - Email service (MANDRILL_API) - Application domain (WEBSITE_DOMAIN) +- Widget cache TTL in seconds (WIDGET_CACHE_TTL, default: 15) diff --git a/migrations/13-add-cache-ttl.sql b/migrations/13-add-cache-ttl.sql new file mode 100644 index 0000000..cd3ca35 --- /dev/null +++ b/migrations/13-add-cache-ttl.sql @@ -0,0 +1,4 @@ +ALTER TABLE {prefix}widget_alert_box ADD cache_updated_at DATETIME(6) NULL AFTER cache_data; +ALTER TABLE {prefix}widget_donation_goal_bar ADD cache_updated_at DATETIME(6) NULL AFTER cache_data; +ALTER TABLE {prefix}widget_card ADD cache_updated_at DATETIME(6) NULL AFTER cache_data; + diff --git a/migrations/run.php b/migrations/run.php index 63c80ad..cf464ff 100644 --- a/migrations/run.php +++ b/migrations/run.php @@ -5,23 +5,55 @@ $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../'); $dotenv->safeLoad(); -$dsn = "mysql:host={$_SERVER['DBURL']};port={$_SERVER['DBPORT']};dbname={$_SERVER['DBNAME']};charset=utf8mb4"; -$pdo = new PDO($dsn, $_SERVER['DBUSER'], $_SERVER['DBPASSWORD'], [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, -]); +$requiredVars = ['DBURL', 'DBPORT', 'DBNAME', 'DBUSER', 'DBPASSWORD', 'DBPREFIX']; +foreach ($requiredVars as $var) { + if (empty($_SERVER[$var]) && empty($_ENV[$var])) { + echo "ERROR: Missing required environment variable: $var\n"; + exit(1); + } +} + +// Prefer $_ENV over $_SERVER for dotenv compatibility +$dbUrl = $_ENV['DBURL'] ?? $_SERVER['DBURL']; +$dbPort = $_ENV['DBPORT'] ?? $_SERVER['DBPORT']; +$dbName = $_ENV['DBNAME'] ?? $_SERVER['DBNAME']; +$dbUser = $_ENV['DBUSER'] ?? $_SERVER['DBUSER']; +$dbPassword = $_ENV['DBPASSWORD'] ?? $_SERVER['DBPASSWORD']; +$dbPrefix = $_ENV['DBPREFIX'] ?? $_SERVER['DBPREFIX']; + +try { + $dsn = "mysql:host={$dbUrl};port={$dbPort};dbname={$dbName};charset=utf8mb4"; + $pdo = new PDO($dsn, $dbUser, $dbPassword, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); +} catch (PDOException $e) { + echo "ERROR: Database connection failed: " . $e->getMessage() . "\n"; + exit(1); +} function execute() { + global $pdo, $dbPrefix; + $migrations = getMigrations(); if (count($migrations) == 0) { + echo "No migration files found.\n"; return; } $executedMigrations = getExecutedMigrations(); + $pending = array_diff($migrations, $executedMigrations); + + if (count($pending) == 0) { + echo "All migrations are up to date.\n"; + return; + } - foreach (array_diff($migrations, $executedMigrations) as $migration) { + foreach ($pending as $migration) { + echo "Running migration: $migration ... "; executeFile($migration); + echo "OK\n"; } } @@ -34,17 +66,18 @@ function getMigrations() function getExecutedMigrations() { - $stmt = $GLOBALS['pdo']->prepare(' - CREATE TABLE IF NOT EXISTS `' . $_SERVER['DBPREFIX'] . 'migrations` ( + global $pdo, $dbPrefix; + + $pdo->exec(' + CREATE TABLE IF NOT EXISTS `' . $dbPrefix . 'migrations` ( `name` varchar(255) NOT NULL, `date` datetime NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; '); - $stmt->execute(); - $stmt = $GLOBALS['pdo']->prepare(' + $stmt = $pdo->prepare(' SELECT name - FROM ' . $_SERVER['DBPREFIX'] . 'migrations; + FROM ' . $dbPrefix . 'migrations '); $stmt->execute(); return $stmt->fetchAll(\PDO::FETCH_COLUMN); @@ -52,17 +85,24 @@ function getExecutedMigrations() function executeFile($fileName) { + global $pdo, $dbPrefix; + $sql = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . $fileName); - $sql = str_replace('{prefix}', $_SERVER['DBPREFIX'], $sql); + $sql = str_replace('{prefix}', $dbPrefix, $sql); - $stmt = $GLOBALS['pdo']->prepare($sql); - $stmt->execute(); + $pdo->exec($sql); - $stmt = $GLOBALS['pdo']->prepare(' - INSERT INTO ' . $_SERVER['DBPREFIX'] . 'migrations VALUES - (?, CURTIME()); + $stmt = $pdo->prepare(' + INSERT INTO ' . $dbPrefix . 'migrations VALUES + (?, NOW()); '); $stmt->execute([$fileName]); } -execute(); +try { + execute(); +} catch (Exception $e) { + echo "ERROR: " . $e->getMessage() . "\n"; + echo "File: " . $e->getFile() . ":" . $e->getLine() . "\n"; + exit(1); +} diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index e01f89a..c207f87 100644 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -78,6 +78,7 @@ public function index(Request $request, Response $response): Response "openCreateEvent" => isset($request->getQueryParams()['createEvent']), "invalidTokenSlugs" => $this->getInvalidTokenSlugs($streams), "ownerEmail" => $user->email, + "streamActivity" => $this->widgetRepository->selectStreamActivityMap(), ]; if ($user->role === "ADMIN") { diff --git a/src/Controllers/WidgetController.php b/src/Controllers/WidgetController.php index 4bc6743..b6c5892 100644 --- a/src/Controllers/WidgetController.php +++ b/src/Controllers/WidgetController.php @@ -14,6 +14,8 @@ class WidgetController { + private int $cacheTtl; + public function __construct( private Twig $view, private ApiWrapper $apiWrapper, @@ -21,7 +23,9 @@ public function __construct( private EventRepository $eventRepository, private StreamRepository $streamRepository, private WidgetRepository $widgetRepository, - ) {} + ) { + $this->cacheTtl = (int) ($_SERVER['WIDGET_CACHE_TTL'] ?? 15); + } // ── Helpers ─────────────────────────────────────────────────── @@ -118,6 +122,15 @@ private function fetchStreamDonationData(string $streamGuid): array $cacheData = $this->widgetRepository->selectStreamDonationWidgetCacheData($charityStream) ?? ['amount' => 0, 'continuation_token' => '']; + // Si le cache est encore frais, on retourne les données en cache sans appeler l'API + if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) { + return ['stream' => $charityStream, 'result' => [ + 'amount' => $cacheData['amount'], + 'donations' => [], + 'continuation_token' => $cacheData['continuation_token'], + ]]; + } + $result = $this->apiWrapper->getAllOrders( $charityStream->organization_slug, $charityStream->form_slug, @@ -131,6 +144,12 @@ private function fetchStreamDonationData(string $streamGuid): array 'amount' => $result['amount'], 'continuation_token' => $result['continuation_token'], ]); + } else { + // Même si rien n'a changé, on met à jour le timestamp du cache + $this->widgetRepository->updateStreamDonationWidgetCacheData($charityStream->guid, [ + 'amount' => $cacheData['amount'], + 'continuation_token' => $cacheData['continuation_token'], + ]); } return ['stream' => $charityStream, 'result' => $result]; @@ -148,12 +167,20 @@ private function fetchEventDonationData(string $eventGuid): array $cacheData = $this->widgetRepository->selectEventDonationWidgetCacheData($event) ?? ['amount' => 0, 'streams' => []]; + // Si le cache est encore frais, on retourne les données en cache sans appeler l'API + if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) { + return ['event' => $event, 'cacheData' => $cacheData]; + } + $streams = $this->streamRepository->selectListByEvent($event); $oldAmount = $cacheData['amount']; $cacheData = $this->aggregateEventStreams($streams, $cacheData); if ($oldAmount !== $cacheData['amount']) { $this->widgetRepository->updateEventDonationWidgetCacheData($event->guid, $cacheData); + } else { + // Même si rien n'a changé, on met à jour le timestamp du cache + $this->widgetRepository->updateEventDonationWidgetCacheData($event->guid, $cacheData); } return ['event' => $event, 'cacheData' => $cacheData]; @@ -171,6 +198,11 @@ private function fetchStreamCardData(string $streamGuid): array $cacheData = $this->widgetRepository->selectStreamCardWidgetCacheData($charityStream) ?? ['amount' => 0, 'donors' => 0, 'continuation_token' => '']; + // Si le cache est encore frais, on retourne les données en cache sans appeler l'API + if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) { + return ['stream' => $charityStream, 'amount' => $cacheData['amount'], 'donors' => $cacheData['donors'] ?? 0]; + } + $result = $this->apiWrapper->getAllOrders( $charityStream->organization_slug, $charityStream->form_slug, @@ -188,6 +220,13 @@ private function fetchStreamCardData(string $streamGuid): array 'donors' => $donors, 'continuation_token' => $result['continuation_token'], ]); + } else { + // Même si rien n'a changé, on met à jour le timestamp du cache + $this->widgetRepository->updateStreamCardWidgetCacheData($charityStream->guid, [ + 'amount' => $cacheData['amount'], + 'donors' => $donors, + 'continuation_token' => $cacheData['continuation_token'], + ]); } return ['stream' => $charityStream, 'amount' => $result['amount'], 'donors' => $donors]; @@ -205,12 +244,20 @@ private function fetchEventCardData(string $eventGuid): array $cacheData = $this->widgetRepository->selectEventCardWidgetCacheData($event) ?? ['amount' => 0, 'donors' => 0, 'streams' => []]; + // Si le cache est encore frais, on retourne les données en cache sans appeler l'API + if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) { + return ['event' => $event, 'amount' => $cacheData['amount'], 'donors' => $cacheData['donors'] ?? 0]; + } + $streams = $this->streamRepository->selectListByEvent($event); $oldAmount = $cacheData['amount']; $cacheData = $this->aggregateEventStreams($streams, $cacheData, true); if ($oldAmount !== $cacheData['amount']) { $this->widgetRepository->updateEventCardWidgetCacheData($event->guid, $cacheData); + } else { + // Même si rien n'a changé, on met à jour le timestamp du cache + $this->widgetRepository->updateEventCardWidgetCacheData($event->guid, $cacheData); } return ['event' => $event, 'amount' => $cacheData['amount'], 'donors' => $cacheData['donors']]; @@ -276,17 +323,24 @@ public function widgetAlert(Request $request, Response $response, array $args): $cacheData = $this->widgetRepository->selectAlertWidgetCacheData($charityStream) ?? ['continuation_token' => '']; - $result = $this->apiWrapper->getAllOrders( - $charityStream->organization_slug, - $charityStream->form_slug, - 0, - $cacheData['continuation_token'], - ); + if (!$this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) { + $result = $this->apiWrapper->getAllOrders( + $charityStream->organization_slug, + $charityStream->form_slug, + 0, + $cacheData['continuation_token'], + ); - if ($cacheData['continuation_token'] !== $result['continuation_token']) { - $this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [ - 'continuation_token' => $result['continuation_token'], - ]); + if ($cacheData['continuation_token'] !== $result['continuation_token']) { + $this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [ + 'continuation_token' => $result['continuation_token'], + ]); + } else { + // Même si rien n'a changé, on met à jour le timestamp du cache + $this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [ + 'continuation_token' => $cacheData['continuation_token'], + ]); + } } return $this->view->render($response, 'widget/alert.html.twig', [ @@ -312,6 +366,15 @@ public function widgetAlertFetch(Request $request, Response $response, array $ar ?? ['continuation_token' => '']; try { + // Si le cache est encore frais, on retourne un résultat vide (pas de nouvelles donations) + if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) { + return $this->jsonResponse($response, [ + 'amount' => 0, + 'donations' => [], + 'continuation_token' => $cacheData['continuation_token'], + ]); + } + $result = $this->apiWrapper->getAllOrders( $charityStream->organization_slug, $charityStream->form_slug, @@ -323,6 +386,11 @@ public function widgetAlertFetch(Request $request, Response $response, array $ar $this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [ 'continuation_token' => $result['continuation_token'], ]); + } else { + // Même si rien n'a changé, on met à jour le timestamp du cache + $this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [ + 'continuation_token' => $cacheData['continuation_token'], + ]); } return $this->jsonResponse($response, $result); diff --git a/src/Models/WidgetAlert.php b/src/Models/WidgetAlert.php index 6de71d1..f9c39e3 100644 --- a/src/Models/WidgetAlert.php +++ b/src/Models/WidgetAlert.php @@ -12,6 +12,7 @@ class WidgetAlert public $sound; public $sound_volume; public $cache_data; + public $cache_updated_at; public $creation_date; public $last_update; } diff --git a/src/Models/WidgetCard.php b/src/Models/WidgetCard.php index cd92145..ba183fa 100644 --- a/src/Models/WidgetCard.php +++ b/src/Models/WidgetCard.php @@ -19,6 +19,7 @@ class WidgetCard public string $tag_color = '#166534'; public string $tag_background_color = '#dcfce7'; public ?string $cache_data = null; + public ?string $cache_updated_at = null; public ?string $creation_date = null; public ?string $last_update = null; } diff --git a/src/Models/WidgetDonation.php b/src/Models/WidgetDonation.php index 1e43dfc..8bab790 100644 --- a/src/Models/WidgetDonation.php +++ b/src/Models/WidgetDonation.php @@ -14,6 +14,7 @@ class WidgetDonation public $background_color; public $goal; public $cache_data; + public $cache_updated_at; public $creation_date; public $last_update; } diff --git a/src/Repositories/EventRepository.php b/src/Repositories/EventRepository.php index 8585c10..3b2e8a2 100644 --- a/src/Repositories/EventRepository.php +++ b/src/Repositories/EventRepository.php @@ -21,6 +21,7 @@ public function selectList(): array FROM ' . $this->prefix . 'charity_event c INNER JOIN ' . $this->prefix . 'user_right r ON r.id_charity_event = c.id INNER JOIN ' . $this->prefix . 'users u ON u.id = r.id_user + ORDER BY c.creation_date DESC '); $stmt->setFetchMode(PDO::FETCH_CLASS, Event::class); @@ -34,6 +35,7 @@ public function selectListByUser(User $user): array FROM ' . $this->prefix . 'charity_event c INNER JOIN ' . $this->prefix . 'user_right ur on ur.id_charity_event = c.id WHERE ur.id_user = ? + ORDER BY c.creation_date DESC '); $stmt->setFetchMode(PDO::FETCH_CLASS, Event::class); $stmt->execute([$user->id]); diff --git a/src/Repositories/StreamRepository.php b/src/Repositories/StreamRepository.php index d3aba5b..e03769e 100644 --- a/src/Repositories/StreamRepository.php +++ b/src/Repositories/StreamRepository.php @@ -22,6 +22,7 @@ public function selectList(): array FROM ' . $this->prefix . 'charity_stream c INNER JOIN ' . $this->prefix . 'user_right r ON r.id_charity_stream = c.id INNER JOIN ' . $this->prefix . 'users u ON u.id = r.id_user + ORDER BY c.creation_date DESC '); $stmt->setFetchMode(PDO::FETCH_CLASS, Stream::class); @@ -56,6 +57,8 @@ public function selectListByUser(User $user): array INNER JOIN ' . $this->prefix . 'user_right ur on ur.id_charity_event = c.charity_event_id INNER JOIN ' . $this->prefix . 'users u ON u.id = ur.id_user WHERE ur.id_user = :id_user + + ORDER BY creation_date DESC '); $stmt->setFetchMode(PDO::FETCH_CLASS, Stream::class); $stmt->execute([':id_user' => $user->id]); diff --git a/src/Repositories/UserRepository.php b/src/Repositories/UserRepository.php index bc8bb29..ab23360 100644 --- a/src/Repositories/UserRepository.php +++ b/src/Repositories/UserRepository.php @@ -83,7 +83,7 @@ public function select(string $email): ?User public function selectAll(): array { - $stmt = $this->pdo->query('SELECT id, email, role, creation_date FROM ' . $this->prefix . 'users ORDER BY creation_date DESC'); + $stmt = $this->pdo->query('SELECT id, email, role, email_verified, creation_date FROM ' . $this->prefix . 'users ORDER BY creation_date DESC'); $stmt->setFetchMode(PDO::FETCH_CLASS, User::class); return $stmt->fetchAll(); } diff --git a/src/Repositories/WidgetRepository.php b/src/Repositories/WidgetRepository.php index cfcef9e..eb72f50 100644 --- a/src/Repositories/WidgetRepository.php +++ b/src/Repositories/WidgetRepository.php @@ -20,8 +20,20 @@ public function __construct( public function selectDonationWidgetByGuid(?string $streamGuid, ?string $eventGuid): ?WidgetDonation { $stmt = $this->pdo->prepare(' - SELECT * - FROM ' . $this->prefix . 'widget_donation_goal_bar + SELECT + id, + charity_event_guid, + charity_stream_guid, + text_color_main, + text_color_alt, + text_content, + bar_color, + background_color, + goal, + cache_data, + creation_date, + last_update + FROM ' . $this->prefix . 'widget_donation_goal_bar WHERE charity_stream_guid = ? OR charity_event_guid = ? '); @@ -33,8 +45,18 @@ public function selectDonationWidgetByGuid(?string $streamGuid, ?string $eventGu public function selectAlertWidgetByGuid(string $guid): ?WidgetAlert { $stmt = $this->pdo->prepare(' - SELECT * - FROM ' . $this->prefix . 'widget_alert_box + SELECT + id, + charity_stream_guid, + image, + alert_duration, + message_template, + sound, + sound_volume, + cache_data, + creation_date, + last_update + FROM ' . $this->prefix . 'widget_alert_box WHERE charity_stream_guid = ? '); $stmt->setFetchMode(PDO::FETCH_CLASS, WidgetAlert::class); @@ -115,22 +137,50 @@ public function updateAlertWidget(string $guid, array $postData, ?string $image private function selectCacheData(string $table, string $column, string $guid): ?array { $stmt = $this->pdo->prepare( - 'SELECT cache_data FROM ' . $this->prefix . $table . ' WHERE ' . $column . ' = ?' + 'SELECT cache_data, cache_updated_at FROM ' . $this->prefix . $table . ' WHERE ' . $column . ' = ?' ); $stmt->execute([$guid]); $data = $stmt->fetch(); - return $data ? json_decode($data["cache_data"] ?? "", true) : null; + if (!$data || !$data['cache_data']) { + return null; + } + + $result = json_decode($data['cache_data'], true); + if ($result !== null && isset($data['cache_updated_at'])) { + $result['_cache_updated_at'] = $data['cache_updated_at']; + } + + return $result; } private function updateCacheData(string $table, string $column, string $guid, array $data): void { + // Remove internal metadata before persisting + unset($data['_cache_updated_at']); + $stmt = $this->pdo->prepare( - 'UPDATE ' . $this->prefix . $table . ' SET cache_data = ? WHERE ' . $column . ' = ?' + 'UPDATE ' . $this->prefix . $table . ' SET cache_data = ?, cache_updated_at = NOW(6) WHERE ' . $column . ' = ?' ); $stmt->execute([json_encode($data), $guid]); } + /** + * Vérifie si le cache est encore frais (non expiré) selon le TTL donné en secondes. + */ + public function isCacheFresh(?array $cacheData, int $ttlSeconds): bool + { + if ($cacheData === null || !isset($cacheData['_cache_updated_at'])) { + return false; + } + + $updatedAt = new \DateTime($cacheData['_cache_updated_at']); + $now = new \DateTime(); + $age = $now->getTimestamp() - $updatedAt->getTimestamp(); + + return $age < $ttlSeconds; + } + // ── Alert widget cache ──────────────────────────────────────── public function selectAlertWidgetCacheData(Stream $stream): ?array @@ -171,7 +221,24 @@ public function selectCardWidgetByGuid(?string $streamGuid, ?string $eventGuid): { try { $stmt = $this->pdo->prepare(' - SELECT * + SELECT + id, + charity_stream_guid, + charity_event_guid, + image, + tag, + title, + description, + goal, + background_color, + bar_color, + bar_background_color, + text_color, + tag_color, + tag_background_color, + cache_data, + creation_date, + last_update FROM ' . $this->prefix . 'widget_card WHERE charity_stream_guid = ? OR charity_event_guid = ? @@ -239,6 +306,31 @@ public function updateCardWidget(?string $streamGuid, ?string $eventGuid, array } } + // ── Stream activity map ────────────────────────────────────── + + /** + * Retourne une map guid => {amount, widget_last_update} pour tous les streams. + * Utilisé pour afficher un indicateur d'activité dans l'admin. + */ + public function selectStreamActivityMap(): array + { + $stmt = $this->pdo->query(' + SELECT charity_stream_guid AS guid, cache_data, last_update AS widget_last_update + FROM ' . $this->prefix . 'widget_donation_goal_bar + WHERE charity_stream_guid IS NOT NULL + '); + + $map = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $cacheData = json_decode($row['cache_data'] ?? '', true); + $map[$row['guid']] = [ + 'amount' => $cacheData['amount'] ?? 0, + 'widget_last_update' => $row['widget_last_update'], + ]; + } + return $map; + } + // ── Card widget cache ───────────────────────────────────────── public function selectStreamCardWidgetCacheData(Stream $stream): ?array diff --git a/src/views/stream/index-admin.html.twig b/src/views/stream/index-admin.html.twig index df5e885..3c2733c 100644 --- a/src/views/stream/index-admin.html.twig +++ b/src/views/stream/index-admin.html.twig @@ -74,101 +74,153 @@ -

Liste des évènements

- - - - - - - - - - - - {% for event in events %} - - - - - - - - {% endfor %} - -
IdTitreAdminDate de créationActions
{{ event.id | e }}{{ event.title | e }}{{ event.admin | e }}{{ event.creation_date ? event.creation_date|date('d/m/Y H:i') : '—' }} - 📝 -
- -
-
+ {# ── Onglets ── #} + -

Liste des stream

- - - - - - - - - - - - - {% for stream in streams %} - - - - - - - - - {% endfor %} - -
IDTitreAdminSlug formulaireSlug associationActions
{{ stream.id | e }} - {{ stream.title | e }} - {% if stream.organization_slug in invalidTokenSlugs %} - ⚠️ Token invalide - {% endif %} - {{ stream.admin | e }}{{ stream.form_slug | e }}{{ stream.organization_slug | e }} - 📝 - 🔑 -
- -
-
+
+ {# ── Onglet Évènements ── #} +
+ + + + + + + + + + + + {% for event in events %} + + + + + + + + {% endfor %} + +
IdTitreAdminDate de créationActions
{{ event.id | e }}{{ event.title | e }}{{ event.admin | e }}{{ event.creation_date ? event.creation_date|date('d/m/Y H:i') : '—' }} + 📝 +
+ +
+
+
+ + {# ── Onglet Streams ── #} +
+ + + + + + + + + + + + + + + {% for stream in streams %} + {% set activity = streamActivity[stream.guid] ?? null %} + {% set amount = activity ? activity.amount : 0 %} + {% set widgetLastUpdate = activity ? activity.widget_last_update : null %} + + + + + + + + + + + {% endfor %} + +
IDTitreActivitéAdminSlug formulaireSlug associationDate de créationActions
{{ stream.id | e }} + {{ stream.title | e }} + {% if stream.organization_slug in invalidTokenSlugs %} + ⚠️ Token invalide + {% endif %} + + {% if widgetLastUpdate and (date(widgetLastUpdate).timestamp > date('-5 minutes').timestamp) %} + 🟢 En direct + {% elseif amount > 0 %} + 💰 {{ (amount / 100)|number_format(0, ',', ' ') }} € + {% else %} + ⚪ Inactif + {% endif %} + {{ stream.admin | e }}{{ stream.form_slug | e }}{{ stream.organization_slug | e }}{{ stream.creation_date ? stream.creation_date|date('d/m/Y H:i') : '—' }} + 📝 + 🔑 +
+ +
+
+
- {% if users is defined %} -

Utilisateurs

- - - - - - - - - - - {% for u in users %} - - - - - - - {% endfor %} - -
IDEmailRôleDate de création
{{ u.id }}{{ u.email | e }} - {% if u.role == 'ADMIN' %} - Admin - {% else %} - Utilisateur - {% endif %} - {{ u.creation_date ? u.creation_date|date('d/m/Y H:i') : '—' }}
- {% endif %} + {# ── Onglet Utilisateurs ── #} + {% if users is defined %} +
+ + + + + + + + + + + + {% for u in users %} + + + + + + + + {% endfor %} + +
IDEmailRôleEmail vérifiéDate de création
{{ u.id }}{{ u.email | e }} + {% if u.role == 'ADMIN' %} + Admin + {% else %} + Utilisateur + {% endif %} + + {% if u.email_verified %} + ✅ Vérifié + {% else %} + ⏳ Non vérifié + {% endif %} + {{ u.creation_date ? u.creation_date|date('d/m/Y H:i') : '—' }}
+
+ {% endif %} +
{% if messages['error'] %}