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/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/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..92a883e 100644
--- a/src/Repositories/WidgetRepository.php
+++ b/src/Repositories/WidgetRepository.php
@@ -115,22 +115,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
@@ -239,6 +267,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'] %}