diff --git a/backend/config/packages/rate_limiter.yaml b/backend/config/packages/rate_limiter.yaml
new file mode 100644
index 0000000..1fb0009
--- /dev/null
+++ b/backend/config/packages/rate_limiter.yaml
@@ -0,0 +1,19 @@
+framework:
+ rate_limiter:
+ # Endpoints publics en lecture : 120 req/min par IP
+ api_read:
+ policy: token_bucket
+ limit: 120
+ rate: { interval: '1 minute', amount: 120 }
+
+ # Endpoint feedback (écriture) : 10 req/min par IP
+ api_feedback:
+ policy: token_bucket
+ limit: 10
+ rate: { interval: '1 minute', amount: 10 }
+
+ # Abonnement push : 5 req/min par IP
+ api_push:
+ policy: token_bucket
+ limit: 5
+ rate: { interval: '1 minute', amount: 5 }
diff --git a/backend/migrations/Version20260401000001.php b/backend/migrations/Version20260401000001.php
new file mode 100644
index 0000000..47fd320
--- /dev/null
+++ b/backend/migrations/Version20260401000001.php
@@ -0,0 +1,39 @@
+addSql('
+ CREATE TABLE push_subscription (
+ id UUID NOT NULL DEFAULT gen_random_uuid(),
+ coastal_point_id UUID,
+ endpoint TEXT NOT NULL,
+ auth_token TEXT NOT NULL,
+ p256dh_key TEXT NOT NULL,
+ created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
+ PRIMARY KEY(id),
+ UNIQUE (endpoint)
+ )
+ ');
+ $this->addSql('ALTER TABLE push_subscription ADD CONSTRAINT fk_push_coastal_point FOREIGN KEY (coastal_point_id) REFERENCES coastal_point (id) ON DELETE SET NULL');
+ $this->addSql('CREATE INDEX idx_push_coastal_point ON push_subscription (coastal_point_id)');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE IF EXISTS push_subscription');
+ }
+}
diff --git a/backend/src/Command/GenerateVapidKeysCommand.php b/backend/src/Command/GenerateVapidKeysCommand.php
new file mode 100644
index 0000000..016f588
--- /dev/null
+++ b/backend/src/Command/GenerateVapidKeysCommand.php
@@ -0,0 +1,34 @@
+title('Clés VAPID générées');
+ $io->text('Ajoutez ces lignes dans votre .env.local (ou secrets Docker) :');
+ $io->newLine();
+ $io->writeln('VAPID_PUBLIC_KEY=' . $keys['publicKey']);
+ $io->writeln('VAPID_PRIVATE_KEY=' . $keys['privateKey']);
+ $io->newLine();
+ $io->warning('Ne committez JAMAIS la clé privée. Utilisez les secrets Docker ou .env.local (non commité).');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/backend/src/Command/IngestSentinelCommand.php b/backend/src/Command/IngestSentinelCommand.php
index 399d02c..12d5e1c 100644
--- a/backend/src/Command/IngestSentinelCommand.php
+++ b/backend/src/Command/IngestSentinelCommand.php
@@ -38,7 +38,7 @@ class IngestSentinelCommand extends Command
->addOption('zone', 'z', InputOption::VALUE_OPTIONAL,
'Zone spécifique. Défaut : toutes les zones.', null)
->addOption('source', 's', InputOption::VALUE_OPTIONAL,
- 'Source satellite (Sentinel-2 ou Sentinel-3)', 'Sentinel-2');
+ 'Source satellite : Sentinel-2, Sentinel-3, ou auto (S2 avec fallback S3)', 'auto');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -71,7 +71,15 @@ class IngestSentinelCommand extends Command
foreach ($zones as $name => $bbox) {
$io->section("Zone : {$name}");
try {
- $this->ingestionService->ingest($bbox, $date, $source);
+ if ($source === 'auto') {
+ $this->ingestionService->ingestWithFallback($bbox, $date);
+ } else {
+ $collection = match (strtolower($source)) {
+ 'sentinel-3' => 'sentinel-3-olci',
+ default => 'sentinel-2-l2a',
+ };
+ $this->ingestionService->ingest($bbox, $date, $source, $collection);
+ }
$io->success("OK — {$name}");
} catch (\Throwable $e) {
$io->error("ÉCHEC — {$name} : " . $e->getMessage());
diff --git a/backend/src/Controller/FeedbackController.php b/backend/src/Controller/FeedbackController.php
index fab3199..e93790a 100644
--- a/backend/src/Controller/FeedbackController.php
+++ b/backend/src/Controller/FeedbackController.php
@@ -8,12 +8,16 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/feedback', name: 'api_feedback_')]
class FeedbackController extends AbstractController
{
- public function __construct(private EntityManagerInterface $em) {}
+ public function __construct(
+ private EntityManagerInterface $em,
+ private RateLimiterFactory $apiFeedbackLimiter,
+ ) {}
/**
* POST /api/feedback
@@ -30,6 +34,11 @@ class FeedbackController extends AbstractController
#[Route('', name: 'create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
+ $limiter = $this->apiFeedbackLimiter->create($request->getClientIp() ?? 'unknown');
+ if (!$limiter->consume()->isAccepted()) {
+ return $this->json(['error' => 'Too many requests'], 429);
+ }
+
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
diff --git a/backend/src/Controller/PushController.php b/backend/src/Controller/PushController.php
new file mode 100644
index 0000000..ac84e31
--- /dev/null
+++ b/backend/src/Controller/PushController.php
@@ -0,0 +1,99 @@
+json(['publicKey' => $this->vapidPublicKey]);
+ }
+
+ /**
+ * POST /api/push/subscribe
+ * Enregistre un abonnement push.
+ *
+ * {
+ * "endpoint": "https://...",
+ * "keys": { "auth": "...", "p256dh": "..." },
+ * "coastalPointId": "uuid" // optionnel
+ * }
+ */
+ #[Route('/subscribe', name: 'subscribe', methods: ['POST'])]
+ public function subscribe(Request $request): JsonResponse
+ {
+ $limiter = $this->apiPushLimiter->create($request->getClientIp() ?? 'unknown');
+ if (!$limiter->consume()->isAccepted()) {
+ return $this->json(['error' => 'Too many requests'], 429);
+ }
+
+ $data = json_decode($request->getContent(), true);
+
+ if (!isset($data['endpoint'], $data['keys']['auth'], $data['keys']['p256dh'])) {
+ return $this->json(['error' => 'Missing required fields'], 422);
+ }
+
+ // Upsert : si l'endpoint existe déjà, on met à jour
+ $repo = $this->em->getRepository(PushSubscription::class);
+ $sub = $repo->findOneBy(['endpoint' => $data['endpoint']]) ?? new PushSubscription();
+
+ $sub->setEndpoint($data['endpoint']);
+ $sub->setAuthToken($data['keys']['auth']);
+ $sub->setP256dhKey($data['keys']['p256dh']);
+
+ if (!empty($data['coastalPointId'])) {
+ $spot = $this->em->getRepository(CoastalPoint::class)->find($data['coastalPointId']);
+ $sub->setCoastalPoint($spot);
+ }
+
+ $this->em->persist($sub);
+ $this->em->flush();
+
+ return $this->json(['id' => (string) $sub->getId()], 201);
+ }
+
+ /**
+ * DELETE /api/push/subscribe
+ * Supprime un abonnement push.
+ */
+ #[Route('/subscribe', name: 'unsubscribe', methods: ['DELETE'])]
+ public function unsubscribe(Request $request): JsonResponse
+ {
+ $data = json_decode($request->getContent(), true);
+ if (empty($data['endpoint'])) {
+ return $this->json(['error' => 'Missing endpoint'], 422);
+ }
+
+ $sub = $this->em->getRepository(PushSubscription::class)
+ ->findOneBy(['endpoint' => $data['endpoint']]);
+
+ if ($sub !== null) {
+ $this->em->remove($sub);
+ $this->em->flush();
+ }
+
+ return $this->json(null, 204);
+ }
+}
diff --git a/backend/src/Entity/PushSubscription.php b/backend/src/Entity/PushSubscription.php
new file mode 100644
index 0000000..ca4912e
--- /dev/null
+++ b/backend/src/Entity/PushSubscription.php
@@ -0,0 +1,51 @@
+createdAt = new \DateTimeImmutable();
+ }
+
+ public function getId(): ?Uuid { return $this->id; }
+ public function getEndpoint(): ?string { return $this->endpoint; }
+ public function setEndpoint(string $endpoint): static { $this->endpoint = $endpoint; return $this; }
+ public function getAuthToken(): ?string { return $this->authToken; }
+ public function setAuthToken(string $authToken): static { $this->authToken = $authToken; return $this; }
+ public function getP256dhKey(): ?string { return $this->p256dhKey; }
+ public function setP256dhKey(string $p256dhKey): static { $this->p256dhKey = $p256dhKey; return $this; }
+ public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; }
+ public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; }
+ public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
+}
diff --git a/backend/src/Repository/PushSubscriptionRepository.php b/backend/src/Repository/PushSubscriptionRepository.php
new file mode 100644
index 0000000..05395c1
--- /dev/null
+++ b/backend/src/Repository/PushSubscriptionRepository.php
@@ -0,0 +1,15 @@
+computeScore($spot, $obsId, $forecastH6);
$this->persistScore($spot, $score);
$this->invalidateCache($spotId);
+
+ // Alerte push si score passe en "high"
+ if ($score['score'] >= self::HIGH_SCORE_THRESHOLD) {
+ $this->push->notifySpot($spot, $score['score'], $score['level']);
+ }
} catch (\Throwable $e) {
$this->logger->error('Score computation failed', [
'spot' => $spot->getName(),
@@ -124,7 +137,10 @@ class ImpactScoreService
$velocityScore = min(max($approachKmh, 0.0) / self::MAX_APPROACH_KMH, 1.0) * 20;
$trendScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 };
- $total = (int) round($distanceScore + $densityScore + $velocityScore + $trendScore);
+ // Bonus feedback terrain récent
+ $feedbackBonus = $this->getFeedbackBonus($spotId);
+
+ $total = (int) round($distanceScore + $densityScore + $velocityScore + $trendScore + $feedbackBonus);
$total = max(0, min(100, $total));
return [
@@ -254,6 +270,27 @@ class ImpactScoreService
return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable'];
}
+ private function getFeedbackBonus(string $spotId): float
+ {
+ $windowStart = (new \DateTimeImmutable())->modify('-' . self::FEEDBACK_WINDOW_H . ' hours');
+
+ $row = $this->connection->fetchAssociative(
+ 'SELECT COUNT(*) AS positive_count
+ FROM user_feedback uf
+ JOIN coastal_point cp ON cp.id = :spotId
+ WHERE uf.has_seaweed = true
+ AND uf.timestamp >= :since
+ AND ST_Distance(uf.location::geography, cp.geometry::geography) <= :radius',
+ [
+ 'spotId' => $spotId,
+ 'since' => $windowStart->format('Y-m-d H:i:s'),
+ 'radius' => self::FEEDBACK_RADIUS_M,
+ ]
+ );
+
+ return ($row !== false && (int) $row['positive_count'] > 0) ? (float) self::FEEDBACK_BONUS : 0.0;
+ }
+
private function findForecastByHorizon(array $forecasts, int $horizon): ?SargassumForecast
{
foreach ($forecasts as $f) {
diff --git a/backend/src/Service/Ingestion/HighCloudCoverageException.php b/backend/src/Service/Ingestion/HighCloudCoverageException.php
new file mode 100644
index 0000000..8313749
--- /dev/null
+++ b/backend/src/Service/Ingestion/HighCloudCoverageException.php
@@ -0,0 +1,5 @@
+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
diff --git a/backend/src/Service/Push/PushNotificationService.php b/backend/src/Service/Push/PushNotificationService.php
new file mode 100644
index 0000000..92f18f3
--- /dev/null
+++ b/backend/src/Service/Push/PushNotificationService.php
@@ -0,0 +1,91 @@
+em->getRepository(PushSubscription::class)
+ ->findBy(['coastalPoint' => $spot]);
+
+ if (empty($subscriptions)) {
+ return;
+ }
+
+ $webPush = new WebPush([
+ 'VAPID' => [
+ 'subject' => $this->appPublicUrl,
+ 'publicKey' => $this->vapidPublicKey,
+ 'privateKey' => $this->vapidPrivateKey,
+ ],
+ ]);
+
+ $payload = json_encode([
+ 'title' => '⚠️ Alerte sargasses — ' . $spot->getName(),
+ 'body' => sprintf('Score %d/100 — %s. Vérifiez avant de partir.', $score, ucfirst($level)),
+ 'url' => $this->appPublicUrl,
+ 'spotId' => (string) $spot->getId(),
+ ]);
+
+ $expired = [];
+
+ foreach ($subscriptions as $sub) {
+ $webPushSub = Subscription::create([
+ 'endpoint' => $sub->getEndpoint(),
+ 'keys' => [
+ 'auth' => $sub->getAuthToken(),
+ 'p256dh' => $sub->getP256dhKey(),
+ ],
+ ]);
+
+ $webPush->queueNotification($webPushSub, $payload);
+ }
+
+ foreach ($webPush->flush() as $report) {
+ if ($report->isSubscriptionExpired()) {
+ // Supprimer les abonnements expirés
+ foreach ($subscriptions as $sub) {
+ if ($sub->getEndpoint() === $report->getRequest()->getUri()->__toString()) {
+ $expired[] = $sub;
+ }
+ }
+ } elseif (!$report->isSuccess()) {
+ $this->logger->warning('Push notification failed', [
+ 'reason' => $report->getReason(),
+ ]);
+ }
+ }
+
+ foreach ($expired as $sub) {
+ $this->em->remove($sub);
+ }
+ if (!empty($expired)) {
+ $this->em->flush();
+ }
+
+ $this->logger->info('Push notifications sent', [
+ 'spot' => $spot->getName(),
+ 'count' => count($subscriptions) - count($expired),
+ ]);
+ }
+}
diff --git a/backend/src/Service/SentinelHub/SentinelHubClient.php b/backend/src/Service/SentinelHub/SentinelHubClient.php
index db4ca6f..ee3486c 100644
--- a/backend/src/Service/SentinelHub/SentinelHubClient.php
+++ b/backend/src/Service/SentinelHub/SentinelHubClient.php
@@ -43,11 +43,17 @@ class SentinelHubClient
}
/**
- * Retourne la couverture nuageuse (%) de la meilleure scène disponible
- * pour le bbox et la date donnés.
+ * Retourne la couverture nuageuse (%) de la meilleure scène disponible.
+ * collection : 'sentinel-2-l2a' (défaut) ou 'sentinel-3-olci'
*/
- public function getCloudCoverage(array $bbox, \DateTimeImmutable $date): float
+ public function getCloudCoverage(array $bbox, \DateTimeImmutable $date, string $collection = 'sentinel-2-l2a'): float
{
+ // Sentinel-3 OLCI n'expose pas eo:cloud_cover dans le Catalog ; on renvoie 0
+ // pour laisser le Process API décider.
+ if ($collection === 'sentinel-3-olci') {
+ return 0.0;
+ }
+
$token = $this->getToken();
$dateStr = $date->format('Y-m-d');
@@ -56,7 +62,7 @@ class SentinelHubClient
'json' => [
'bbox' => $bbox,
'datetime' => "{$dateStr}T00:00:00Z/{$dateStr}T23:59:59Z",
- 'collections' => ['sentinel-2-l2a'],
+ 'collections' => [$collection],
'limit' => 1,
'sortby' => [['field' => 'eo:cloud_cover', 'direction' => 'asc']],
],
@@ -73,14 +79,22 @@ class SentinelHubClient
/**
* Télécharge un raster UINT8 binaire (1=sargasse, 0=non, 255=nuage/nodata)
- * calculé via evalscript AFAI, et le sauvegarde dans $outputPath.
- * Retourne les métadonnées de la scène.
+ * calculé via evalscript AFAI/FAI, et le sauvegarde dans $outputPath.
+ * collection : 'sentinel-2-l2a' (défaut) ou 'sentinel-3-olci'
*/
- public function downloadAfaiBinary(array $bbox, \DateTimeImmutable $date, string $outputPath): array
- {
+ public function downloadAfaiBinary(
+ array $bbox,
+ \DateTimeImmutable $date,
+ string $outputPath,
+ string $collection = 'sentinel-2-l2a',
+ ): array {
$token = $this->getToken();
$dateStr = $date->format('Y-m-d');
+ $evalscript = $collection === 'sentinel-3-olci'
+ ? $this->getFaiBinaryEvalscriptS3()
+ : $this->getAfaiBinaryEvalscript();
+
$response = $this->httpClient->request('POST', self::PROCESS_URL, [
'headers' => ['Authorization' => "Bearer {$token}"],
'json' => [
@@ -90,7 +104,7 @@ class SentinelHubClient
'properties' => ['crs' => 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'],
],
'data' => [[
- 'type' => 'sentinel-2-l2a',
+ 'type' => $collection,
'dataFilter' => [
'timeRange' => [
'from' => "{$dateStr}T00:00:00Z",
@@ -107,7 +121,7 @@ class SentinelHubClient
'format' => ['type' => 'image/tiff'],
]],
],
- 'evalscript' => $this->getAfaiBinaryEvalscript(),
+ 'evalscript' => $evalscript,
],
]);
@@ -117,7 +131,7 @@ class SentinelHubClient
return [
'tileId' => $headers['x-process-request-id'][0] ?? uniqid('tile_'),
- 'afaiMean' => 0.0, // calculé en phase post-traitement si nécessaire
+ 'afaiMean' => 0.0,
'afaiStd' => 0.0,
];
}
@@ -126,7 +140,6 @@ class SentinelHubClient
{
// Longueurs d'onde Sentinel-2 (nm) — constantes capteur
// B04 (Red) : 664.5 | B08 (NIR) : 832.8 | B11 (SWIR1) : 1613.7
- // ratio = (λ_NIR - λ_RED) / (λ_SWIR1 - λ_RED)
return <<<'EVALSCRIPT'
//VERSION=3
function setup() {
@@ -146,7 +159,40 @@ function evaluatePixel(sample) {
const afai = sample.B08 - sample.B04 - (sample.B11 - sample.B04) * ratio;
- return [afai > 0.005 ? 1 : 0]; // 1 = sargasse détectée
+ return [afai > 0.005 ? 1 : 0];
+}
+EVALSCRIPT;
+ }
+
+ /**
+ * Evalscript Sentinel-3 OLCI — FAI (Floating Algae Index) via MCI.
+ *
+ * OLCI bands (nm) : Oa08=665 (Red), Oa11=708.75 (RedEdge), Oa17=865 (NIR)
+ * MCI = Oa17 − Oa08 − (Oa11 − Oa08) × (865−665)/(708.75−665)
+ * Seuil empirique retenu : MCI > 0.008
+ */
+ private function getFaiBinaryEvalscriptS3(): string
+ {
+ return <<<'EVALSCRIPT'
+//VERSION=3
+function setup() {
+ return {
+ input: [{ bands: ["B08", "B11", "B17"], units: "REFLECTANCE" }],
+ output: { bands: 1, sampleType: "UINT8" }
+ };
+}
+
+function evaluatePixel(sample) {
+ const lambdaRed = 665.0;
+ const lambdaRedEdge = 708.75;
+ const lambdaNir = 865.0;
+ const ratio = (lambdaNir - lambdaRed) / (lambdaRedEdge - lambdaRed);
+
+ // MCI : baseline interpolée entre Red et RedEdge
+ const baseline = sample.B08 + (sample.B11 - sample.B08) * (lambdaNir - lambdaRed) / (lambdaRedEdge - lambdaRed);
+ const mci = sample.B17 - baseline;
+
+ return [mci > 0.008 ? 1 : 0];
}
EVALSCRIPT;
}
diff --git a/deploy/post-receive.sh b/deploy/post-receive.sh
new file mode 100644
index 0000000..3b3fbd8
--- /dev/null
+++ b/deploy/post-receive.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+# post-receive hook — à copier dans /opt/barerepos/radarsargasses971.git/hooks/post-receive
+# puis : chmod +x /opt/barerepos/radarsargasses971.git/hooks/post-receive
+
+set -e
+
+DEPLOY_DIR="/opt/apps/radarsargasses971"
+REPO_DIR="/opt/barerepos/radarsargasses971.git"
+
+echo "==> [post-receive] Déploiement Sargasse-Sentry"
+
+# 1. Checkout du code source
+git --work-tree="$DEPLOY_DIR" --git-dir="$REPO_DIR" checkout -f main
+
+cd "$DEPLOY_DIR"
+
+# 2. Dépendances PHP (sans scripts en prod pour éviter les erreurs de conteneur)
+echo "==> composer install"
+docker compose exec -T php composer install \
+ --no-dev --no-interaction --optimize-autoloader --no-scripts 2>&1 || \
+ docker run --rm -v "$DEPLOY_DIR/backend":/app -w /app \
+ composer:2 install --no-dev --no-interaction --optimize-autoloader
+
+# 3. Build React → backend/public
+echo "==> npm run build"
+docker run --rm -v "$DEPLOY_DIR/frontend":/app -w /app \
+ node:20-alpine sh -c "npm ci --silent && npm run build"
+
+# 4. Migrations Doctrine
+echo "==> migrations:migrate"
+docker compose exec -T php php bin/console doctrine:migrations:migrate --no-interaction --env=prod
+
+# 5. Vidage du cache Symfony
+echo "==> cache:clear"
+docker compose exec -T php php bin/console cache:clear --env=prod --no-warmup
+docker compose exec -T php php bin/console cache:warmup --env=prod
+
+# 6. Redémarrage des conteneurs si la config Docker a changé
+echo "==> docker compose up -d"
+docker compose up -d --remove-orphans
+
+echo "==> Déploiement terminé."
diff --git a/docker/php/Caddyfile b/docker/php/Caddyfile
index 0855224..4ddbed9 100644
--- a/docker/php/Caddyfile
+++ b/docker/php/Caddyfile
@@ -6,20 +6,26 @@
:80 {
root * /app/public
- # Assets Symfony
+ # Assets Symfony (webpack encore)
handle /bundles/* {
file_server
}
- # SPA React (fichiers statiques buildés dans public/spa/)
- handle /spa/* {
+ # Service worker (must be served from root scope)
+ handle /sw.js {
file_server
}
- # Tout le reste → Symfony (front controller index.php)
- handle {
+ # API → Symfony front controller
+ handle /api/* {
php_server
}
+ # SPA React : try static files first, fallback to index.html
+ handle {
+ try_files {path} /index.html
+ file_server
+ }
+
encode gzip
}
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 5410ea3..cb55d07 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -20,7 +20,7 @@
- [x] Initialisation dépôt Git (bare repo VPS + hook post-receive)
- [x] Configuration Docker (FrankenPHP + PostgreSQL/PostGIS + Redis)
- [x] Configuration Traefik (domaine radarsargasses971.com, SSL Let's Encrypt)
-- [x] Variables d'environnement (.env.example)
+- [x] Variables d'environnement (.env + clés VAPID documentées)
- [x] Initialisation projet Symfony (`symfony new backend`)
- [x] Installation API Platform
- [x] Initialisation projet React (`npm create vite@latest frontend`)
@@ -35,6 +35,7 @@
- [x] Migration : `CoastalPoint`
- [x] Migration : `ImpactScore`
- [x] Migration : `UserFeedback`
+- [x] Migration : `PushSubscription`
- [x] Index spatiaux (GIST) + index BTREE définis
---
@@ -71,7 +72,7 @@
- [x] Marqueurs CoastalPoints avec sélection
- [x] SpotPanel : Spot Mode complet (score, niveau, distance, trend)
- [x] Bouton feedback terrain (confirmation/infirmation anonyme)
-- [x] Build Vite → backend/public/spa/ (SPA statique servie par Caddy)
+- [x] Build Vite → backend/public/ (SPA servie depuis la racine par Caddy)
### Données initiales
@@ -110,7 +111,6 @@
- [x] Mise à jour dynamique polygones + score sur slider
- [x] Couche forecasts (violet pointillé) vs observations (orange plein)
- [ ] Gradients radiaux densité — différé
-- [ ] Vue mobile optimisée — différé
---
@@ -118,24 +118,32 @@
### Boucle d'apprentissage
-- [ ] Bouton "Je confirme présence de sargasses"
-- [ ] `POST /api/feedback` — persistance `UserFeedback` anonyme
-- [ ] Intégration feedback dans calcul score (pondération)
+- [x] Bouton "Je confirme présence de sargasses"
+- [x] `POST /api/feedback` — persistance `UserFeedback` anonyme
+- [x] Intégration feedback dans calcul score (bonus +8 pts si présence confirmée < 6h dans rayon 20 km)
### Alertes push
-- [ ] Définir modalité d'inscription légère (push token navigateur ou email)
-- [ ] Système d'abonnement par `CoastalPoint`
-- [ ] Worker Symfony — envoi alertes si score dépasse seuil
-- [ ] Interface d'inscription (friction minimale)
+- [x] Commande `app:generate-vapid-keys` (génération clés VAPID)
+- [x] `GET /api/push/vapid-public-key` — clé publique VAPID
+- [x] `POST /api/push/subscribe` — enregistrement abonnement push
+- [x] `DELETE /api/push/subscribe` — désinscription
+- [x] `PushNotificationService` — envoi WebPush VAPID, nettoyage abonnements expirés
+- [x] Déclenchement alertes si score ≥ 70 (HIGH_SCORE_THRESHOLD)
+- [x] Service worker (`sw.js`) — réception push + ouverture app au clic
+- [x] `usePushSubscription` hook React — subscribe/unsubscribe lifecycle
+- [x] Bouton "Activer les alertes" dans SpotPanel
---
## Transversal (continu)
-- [ ] Rate limiting endpoints publics
+- [x] Rate limiting endpoints publics (api_read 120/min, api_feedback 10/min, api_push 5/min)
+- [x] Fallback Sentinel-2 → Sentinel-3 OLCI (mode `auto` dans IngestSentinelCommand)
+- [x] Routing SPA corrigé (Caddyfile `try_files` + build vers `backend/public/`)
+- [x] Vue mobile (SpotPanel bottom-sheet + TimelineSlider compact)
+- [x] Post-receive hook complet (composer + npm build + migrations + cache + docker up)
- [ ] Monitoring pipeline (alertes si ingestion échoue > N fois)
-- [ ] Logs structurés
-- [ ] Fallback Sentinel-2 → Sentinel-3
+- [ ] Logs structurés (Monolog JSON handler)
- [ ] Configuration Cloudflare free tier (cache tiles statiques)
- [ ] Tests fonctionnels pipeline (ingestion → score)
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 0000000..f9d002f
--- /dev/null
+++ b/frontend/public/sw.js
@@ -0,0 +1,42 @@
+// Service Worker — Sargasse-Sentry push notifications
+self.addEventListener('push', (event) => {
+ if (!event.data) return;
+
+ let payload;
+ try {
+ payload = event.data.json();
+ } catch {
+ payload = { title: 'Alerte sargasses', body: event.data.text() };
+ }
+
+ const title = payload.title ?? 'Alerte sargasses';
+ const options = {
+ body: payload.body ?? '',
+ icon: '/icons.svg',
+ badge: '/icons.svg',
+ tag: payload.spotId ? `sargasse-${payload.spotId}` : 'sargasse-alert',
+ renotify: true,
+ data: { url: payload.url ?? '/' },
+ };
+
+ event.waitUntil(self.registration.showNotification(title, options));
+});
+
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close();
+
+ const targetUrl = event.notification.data?.url ?? '/';
+
+ event.waitUntil(
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
+ for (const client of windowClients) {
+ if (client.url === targetUrl && 'focus' in client) {
+ return client.focus();
+ }
+ }
+ if (clients.openWindow) {
+ return clients.openWindow(targetUrl);
+ }
+ })
+ );
+});
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 908e11b..0a3b583 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -40,4 +40,16 @@ export const api = {
feedback: {
create: (data) => post('/feedback', data),
},
+ push: {
+ vapidKey: () => get('/push/vapid-public-key'),
+ subscribe: (data) => post('/push/subscribe', data),
+ unsubscribe: async (endpoint) => {
+ const res = await fetch(BASE_URL + '/push/subscribe', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ endpoint }),
+ });
+ if (!res.ok && res.status !== 204) throw new Error(`API error ${res.status}`);
+ },
+ },
};
diff --git a/frontend/src/components/SpotPanel/SpotPanel.css b/frontend/src/components/SpotPanel/SpotPanel.css
index de42181..0b33d44 100644
--- a/frontend/src/components/SpotPanel/SpotPanel.css
+++ b/frontend/src/components/SpotPanel/SpotPanel.css
@@ -121,3 +121,47 @@
padding-top: 14px;
border-top: 1px solid #334155;
}
+
+/* Push notifications */
+.spot-panel__push {
+ border-top: 1px solid #334155;
+ padding-top: 12px;
+ text-align: center;
+}
+.spot-panel__push-btn {
+ width: 100%;
+ padding: 9px 16px;
+ border: 1px solid #475569;
+ border-radius: 8px;
+ background: #0f172a;
+ color: #94a3b8;
+ font-size: 13px;
+ cursor: pointer;
+ transition: background .15s, border-color .15s;
+}
+.spot-panel__push-btn:hover:not(:disabled) { background: #1e293b; border-color: #64748b; color: #f1f5f9; }
+.spot-panel__push-btn--active { border-color: #0ea5e9; color: #38bdf8; }
+.spot-panel__push-btn:disabled { opacity: .5; cursor: default; }
+.spot-panel__push-error { color: #f87171; font-size: 12px; margin-top: 6px; }
+
+/* ── Mobile ── */
+@media (max-width: 480px) {
+ .spot-panel {
+ bottom: 0;
+ left: 0;
+ right: 0;
+ transform: none;
+ width: 100%;
+ border-radius: 16px 16px 0 0;
+ max-height: 70vh;
+ overflow-y: auto;
+ }
+
+ .spot-panel__feedback-btns {
+ flex-direction: column;
+ }
+
+ .spot-panel__details {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend/src/components/SpotPanel/SpotPanel.jsx b/frontend/src/components/SpotPanel/SpotPanel.jsx
index eba03a2..b026344 100644
--- a/frontend/src/components/SpotPanel/SpotPanel.jsx
+++ b/frontend/src/components/SpotPanel/SpotPanel.jsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
+import usePushSubscription from '../../hooks/usePushSubscription';
import './SpotPanel.css';
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
@@ -89,6 +90,7 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
{error}
} +