Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions migrations/13-add-cache-ttl.sql
Original file line number Diff line number Diff line change
@@ -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;

1 change: 1 addition & 0 deletions src/Controllers/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
90 changes: 79 additions & 11 deletions src/Controllers/WidgetController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@

class WidgetController
{
private int $cacheTtl;

public function __construct(
private Twig $view,
private ApiWrapper $apiWrapper,
private FileManager $fileManager,
private EventRepository $eventRepository,
private StreamRepository $streamRepository,
private WidgetRepository $widgetRepository,
) {}
) {
$this->cacheTtl = (int) ($_SERVER['WIDGET_CACHE_TTL'] ?? 15);
}

// ── Helpers ───────────────────────────────────────────────────

Expand Down Expand Up @@ -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,
Expand All @@ -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];
Expand All @@ -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];
Expand All @@ -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,
Expand All @@ -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];
Expand All @@ -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']];
Expand Down Expand Up @@ -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', [
Expand All @@ -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,
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/Repositories/EventRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]);
Expand Down
3 changes: 3 additions & 0 deletions src/Repositories/StreamRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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]);
Expand Down
2 changes: 1 addition & 1 deletion src/Repositories/UserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
59 changes: 56 additions & 3 deletions src/Repositories/WidgetRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading