From e0e5f86204c2da6ebb08643c0f842a08e770f0d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Le=CC=81o?= Date: Fri, 15 May 2026 10:30:16 +0200 Subject: [PATCH 1/2] Admin home page improvement --- src/Controllers/AdminController.php | 1 + src/Repositories/EventRepository.php | 2 + src/Repositories/StreamRepository.php | 3 + src/Repositories/UserRepository.php | 2 +- src/Repositories/WidgetRepository.php | 25 +++ src/views/stream/index-admin.html.twig | 238 +++++++++++++++---------- 6 files changed, 177 insertions(+), 94 deletions(-) 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/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..7e62ab6 100644 --- a/src/Repositories/WidgetRepository.php +++ b/src/Repositories/WidgetRepository.php @@ -239,6 +239,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'] %} From f27186bfb474f01a46eaaf3105b1da1399567979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Le=CC=81o?= Date: Fri, 15 May 2026 10:53:02 +0200 Subject: [PATCH 2/2] Add cache to scale widget deployment --- README.md | 18 ++++++ migrations/13-add-cache-ttl.sql | 4 ++ src/Controllers/WidgetController.php | 90 +++++++++++++++++++++++---- src/Repositories/WidgetRepository.php | 34 +++++++++- 4 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 migrations/13-add-cache-ttl.sql 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/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/WidgetRepository.php b/src/Repositories/WidgetRepository.php index 7e62ab6..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