Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/php-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,6 @@ jobs:
run : |
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"

# - name: Launch sql migrations
# run : |
# 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"
- name: Launch sql migrations
run : |
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"
6 changes: 3 additions & 3 deletions .github/workflows/php-sandbox.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
run : |
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"

# - name: Launch sql migrations
# run : |
# 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"
- name: Launch sql migrations
run : |
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"

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;

76 changes: 58 additions & 18 deletions migrations/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,55 @@
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->safeLoad();

$dsn = "mysql:host={$_SERVER['DBURL']};port={$_SERVER['DBPORT']};dbname={$_SERVER['DBNAME']};charset=utf8mb4";
$pdo = new PDO($dsn, $_SERVER['DBUSER'], $_SERVER['DBPASSWORD'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$requiredVars = ['DBURL', 'DBPORT', 'DBNAME', 'DBUSER', 'DBPASSWORD', 'DBPREFIX'];
foreach ($requiredVars as $var) {
if (empty($_SERVER[$var]) && empty($_ENV[$var])) {
echo "ERROR: Missing required environment variable: $var\n";
exit(1);
}
}

// Prefer $_ENV over $_SERVER for dotenv compatibility
$dbUrl = $_ENV['DBURL'] ?? $_SERVER['DBURL'];
$dbPort = $_ENV['DBPORT'] ?? $_SERVER['DBPORT'];
$dbName = $_ENV['DBNAME'] ?? $_SERVER['DBNAME'];
$dbUser = $_ENV['DBUSER'] ?? $_SERVER['DBUSER'];
$dbPassword = $_ENV['DBPASSWORD'] ?? $_SERVER['DBPASSWORD'];
$dbPrefix = $_ENV['DBPREFIX'] ?? $_SERVER['DBPREFIX'];

try {
$dsn = "mysql:host={$dbUrl};port={$dbPort};dbname={$dbName};charset=utf8mb4";
$pdo = new PDO($dsn, $dbUser, $dbPassword, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
echo "ERROR: Database connection failed: " . $e->getMessage() . "\n";
exit(1);
}

function execute()
{
global $pdo, $dbPrefix;

$migrations = getMigrations();
if (count($migrations) == 0) {
echo "No migration files found.\n";
return;
}

$executedMigrations = getExecutedMigrations();
$pending = array_diff($migrations, $executedMigrations);

if (count($pending) == 0) {
echo "All migrations are up to date.\n";
return;
}

foreach (array_diff($migrations, $executedMigrations) as $migration) {
foreach ($pending as $migration) {
echo "Running migration: $migration ... ";
executeFile($migration);
echo "OK\n";
}
}

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

function getExecutedMigrations()
{
$stmt = $GLOBALS['pdo']->prepare('
CREATE TABLE IF NOT EXISTS `' . $_SERVER['DBPREFIX'] . 'migrations` (
global $pdo, $dbPrefix;

$pdo->exec('
CREATE TABLE IF NOT EXISTS `' . $dbPrefix . 'migrations` (
`name` varchar(255) NOT NULL,
`date` datetime NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
');
$stmt->execute();

$stmt = $GLOBALS['pdo']->prepare('
$stmt = $pdo->prepare('
SELECT name
FROM ' . $_SERVER['DBPREFIX'] . 'migrations;
FROM ' . $dbPrefix . 'migrations
');
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}

function executeFile($fileName)
{
global $pdo, $dbPrefix;

$sql = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . $fileName);
$sql = str_replace('{prefix}', $_SERVER['DBPREFIX'], $sql);
$sql = str_replace('{prefix}', $dbPrefix, $sql);

$stmt = $GLOBALS['pdo']->prepare($sql);
$stmt->execute();
$pdo->exec($sql);

$stmt = $GLOBALS['pdo']->prepare('
INSERT INTO ' . $_SERVER['DBPREFIX'] . 'migrations VALUES
(?, CURTIME());
$stmt = $pdo->prepare('
INSERT INTO ' . $dbPrefix . 'migrations VALUES
(?, NOW());
');
$stmt->execute([$fileName]);
}

execute();
try {
execute();
} catch (Exception $e) {
echo "ERROR: " . $e->getMessage() . "\n";
echo "File: " . $e->getFile() . ":" . $e->getLine() . "\n";
exit(1);
}
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
1 change: 1 addition & 0 deletions src/Models/WidgetAlert.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class WidgetAlert
public $sound;
public $sound_volume;
public $cache_data;
public $cache_updated_at;
public $creation_date;
public $last_update;
}
1 change: 1 addition & 0 deletions src/Models/WidgetCard.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class WidgetCard
public string $tag_color = '#166534';
public string $tag_background_color = '#dcfce7';
public ?string $cache_data = null;
public ?string $cache_updated_at = null;
public ?string $creation_date = null;
public ?string $last_update = null;
}
1 change: 1 addition & 0 deletions src/Models/WidgetDonation.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class WidgetDonation
public $background_color;
public $goal;
public $cache_data;
public $cache_updated_at;
public $creation_date;
public $last_update;
}
Loading
Loading