Skip to content

Commit 4e4c1f6

Browse files
authored
Merge pull request #26 from HelloAsso/develop
Develop
2 parents e844e60 + cbf2119 commit 4e4c1f6

15 files changed

Lines changed: 420 additions & 137 deletions

.github/workflows/php-prod.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,6 @@ jobs:
7878
run : |
7979
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"
8080
81-
# - name: Launch sql migrations
82-
# run : |
83-
# 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"
81+
- name: Launch sql migrations
82+
run : |
83+
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"

.github/workflows/php-sandbox.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ jobs:
7979
run : |
8080
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"
8181
82-
# - name: Launch sql migrations
83-
# run : |
84-
# 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"
82+
- name: Launch sql migrations
83+
run : |
84+
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"
8585

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+

migrations/run.php

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,55 @@
55
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../');
66
$dotenv->safeLoad();
77

8-
$dsn = "mysql:host={$_SERVER['DBURL']};port={$_SERVER['DBPORT']};dbname={$_SERVER['DBNAME']};charset=utf8mb4";
9-
$pdo = new PDO($dsn, $_SERVER['DBUSER'], $_SERVER['DBPASSWORD'], [
10-
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
11-
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
12-
]);
8+
$requiredVars = ['DBURL', 'DBPORT', 'DBNAME', 'DBUSER', 'DBPASSWORD', 'DBPREFIX'];
9+
foreach ($requiredVars as $var) {
10+
if (empty($_SERVER[$var]) && empty($_ENV[$var])) {
11+
echo "ERROR: Missing required environment variable: $var\n";
12+
exit(1);
13+
}
14+
}
15+
16+
// Prefer $_ENV over $_SERVER for dotenv compatibility
17+
$dbUrl = $_ENV['DBURL'] ?? $_SERVER['DBURL'];
18+
$dbPort = $_ENV['DBPORT'] ?? $_SERVER['DBPORT'];
19+
$dbName = $_ENV['DBNAME'] ?? $_SERVER['DBNAME'];
20+
$dbUser = $_ENV['DBUSER'] ?? $_SERVER['DBUSER'];
21+
$dbPassword = $_ENV['DBPASSWORD'] ?? $_SERVER['DBPASSWORD'];
22+
$dbPrefix = $_ENV['DBPREFIX'] ?? $_SERVER['DBPREFIX'];
23+
24+
try {
25+
$dsn = "mysql:host={$dbUrl};port={$dbPort};dbname={$dbName};charset=utf8mb4";
26+
$pdo = new PDO($dsn, $dbUser, $dbPassword, [
27+
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
28+
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
29+
]);
30+
} catch (PDOException $e) {
31+
echo "ERROR: Database connection failed: " . $e->getMessage() . "\n";
32+
exit(1);
33+
}
1334

1435
function execute()
1536
{
37+
global $pdo, $dbPrefix;
38+
1639
$migrations = getMigrations();
1740
if (count($migrations) == 0) {
41+
echo "No migration files found.\n";
1842
return;
1943
}
2044

2145
$executedMigrations = getExecutedMigrations();
46+
$pending = array_diff($migrations, $executedMigrations);
47+
48+
if (count($pending) == 0) {
49+
echo "All migrations are up to date.\n";
50+
return;
51+
}
2252

23-
foreach (array_diff($migrations, $executedMigrations) as $migration) {
53+
foreach ($pending as $migration) {
54+
echo "Running migration: $migration ... ";
2455
executeFile($migration);
56+
echo "OK\n";
2557
}
2658
}
2759

@@ -34,35 +66,43 @@ function getMigrations()
3466

3567
function getExecutedMigrations()
3668
{
37-
$stmt = $GLOBALS['pdo']->prepare('
38-
CREATE TABLE IF NOT EXISTS `' . $_SERVER['DBPREFIX'] . 'migrations` (
69+
global $pdo, $dbPrefix;
70+
71+
$pdo->exec('
72+
CREATE TABLE IF NOT EXISTS `' . $dbPrefix . 'migrations` (
3973
`name` varchar(255) NOT NULL,
4074
`date` datetime NOT NULL
4175
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4276
');
43-
$stmt->execute();
4477

45-
$stmt = $GLOBALS['pdo']->prepare('
78+
$stmt = $pdo->prepare('
4679
SELECT name
47-
FROM ' . $_SERVER['DBPREFIX'] . 'migrations;
80+
FROM ' . $dbPrefix . 'migrations
4881
');
4982
$stmt->execute();
5083
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
5184
}
5285

5386
function executeFile($fileName)
5487
{
88+
global $pdo, $dbPrefix;
89+
5590
$sql = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . $fileName);
56-
$sql = str_replace('{prefix}', $_SERVER['DBPREFIX'], $sql);
91+
$sql = str_replace('{prefix}', $dbPrefix, $sql);
5792

58-
$stmt = $GLOBALS['pdo']->prepare($sql);
59-
$stmt->execute();
93+
$pdo->exec($sql);
6094

61-
$stmt = $GLOBALS['pdo']->prepare('
62-
INSERT INTO ' . $_SERVER['DBPREFIX'] . 'migrations VALUES
63-
(?, CURTIME());
95+
$stmt = $pdo->prepare('
96+
INSERT INTO ' . $dbPrefix . 'migrations VALUES
97+
(?, NOW());
6498
');
6599
$stmt->execute([$fileName]);
66100
}
67101

68-
execute();
102+
try {
103+
execute();
104+
} catch (Exception $e) {
105+
echo "ERROR: " . $e->getMessage() . "\n";
106+
echo "File: " . $e->getFile() . ":" . $e->getLine() . "\n";
107+
exit(1);
108+
}

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/Models/WidgetAlert.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class WidgetAlert
1212
public $sound;
1313
public $sound_volume;
1414
public $cache_data;
15+
public $cache_updated_at;
1516
public $creation_date;
1617
public $last_update;
1718
}

src/Models/WidgetCard.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class WidgetCard
1919
public string $tag_color = '#166534';
2020
public string $tag_background_color = '#dcfce7';
2121
public ?string $cache_data = null;
22+
public ?string $cache_updated_at = null;
2223
public ?string $creation_date = null;
2324
public ?string $last_update = null;
2425
}

src/Models/WidgetDonation.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class WidgetDonation
1414
public $background_color;
1515
public $goal;
1616
public $cache_data;
17+
public $cache_updated_at;
1718
public $creation_date;
1819
public $last_update;
1920
}

0 commit comments

Comments
 (0)