feat: Phase 3 complete — push alerts, feedback scoring, S3 fallback, SPA routing, mobile CSS

- ImpactScoreService: implement getFeedbackBonus() (ST_Distance query on UserFeedback within 20km/6h)
- ImpactScoreService: trigger PushNotificationService when score >= 70
- PushNotificationService: send VAPID WebPush to spot subscribers, clean expired subs
- PushController: GET vapid-public-key, POST/DELETE subscribe with rate limiting
- SentinelHubClient: add optional $collection param, add Sentinel-3 OLCI FAI evalscript (MCI)
- IngestionService: add $collection param + HighCloudCoverageException for fallback logic
- IngestionService: add ingestWithFallback() — tries S2, falls back to S3 on high cloud
- IngestSentinelCommand: --source=auto (default) triggers ingestWithFallback
- FeedbackController: rate limiting via apiFeedbackLimiter
- Migration: push_subscription table
- rate_limiter.yaml: api_read(120/min), api_feedback(10/min), api_push(5/min)
- sw.js: service worker handling push events + notificationclick
- usePushSubscription hook: subscribe/unsubscribe lifecycle with VAPID
- SpotPanel: PushButton component integrated
- SpotPanel.css + TimelineSlider.css: mobile responsive (bottom-sheet on small screens)
- Caddyfile: SPA served at / with try_files fallback, sw.js served from root scope
- vite.config.js: build outDir → backend/public (not /spa)
- deploy/post-receive.sh: full deploy script (composer, npm build, migrations, cache, docker up)
- docs/roadmap.md: all Phase 3 + transversal items marked done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-01 03:43:56 -04:00
parent 94bb6f5e8c
commit 2cef3725e9
24 changed files with 780 additions and 42 deletions

View File

@@ -26,8 +26,26 @@ class IngestionService
#[Autowire('%kernel.project_dir%')] private string $projectDir,
) {}
public function ingest(array $bbox, \DateTimeImmutable $date, string $source = 'Sentinel-2'): void
/**
* Tente l'ingestion Sentinel-2, puis Sentinel-3 OLCI en fallback si la
* couverture nuageuse est trop élevée ou si la scène S2 est indisponible.
*/
public function ingestWithFallback(array $bbox, \DateTimeImmutable $date): void
{
try {
$this->ingest($bbox, $date, 'Sentinel-2', 'sentinel-2-l2a');
} catch (HighCloudCoverageException $e) {
$this->logger->info('S2 cloud too high, falling back to Sentinel-3', ['reason' => $e->getMessage()]);
$this->ingest($bbox, $date, 'Sentinel-3', 'sentinel-3-olci');
}
}
public function ingest(
array $bbox,
\DateTimeImmutable $date,
string $source = 'Sentinel-2',
string $collection = 'sentinel-2-l2a',
): void {
$job = new DataIngestionJob();
$job->setSource($source);
$job->setTileId(sprintf('[%s]', implode(',', $bbox)));
@@ -38,14 +56,16 @@ class IngestionService
try {
// Étape 1 : vérification couverture nuageuse via Catalog API
$cloudCoverage = $this->sentinelHub->getCloudCoverage($bbox, $date);
$this->logger->info('Cloud coverage check', ['bbox' => $bbox, 'coverage' => $cloudCoverage]);
$cloudCoverage = $this->sentinelHub->getCloudCoverage($bbox, $date, $collection);
$this->logger->info('Cloud coverage check', ['bbox' => $bbox, 'coverage' => $cloudCoverage, 'collection' => $collection]);
if ($cloudCoverage > self::CLOUD_THRESHOLD) {
$job->setStatus('failed');
$job->setErrorMessage("Couverture nuageuse {$cloudCoverage}% > seuil " . self::CLOUD_THRESHOLD . '%');
$this->em->flush();
return;
throw new HighCloudCoverageException(
"Cloud coverage {$cloudCoverage}% exceeds threshold for collection {$collection}"
);
}
// Étape 2 : téléchargement du raster AFAI binaire
@@ -54,7 +74,7 @@ class IngestionService
$tifPath = $tmpDir . '/afai.tif';
$geojsonPath = $tmpDir . '/polygons.geojson';
$metadata = $this->sentinelHub->downloadAfaiBinary($bbox, $date, $tifPath);
$metadata = $this->sentinelHub->downloadAfaiBinary($bbox, $date, $tifPath, $collection);
$this->logger->info('GeoTIFF downloaded', ['path' => $tifPath, 'tile' => $metadata['tileId']]);
// Étape 3 : vectorisation via GDAL