From 2cef3725e97cede91d833f300e204347915c20a2 Mon Sep 17 00:00:00 2001 From: Gwadaking Date: Wed, 1 Apr 2026 03:43:56 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20complete=20=E2=80=94=20push?= =?UTF-8?q?=20alerts,=20feedback=20scoring,=20S3=20fallback,=20SPA=20routi?= =?UTF-8?q?ng,=20mobile=20CSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/config/packages/rate_limiter.yaml | 19 ++++ backend/migrations/Version20260401000001.php | 39 ++++++++ .../src/Command/GenerateVapidKeysCommand.php | 34 +++++++ backend/src/Command/IngestSentinelCommand.php | 12 ++- backend/src/Controller/FeedbackController.php | 11 ++- backend/src/Controller/PushController.php | 99 +++++++++++++++++++ backend/src/Entity/PushSubscription.php | 51 ++++++++++ .../Repository/PushSubscriptionRepository.php | 15 +++ .../Service/Forecast/ImpactScoreService.php | 39 +++++++- .../Ingestion/HighCloudCoverageException.php | 5 + .../Service/Ingestion/IngestionService.php | 30 +++++- .../Service/Push/PushNotificationService.php | 91 +++++++++++++++++ .../Service/SentinelHub/SentinelHubClient.php | 72 +++++++++++--- deploy/post-receive.sh | 42 ++++++++ docker/php/Caddyfile | 16 ++- docs/roadmap.md | 34 ++++--- frontend/public/sw.js | 42 ++++++++ frontend/src/api/client.js | 12 +++ .../src/components/SpotPanel/SpotPanel.css | 44 +++++++++ .../src/components/SpotPanel/SpotPanel.jsx | 22 +++++ .../components/Timeline/TimelineSlider.css | 13 +++ frontend/src/hooks/usePushSubscription.js | 70 +++++++++++++ frontend/src/main.jsx | 6 ++ frontend/vite.config.js | 4 +- 24 files changed, 780 insertions(+), 42 deletions(-) create mode 100644 backend/config/packages/rate_limiter.yaml create mode 100644 backend/migrations/Version20260401000001.php create mode 100644 backend/src/Command/GenerateVapidKeysCommand.php create mode 100644 backend/src/Controller/PushController.php create mode 100644 backend/src/Entity/PushSubscription.php create mode 100644 backend/src/Repository/PushSubscriptionRepository.php create mode 100644 backend/src/Service/Ingestion/HighCloudCoverageException.php create mode 100644 backend/src/Service/Push/PushNotificationService.php create mode 100644 deploy/post-receive.sh create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/hooks/usePushSubscription.js 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 }) { + ); } @@ -123,3 +125,23 @@ function FeedbackButton({ spot }) { ); } + +function PushButton({ spotId }) { + const { supported, subscribed, loading, error, toggle } = usePushSubscription(spotId); + + if (!supported) return null; + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/components/Timeline/TimelineSlider.css b/frontend/src/components/Timeline/TimelineSlider.css index 8ef6d35..34d06bc 100644 --- a/frontend/src/components/Timeline/TimelineSlider.css +++ b/frontend/src/components/Timeline/TimelineSlider.css @@ -29,3 +29,16 @@ background: #0ea5e9; color: #fff; } + +/* ── Mobile ── */ +@media (max-width: 480px) { + .timeline { + top: 8px; + padding: 3px; + gap: 2px; + } + .timeline__step { + padding: 5px 9px; + font-size: 12px; + } +} diff --git a/frontend/src/hooks/usePushSubscription.js b/frontend/src/hooks/usePushSubscription.js new file mode 100644 index 0000000..f5c263e --- /dev/null +++ b/frontend/src/hooks/usePushSubscription.js @@ -0,0 +1,70 @@ +import { useState, useEffect } from 'react'; +import { api } from '../api/client'; + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = window.atob(base64); + return Uint8Array.from([...raw].map((c) => c.charCodeAt(0))); +} + +/** + * Hook managing the Web Push subscription lifecycle for a given coastalPointId. + * Returns { supported, subscribed, loading, error, toggle } + */ +export default function usePushSubscription(coastalPointId) { + const [supported, setSupported] = useState(false); + const [subscribed, setSubscribed] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setSupported('serviceWorker' in navigator && 'PushManager' in window); + }, []); + + useEffect(() => { + if (!supported) return; + + navigator.serviceWorker.ready.then((reg) => + reg.pushManager.getSubscription() + ).then((sub) => { + setSubscribed(sub !== null); + }).catch(() => {}); + }, [supported]); + + const toggle = async () => { + if (!supported || loading) return; + setLoading(true); + setError(null); + + try { + const reg = await navigator.serviceWorker.ready; + const existingSub = await reg.pushManager.getSubscription(); + + if (existingSub) { + await api.push.unsubscribe(existingSub.endpoint).catch(() => {}); + await existingSub.unsubscribe(); + setSubscribed(false); + } else { + const { publicKey } = await api.push.vapidKey(); + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + const json = sub.toJSON(); + await api.push.subscribe({ + endpoint: json.endpoint, + keys: json.keys, + coastalPointId, + }); + setSubscribed(true); + } + } catch (e) { + setError(e.message ?? 'Erreur abonnement push'); + } finally { + setLoading(false); + } + }; + + return { supported, subscribed, loading, error, toggle }; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index b9a1a6d..351be61 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.jsx' +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + }); +} + createRoot(document.getElementById('root')).render( diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 4ecb9cd..c644082 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -13,7 +13,7 @@ export default defineConfig({ }, }, build: { - outDir: '../backend/public/spa', - emptyOutDir: true, + outDir: '../backend/public', + emptyOutDir: false, // ne pas supprimer index.php, .htaccess Symfony, etc. }, });