Skip to content

Commit eb0244d

Browse files
committed
Merge remote-tracking branch 'origin/develop' into develop
2 parents 468f708 + 368b31a commit eb0244d

9 files changed

Lines changed: 309 additions & 108 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,23 @@ The application provides two main widget types:
203203
- Real-time goal tracking
204204
- Animated progress bars
205205

206+
3. **Card Widget** (`/widget-stream-card/{id}` / `/widget-event-card/{id}`)
207+
- Donation card with progress bar, amount and donor count
208+
209+
#### Cache & mise à jour des widgets
210+
211+
Les widgets se mettent à jour via un **polling AJAX toutes les 10 secondes** côté navigateur (appel aux endpoints `/fetch`).
212+
213+
Pour éviter de surcharger l'API HelloAsso, un **cache serveur avec TTL** est en place :
214+
215+
- 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.
216+
- 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**.
217+
- 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.
218+
219+
**Le TTL est configurable** via la variable d'environnement `WIDGET_CACHE_TTL` (en secondes, défaut : **15 secondes**).
220+
221+
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.
222+
206223
### Database Migrations
207224
Database schema is managed through SQL migration files in the `migrations/` directory:
208225
- `00-init-db.sql`: Initial database setup
@@ -230,3 +247,4 @@ Key environment variables include:
230247
- Azure Blob Storage (BLOB_CONNECTION_STRING, BLOB_URL)
231248
- Email service (MANDRILL_API)
232249
- Application domain (WEBSITE_DOMAIN)
250+
- Widget cache TTL in seconds (WIDGET_CACHE_TTL, default: 15)

migrations/13-add-cache-ttl.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE {prefix}widget_alert_box ADD cache_updated_at DATETIME(6) NULL AFTER cache_data;
2+
ALTER TABLE {prefix}widget_donation_goal_bar ADD cache_updated_at DATETIME(6) NULL AFTER cache_data;
3+
ALTER TABLE {prefix}widget_card ADD cache_updated_at DATETIME(6) NULL AFTER cache_data;
4+

src/Controllers/AdminController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public function index(Request $request, Response $response): Response
7878
"openCreateEvent" => isset($request->getQueryParams()['createEvent']),
7979
"invalidTokenSlugs" => $this->getInvalidTokenSlugs($streams),
8080
"ownerEmail" => $user->email,
81+
"streamActivity" => $this->widgetRepository->selectStreamActivityMap(),
8182
];
8283

8384
if ($user->role === "ADMIN") {

src/Controllers/WidgetController.php

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@
1414

1515
class WidgetController
1616
{
17+
private int $cacheTtl;
18+
1719
public function __construct(
1820
private Twig $view,
1921
private ApiWrapper $apiWrapper,
2022
private FileManager $fileManager,
2123
private EventRepository $eventRepository,
2224
private StreamRepository $streamRepository,
2325
private WidgetRepository $widgetRepository,
24-
) {}
26+
) {
27+
$this->cacheTtl = (int) ($_SERVER['WIDGET_CACHE_TTL'] ?? 15);
28+
}
2529

2630
// ── Helpers ───────────────────────────────────────────────────
2731

@@ -118,6 +122,15 @@ private function fetchStreamDonationData(string $streamGuid): array
118122
$cacheData = $this->widgetRepository->selectStreamDonationWidgetCacheData($charityStream)
119123
?? ['amount' => 0, 'continuation_token' => ''];
120124

125+
// Si le cache est encore frais, on retourne les données en cache sans appeler l'API
126+
if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
127+
return ['stream' => $charityStream, 'result' => [
128+
'amount' => $cacheData['amount'],
129+
'donations' => [],
130+
'continuation_token' => $cacheData['continuation_token'],
131+
]];
132+
}
133+
121134
$result = $this->apiWrapper->getAllOrders(
122135
$charityStream->organization_slug,
123136
$charityStream->form_slug,
@@ -131,6 +144,12 @@ private function fetchStreamDonationData(string $streamGuid): array
131144
'amount' => $result['amount'],
132145
'continuation_token' => $result['continuation_token'],
133146
]);
147+
} else {
148+
// Même si rien n'a changé, on met à jour le timestamp du cache
149+
$this->widgetRepository->updateStreamDonationWidgetCacheData($charityStream->guid, [
150+
'amount' => $cacheData['amount'],
151+
'continuation_token' => $cacheData['continuation_token'],
152+
]);
134153
}
135154

136155
return ['stream' => $charityStream, 'result' => $result];
@@ -148,12 +167,20 @@ private function fetchEventDonationData(string $eventGuid): array
148167
$cacheData = $this->widgetRepository->selectEventDonationWidgetCacheData($event)
149168
?? ['amount' => 0, 'streams' => []];
150169

170+
// Si le cache est encore frais, on retourne les données en cache sans appeler l'API
171+
if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
172+
return ['event' => $event, 'cacheData' => $cacheData];
173+
}
174+
151175
$streams = $this->streamRepository->selectListByEvent($event);
152176
$oldAmount = $cacheData['amount'];
153177
$cacheData = $this->aggregateEventStreams($streams, $cacheData);
154178

155179
if ($oldAmount !== $cacheData['amount']) {
156180
$this->widgetRepository->updateEventDonationWidgetCacheData($event->guid, $cacheData);
181+
} else {
182+
// Même si rien n'a changé, on met à jour le timestamp du cache
183+
$this->widgetRepository->updateEventDonationWidgetCacheData($event->guid, $cacheData);
157184
}
158185

159186
return ['event' => $event, 'cacheData' => $cacheData];
@@ -171,6 +198,11 @@ private function fetchStreamCardData(string $streamGuid): array
171198
$cacheData = $this->widgetRepository->selectStreamCardWidgetCacheData($charityStream)
172199
?? ['amount' => 0, 'donors' => 0, 'continuation_token' => ''];
173200

201+
// Si le cache est encore frais, on retourne les données en cache sans appeler l'API
202+
if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
203+
return ['stream' => $charityStream, 'amount' => $cacheData['amount'], 'donors' => $cacheData['donors'] ?? 0];
204+
}
205+
174206
$result = $this->apiWrapper->getAllOrders(
175207
$charityStream->organization_slug,
176208
$charityStream->form_slug,
@@ -188,6 +220,13 @@ private function fetchStreamCardData(string $streamGuid): array
188220
'donors' => $donors,
189221
'continuation_token' => $result['continuation_token'],
190222
]);
223+
} else {
224+
// Même si rien n'a changé, on met à jour le timestamp du cache
225+
$this->widgetRepository->updateStreamCardWidgetCacheData($charityStream->guid, [
226+
'amount' => $cacheData['amount'],
227+
'donors' => $donors,
228+
'continuation_token' => $cacheData['continuation_token'],
229+
]);
191230
}
192231

193232
return ['stream' => $charityStream, 'amount' => $result['amount'], 'donors' => $donors];
@@ -205,12 +244,20 @@ private function fetchEventCardData(string $eventGuid): array
205244
$cacheData = $this->widgetRepository->selectEventCardWidgetCacheData($event)
206245
?? ['amount' => 0, 'donors' => 0, 'streams' => []];
207246

247+
// Si le cache est encore frais, on retourne les données en cache sans appeler l'API
248+
if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
249+
return ['event' => $event, 'amount' => $cacheData['amount'], 'donors' => $cacheData['donors'] ?? 0];
250+
}
251+
208252
$streams = $this->streamRepository->selectListByEvent($event);
209253
$oldAmount = $cacheData['amount'];
210254
$cacheData = $this->aggregateEventStreams($streams, $cacheData, true);
211255

212256
if ($oldAmount !== $cacheData['amount']) {
213257
$this->widgetRepository->updateEventCardWidgetCacheData($event->guid, $cacheData);
258+
} else {
259+
// Même si rien n'a changé, on met à jour le timestamp du cache
260+
$this->widgetRepository->updateEventCardWidgetCacheData($event->guid, $cacheData);
214261
}
215262

216263
return ['event' => $event, 'amount' => $cacheData['amount'], 'donors' => $cacheData['donors']];
@@ -276,17 +323,24 @@ public function widgetAlert(Request $request, Response $response, array $args):
276323
$cacheData = $this->widgetRepository->selectAlertWidgetCacheData($charityStream)
277324
?? ['continuation_token' => ''];
278325

279-
$result = $this->apiWrapper->getAllOrders(
280-
$charityStream->organization_slug,
281-
$charityStream->form_slug,
282-
0,
283-
$cacheData['continuation_token'],
284-
);
326+
if (!$this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
327+
$result = $this->apiWrapper->getAllOrders(
328+
$charityStream->organization_slug,
329+
$charityStream->form_slug,
330+
0,
331+
$cacheData['continuation_token'],
332+
);
285333

286-
if ($cacheData['continuation_token'] !== $result['continuation_token']) {
287-
$this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [
288-
'continuation_token' => $result['continuation_token'],
289-
]);
334+
if ($cacheData['continuation_token'] !== $result['continuation_token']) {
335+
$this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [
336+
'continuation_token' => $result['continuation_token'],
337+
]);
338+
} else {
339+
// Même si rien n'a changé, on met à jour le timestamp du cache
340+
$this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [
341+
'continuation_token' => $cacheData['continuation_token'],
342+
]);
343+
}
290344
}
291345

292346
return $this->view->render($response, 'widget/alert.html.twig', [
@@ -312,6 +366,15 @@ public function widgetAlertFetch(Request $request, Response $response, array $ar
312366
?? ['continuation_token' => ''];
313367

314368
try {
369+
// Si le cache est encore frais, on retourne un résultat vide (pas de nouvelles donations)
370+
if ($this->widgetRepository->isCacheFresh($cacheData, $this->cacheTtl)) {
371+
return $this->jsonResponse($response, [
372+
'amount' => 0,
373+
'donations' => [],
374+
'continuation_token' => $cacheData['continuation_token'],
375+
]);
376+
}
377+
315378
$result = $this->apiWrapper->getAllOrders(
316379
$charityStream->organization_slug,
317380
$charityStream->form_slug,
@@ -323,6 +386,11 @@ public function widgetAlertFetch(Request $request, Response $response, array $ar
323386
$this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [
324387
'continuation_token' => $result['continuation_token'],
325388
]);
389+
} else {
390+
// Même si rien n'a changé, on met à jour le timestamp du cache
391+
$this->widgetRepository->updateAlertWidgetCacheData($charityStream->guid, [
392+
'continuation_token' => $cacheData['continuation_token'],
393+
]);
326394
}
327395

328396
return $this->jsonResponse($response, $result);

src/Repositories/EventRepository.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public function selectList(): array
2121
FROM ' . $this->prefix . 'charity_event c
2222
INNER JOIN ' . $this->prefix . 'user_right r ON r.id_charity_event = c.id
2323
INNER JOIN ' . $this->prefix . 'users u ON u.id = r.id_user
24+
ORDER BY c.creation_date DESC
2425
');
2526

2627
$stmt->setFetchMode(PDO::FETCH_CLASS, Event::class);
@@ -34,6 +35,7 @@ public function selectListByUser(User $user): array
3435
FROM ' . $this->prefix . 'charity_event c
3536
INNER JOIN ' . $this->prefix . 'user_right ur on ur.id_charity_event = c.id
3637
WHERE ur.id_user = ?
38+
ORDER BY c.creation_date DESC
3739
');
3840
$stmt->setFetchMode(PDO::FETCH_CLASS, Event::class);
3941
$stmt->execute([$user->id]);

src/Repositories/StreamRepository.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public function selectList(): array
2222
FROM ' . $this->prefix . 'charity_stream c
2323
INNER JOIN ' . $this->prefix . 'user_right r ON r.id_charity_stream = c.id
2424
INNER JOIN ' . $this->prefix . 'users u ON u.id = r.id_user
25+
ORDER BY c.creation_date DESC
2526
');
2627

2728
$stmt->setFetchMode(PDO::FETCH_CLASS, Stream::class);
@@ -56,6 +57,8 @@ public function selectListByUser(User $user): array
5657
INNER JOIN ' . $this->prefix . 'user_right ur on ur.id_charity_event = c.charity_event_id
5758
INNER JOIN ' . $this->prefix . 'users u ON u.id = ur.id_user
5859
WHERE ur.id_user = :id_user
60+
61+
ORDER BY creation_date DESC
5962
');
6063
$stmt->setFetchMode(PDO::FETCH_CLASS, Stream::class);
6164
$stmt->execute([':id_user' => $user->id]);

src/Repositories/UserRepository.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public function select(string $email): ?User
8383

8484
public function selectAll(): array
8585
{
86-
$stmt = $this->pdo->query('SELECT id, email, role, creation_date FROM ' . $this->prefix . 'users ORDER BY creation_date DESC');
86+
$stmt = $this->pdo->query('SELECT id, email, role, email_verified, creation_date FROM ' . $this->prefix . 'users ORDER BY creation_date DESC');
8787
$stmt->setFetchMode(PDO::FETCH_CLASS, User::class);
8888
return $stmt->fetchAll();
8989
}

src/Repositories/WidgetRepository.php

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,22 +137,50 @@ public function updateAlertWidget(string $guid, array $postData, ?string $image
137137
private function selectCacheData(string $table, string $column, string $guid): ?array
138138
{
139139
$stmt = $this->pdo->prepare(
140-
'SELECT cache_data FROM ' . $this->prefix . $table . ' WHERE ' . $column . ' = ?'
140+
'SELECT cache_data, cache_updated_at FROM ' . $this->prefix . $table . ' WHERE ' . $column . ' = ?'
141141
);
142142
$stmt->execute([$guid]);
143143
$data = $stmt->fetch();
144144

145-
return $data ? json_decode($data["cache_data"] ?? "", true) : null;
145+
if (!$data || !$data['cache_data']) {
146+
return null;
147+
}
148+
149+
$result = json_decode($data['cache_data'], true);
150+
if ($result !== null && isset($data['cache_updated_at'])) {
151+
$result['_cache_updated_at'] = $data['cache_updated_at'];
152+
}
153+
154+
return $result;
146155
}
147156

148157
private function updateCacheData(string $table, string $column, string $guid, array $data): void
149158
{
159+
// Remove internal metadata before persisting
160+
unset($data['_cache_updated_at']);
161+
150162
$stmt = $this->pdo->prepare(
151-
'UPDATE ' . $this->prefix . $table . ' SET cache_data = ? WHERE ' . $column . ' = ?'
163+
'UPDATE ' . $this->prefix . $table . ' SET cache_data = ?, cache_updated_at = NOW(6) WHERE ' . $column . ' = ?'
152164
);
153165
$stmt->execute([json_encode($data), $guid]);
154166
}
155167

168+
/**
169+
* Vérifie si le cache est encore frais (non expiré) selon le TTL donné en secondes.
170+
*/
171+
public function isCacheFresh(?array $cacheData, int $ttlSeconds): bool
172+
{
173+
if ($cacheData === null || !isset($cacheData['_cache_updated_at'])) {
174+
return false;
175+
}
176+
177+
$updatedAt = new \DateTime($cacheData['_cache_updated_at']);
178+
$now = new \DateTime();
179+
$age = $now->getTimestamp() - $updatedAt->getTimestamp();
180+
181+
return $age < $ttlSeconds;
182+
}
183+
156184
// ── Alert widget cache ────────────────────────────────────────
157185

158186
public function selectAlertWidgetCacheData(Stream $stream): ?array
@@ -278,6 +306,31 @@ public function updateCardWidget(?string $streamGuid, ?string $eventGuid, array
278306
}
279307
}
280308

309+
// ── Stream activity map ──────────────────────────────────────
310+
311+
/**
312+
* Retourne une map guid => {amount, widget_last_update} pour tous les streams.
313+
* Utilisé pour afficher un indicateur d'activité dans l'admin.
314+
*/
315+
public function selectStreamActivityMap(): array
316+
{
317+
$stmt = $this->pdo->query('
318+
SELECT charity_stream_guid AS guid, cache_data, last_update AS widget_last_update
319+
FROM ' . $this->prefix . 'widget_donation_goal_bar
320+
WHERE charity_stream_guid IS NOT NULL
321+
');
322+
323+
$map = [];
324+
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
325+
$cacheData = json_decode($row['cache_data'] ?? '', true);
326+
$map[$row['guid']] = [
327+
'amount' => $cacheData['amount'] ?? 0,
328+
'widget_last_update' => $row['widget_last_update'],
329+
];
330+
}
331+
return $map;
332+
}
333+
281334
// ── Card widget cache ─────────────────────────────────────────
282335

283336
public function selectStreamCardWidgetCacheData(Stream $stream): ?array

0 commit comments

Comments
 (0)