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
-
-
-
- | Id |
- Titre |
- Admin |
- Date de création |
- Actions |
-
-
-
- {% for event in events %}
-
- | {{ event.id | e }} |
- {{ event.title | e }} |
- {{ event.admin | e }} |
- {{ event.creation_date ? event.creation_date|date('d/m/Y H:i') : '—' }} |
-
- 📝
-
- |
-
- {% endfor %}
-
-
+ {# ── Onglets ── #}
+
+ -
+
+
+ -
+
+
+ {% if users is defined %}
+ -
+
+
+ {% endif %}
+
- Liste des stream
-
-
-
- | ID |
- Titre |
- Admin |
- Slug formulaire |
- Slug association |
- Actions |
-
-
-
- {% for stream in streams %}
-
- | {{ 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 }} |
-
- 📝
- 🔑
-
- |
-
- {% endfor %}
-
-
+
+ {# ── Onglet Évènements ── #}
+
+
+
+
+ | Id |
+ Titre |
+ Admin |
+ Date de création |
+ Actions |
+
+
+
+ {% for event in events %}
+
+ | {{ event.id | e }} |
+ {{ event.title | e }} |
+ {{ event.admin | e }} |
+ {{ event.creation_date ? event.creation_date|date('d/m/Y H:i') : '—' }} |
+
+ 📝
+
+ |
+
+ {% endfor %}
+
+
+
+
+ {# ── Onglet Streams ── #}
+
+
+
+
+ | ID |
+ Titre |
+ Activité |
+ Admin |
+ Slug formulaire |
+ Slug association |
+ Date de création |
+ Actions |
+
+
+
+ {% for stream in streams %}
+ {% set activity = streamActivity[stream.guid] ?? null %}
+ {% set amount = activity ? activity.amount : 0 %}
+ {% set widgetLastUpdate = activity ? activity.widget_last_update : null %}
+
+ | {{ 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') : '—' }} |
+
+ 📝
+ 🔑
+
+ |
+
+ {% endfor %}
+
+
+
- {% if users is defined %}
-
Utilisateurs
-
-
-
- | ID |
- Email |
- Rôle |
- Date de création |
-
-
-
- {% for u in users %}
-
- | {{ 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') : '—' }} |
-
- {% endfor %}
-
-
- {% endif %}
+ {# ── Onglet Utilisateurs ── #}
+ {% if users is defined %}
+
+
+
+
+ | ID |
+ Email |
+ Rôle |
+ Email vérifié |
+ Date de création |
+
+
+
+ {% for u in users %}
+
+ | {{ 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') : '—' }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
{% if messages['error'] %}