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:
19
backend/config/packages/rate_limiter.yaml
Normal file
19
backend/config/packages/rate_limiter.yaml
Normal file
@@ -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 }
|
||||||
39
backend/migrations/Version20260401000001.php
Normal file
39
backend/migrations/Version20260401000001.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260401000001 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Ajout table push_subscription';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/src/Command/GenerateVapidKeysCommand.php
Normal file
34
backend/src/Command/GenerateVapidKeysCommand.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use Minishlink\WebPush\VAPID;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:generate-vapid-keys',
|
||||||
|
description: 'Génère les clés VAPID pour les notifications push',
|
||||||
|
)]
|
||||||
|
class GenerateVapidKeysCommand extends Command
|
||||||
|
{
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$keys = VAPID::createVapidKeys();
|
||||||
|
|
||||||
|
$io->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ class IngestSentinelCommand extends Command
|
|||||||
->addOption('zone', 'z', InputOption::VALUE_OPTIONAL,
|
->addOption('zone', 'z', InputOption::VALUE_OPTIONAL,
|
||||||
'Zone spécifique. Défaut : toutes les zones.', null)
|
'Zone spécifique. Défaut : toutes les zones.', null)
|
||||||
->addOption('source', 's', InputOption::VALUE_OPTIONAL,
|
->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
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
@@ -71,7 +71,15 @@ class IngestSentinelCommand extends Command
|
|||||||
foreach ($zones as $name => $bbox) {
|
foreach ($zones as $name => $bbox) {
|
||||||
$io->section("Zone : {$name}");
|
$io->section("Zone : {$name}");
|
||||||
try {
|
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}");
|
$io->success("OK — {$name}");
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$io->error("ÉCHEC — {$name} : " . $e->getMessage());
|
$io->error("ÉCHEC — {$name} : " . $e->getMessage());
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
#[Route('/api/feedback', name: 'api_feedback_')]
|
#[Route('/api/feedback', name: 'api_feedback_')]
|
||||||
class FeedbackController extends AbstractController
|
class FeedbackController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private EntityManagerInterface $em) {}
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private RateLimiterFactory $apiFeedbackLimiter,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/feedback
|
* POST /api/feedback
|
||||||
@@ -30,6 +34,11 @@ class FeedbackController extends AbstractController
|
|||||||
#[Route('', name: 'create', methods: ['POST'])]
|
#[Route('', name: 'create', methods: ['POST'])]
|
||||||
public function create(Request $request): JsonResponse
|
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);
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
|
|||||||
99
backend/src/Controller/PushController.php
Normal file
99
backend/src/Controller/PushController.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\CoastalPoint;
|
||||||
|
use App\Entity\PushSubscription;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/api/push', name: 'api_push_')]
|
||||||
|
class PushController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private RateLimiterFactory $apiPushLimiter,
|
||||||
|
#[Autowire('%env(VAPID_PUBLIC_KEY)%')] private string $vapidPublicKey,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/push/vapid-public-key
|
||||||
|
* Retourne la clé publique VAPID pour l'abonnement push côté client.
|
||||||
|
*/
|
||||||
|
#[Route('/vapid-public-key', name: 'vapid_key', methods: ['GET'])]
|
||||||
|
public function vapidKey(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/Entity/PushSubscription.php
Normal file
51
backend/src/Entity/PushSubscription.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\PushSubscriptionRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: PushSubscriptionRepository::class)]
|
||||||
|
#[ORM\Index(columns: ['coastal_point_id'], name: 'idx_push_coastal_point')]
|
||||||
|
class PushSubscription
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||||
|
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||||
|
private ?Uuid $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', unique: true)]
|
||||||
|
private ?string $endpoint = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text')]
|
||||||
|
private ?string $authToken = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text')]
|
||||||
|
private ?string $p256dhKey = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?CoastalPoint $coastalPoint = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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; }
|
||||||
|
}
|
||||||
15
backend/src/Repository/PushSubscriptionRepository.php
Normal file
15
backend/src/Repository/PushSubscriptionRepository.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\PushSubscription;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
class PushSubscriptionRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, PushSubscription::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Entity\CoastalPoint;
|
|||||||
use App\Entity\ImpactScore;
|
use App\Entity\ImpactScore;
|
||||||
use App\Entity\SargassumForecast;
|
use App\Entity\SargassumForecast;
|
||||||
use App\Entity\SargassumObservation;
|
use App\Entity\SargassumObservation;
|
||||||
|
use App\Service\Push\PushNotificationService;
|
||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -31,11 +32,18 @@ class ImpactScoreService
|
|||||||
private const MAX_APPROACH_KMH = 5; // 5 km/h = vitesse d'approche max
|
private const MAX_APPROACH_KMH = 5; // 5 km/h = vitesse d'approche max
|
||||||
private const CACHE_TTL = 10_800; // 3h
|
private const CACHE_TTL = 10_800; // 3h
|
||||||
|
|
||||||
|
// Bonus feedback : +8 pts si présence confirmée dans les 6h et dans rayon 20km
|
||||||
|
private const FEEDBACK_RADIUS_M = 20_000;
|
||||||
|
private const FEEDBACK_WINDOW_H = 6;
|
||||||
|
private const FEEDBACK_BONUS = 8;
|
||||||
|
private const HIGH_SCORE_THRESHOLD = 70;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Connection $connection,
|
private Connection $connection,
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private CacheInterface $cache,
|
private CacheInterface $cache,
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
|
private PushNotificationService $push,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +69,11 @@ class ImpactScoreService
|
|||||||
$score = $this->computeScore($spot, $obsId, $forecastH6);
|
$score = $this->computeScore($spot, $obsId, $forecastH6);
|
||||||
$this->persistScore($spot, $score);
|
$this->persistScore($spot, $score);
|
||||||
$this->invalidateCache($spotId);
|
$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) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('Score computation failed', [
|
$this->logger->error('Score computation failed', [
|
||||||
'spot' => $spot->getName(),
|
'spot' => $spot->getName(),
|
||||||
@@ -124,7 +137,10 @@ class ImpactScoreService
|
|||||||
$velocityScore = min(max($approachKmh, 0.0) / self::MAX_APPROACH_KMH, 1.0) * 20;
|
$velocityScore = min(max($approachKmh, 0.0) / self::MAX_APPROACH_KMH, 1.0) * 20;
|
||||||
$trendScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 };
|
$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));
|
$total = max(0, min(100, $total));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -254,6 +270,27 @@ class ImpactScoreService
|
|||||||
return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable'];
|
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
|
private function findForecastByHorizon(array $forecasts, int $horizon): ?SargassumForecast
|
||||||
{
|
{
|
||||||
foreach ($forecasts as $f) {
|
foreach ($forecasts as $f) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\Ingestion;
|
||||||
|
|
||||||
|
class HighCloudCoverageException extends \RuntimeException {}
|
||||||
@@ -26,8 +26,26 @@ class IngestionService
|
|||||||
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
#[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 = new DataIngestionJob();
|
||||||
$job->setSource($source);
|
$job->setSource($source);
|
||||||
$job->setTileId(sprintf('[%s]', implode(',', $bbox)));
|
$job->setTileId(sprintf('[%s]', implode(',', $bbox)));
|
||||||
@@ -38,14 +56,16 @@ class IngestionService
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Étape 1 : vérification couverture nuageuse via Catalog API
|
// Étape 1 : vérification couverture nuageuse via Catalog API
|
||||||
$cloudCoverage = $this->sentinelHub->getCloudCoverage($bbox, $date);
|
$cloudCoverage = $this->sentinelHub->getCloudCoverage($bbox, $date, $collection);
|
||||||
$this->logger->info('Cloud coverage check', ['bbox' => $bbox, 'coverage' => $cloudCoverage]);
|
$this->logger->info('Cloud coverage check', ['bbox' => $bbox, 'coverage' => $cloudCoverage, 'collection' => $collection]);
|
||||||
|
|
||||||
if ($cloudCoverage > self::CLOUD_THRESHOLD) {
|
if ($cloudCoverage > self::CLOUD_THRESHOLD) {
|
||||||
$job->setStatus('failed');
|
$job->setStatus('failed');
|
||||||
$job->setErrorMessage("Couverture nuageuse {$cloudCoverage}% > seuil " . self::CLOUD_THRESHOLD . '%');
|
$job->setErrorMessage("Couverture nuageuse {$cloudCoverage}% > seuil " . self::CLOUD_THRESHOLD . '%');
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
return;
|
throw new HighCloudCoverageException(
|
||||||
|
"Cloud coverage {$cloudCoverage}% exceeds threshold for collection {$collection}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Étape 2 : téléchargement du raster AFAI binaire
|
// Étape 2 : téléchargement du raster AFAI binaire
|
||||||
@@ -54,7 +74,7 @@ class IngestionService
|
|||||||
$tifPath = $tmpDir . '/afai.tif';
|
$tifPath = $tmpDir . '/afai.tif';
|
||||||
$geojsonPath = $tmpDir . '/polygons.geojson';
|
$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']]);
|
$this->logger->info('GeoTIFF downloaded', ['path' => $tifPath, 'tile' => $metadata['tileId']]);
|
||||||
|
|
||||||
// Étape 3 : vectorisation via GDAL
|
// Étape 3 : vectorisation via GDAL
|
||||||
|
|||||||
91
backend/src/Service/Push/PushNotificationService.php
Normal file
91
backend/src/Service/Push/PushNotificationService.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\Push;
|
||||||
|
|
||||||
|
use App\Entity\CoastalPoint;
|
||||||
|
use App\Entity\PushSubscription;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Minishlink\WebPush\Subscription;
|
||||||
|
use Minishlink\WebPush\WebPush;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
class PushNotificationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
#[Autowire('%env(VAPID_PUBLIC_KEY)%')] private string $vapidPublicKey,
|
||||||
|
#[Autowire('%env(VAPID_PRIVATE_KEY)%')] private string $vapidPrivateKey,
|
||||||
|
#[Autowire('%env(APP_PUBLIC_URL)%')] private string $appPublicUrl,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie une alerte push à tous les abonnés d'un CoastalPoint.
|
||||||
|
*/
|
||||||
|
public function notifySpot(CoastalPoint $spot, int $score, string $level): void
|
||||||
|
{
|
||||||
|
$subscriptions = $this->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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,11 +43,17 @@ class SentinelHubClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retourne la couverture nuageuse (%) de la meilleure scène disponible
|
* Retourne la couverture nuageuse (%) de la meilleure scène disponible.
|
||||||
* pour le bbox et la date donnés.
|
* 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();
|
$token = $this->getToken();
|
||||||
$dateStr = $date->format('Y-m-d');
|
$dateStr = $date->format('Y-m-d');
|
||||||
|
|
||||||
@@ -56,7 +62,7 @@ class SentinelHubClient
|
|||||||
'json' => [
|
'json' => [
|
||||||
'bbox' => $bbox,
|
'bbox' => $bbox,
|
||||||
'datetime' => "{$dateStr}T00:00:00Z/{$dateStr}T23:59:59Z",
|
'datetime' => "{$dateStr}T00:00:00Z/{$dateStr}T23:59:59Z",
|
||||||
'collections' => ['sentinel-2-l2a'],
|
'collections' => [$collection],
|
||||||
'limit' => 1,
|
'limit' => 1,
|
||||||
'sortby' => [['field' => 'eo:cloud_cover', 'direction' => 'asc']],
|
'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)
|
* Télécharge un raster UINT8 binaire (1=sargasse, 0=non, 255=nuage/nodata)
|
||||||
* calculé via evalscript AFAI, et le sauvegarde dans $outputPath.
|
* calculé via evalscript AFAI/FAI, et le sauvegarde dans $outputPath.
|
||||||
* Retourne les métadonnées de la scène.
|
* 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();
|
$token = $this->getToken();
|
||||||
$dateStr = $date->format('Y-m-d');
|
$dateStr = $date->format('Y-m-d');
|
||||||
|
|
||||||
|
$evalscript = $collection === 'sentinel-3-olci'
|
||||||
|
? $this->getFaiBinaryEvalscriptS3()
|
||||||
|
: $this->getAfaiBinaryEvalscript();
|
||||||
|
|
||||||
$response = $this->httpClient->request('POST', self::PROCESS_URL, [
|
$response = $this->httpClient->request('POST', self::PROCESS_URL, [
|
||||||
'headers' => ['Authorization' => "Bearer {$token}"],
|
'headers' => ['Authorization' => "Bearer {$token}"],
|
||||||
'json' => [
|
'json' => [
|
||||||
@@ -90,7 +104,7 @@ class SentinelHubClient
|
|||||||
'properties' => ['crs' => 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'],
|
'properties' => ['crs' => 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'],
|
||||||
],
|
],
|
||||||
'data' => [[
|
'data' => [[
|
||||||
'type' => 'sentinel-2-l2a',
|
'type' => $collection,
|
||||||
'dataFilter' => [
|
'dataFilter' => [
|
||||||
'timeRange' => [
|
'timeRange' => [
|
||||||
'from' => "{$dateStr}T00:00:00Z",
|
'from' => "{$dateStr}T00:00:00Z",
|
||||||
@@ -107,7 +121,7 @@ class SentinelHubClient
|
|||||||
'format' => ['type' => 'image/tiff'],
|
'format' => ['type' => 'image/tiff'],
|
||||||
]],
|
]],
|
||||||
],
|
],
|
||||||
'evalscript' => $this->getAfaiBinaryEvalscript(),
|
'evalscript' => $evalscript,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -117,7 +131,7 @@ class SentinelHubClient
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'tileId' => $headers['x-process-request-id'][0] ?? uniqid('tile_'),
|
'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,
|
'afaiStd' => 0.0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -126,7 +140,6 @@ class SentinelHubClient
|
|||||||
{
|
{
|
||||||
// Longueurs d'onde Sentinel-2 (nm) — constantes capteur
|
// Longueurs d'onde Sentinel-2 (nm) — constantes capteur
|
||||||
// B04 (Red) : 664.5 | B08 (NIR) : 832.8 | B11 (SWIR1) : 1613.7
|
// B04 (Red) : 664.5 | B08 (NIR) : 832.8 | B11 (SWIR1) : 1613.7
|
||||||
// ratio = (λ_NIR - λ_RED) / (λ_SWIR1 - λ_RED)
|
|
||||||
return <<<'EVALSCRIPT'
|
return <<<'EVALSCRIPT'
|
||||||
//VERSION=3
|
//VERSION=3
|
||||||
function setup() {
|
function setup() {
|
||||||
@@ -146,7 +159,40 @@ function evaluatePixel(sample) {
|
|||||||
|
|
||||||
const afai = sample.B08 - sample.B04 - (sample.B11 - sample.B04) * ratio;
|
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;
|
EVALSCRIPT;
|
||||||
}
|
}
|
||||||
|
|||||||
42
deploy/post-receive.sh
Normal file
42
deploy/post-receive.sh
Normal file
@@ -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é."
|
||||||
@@ -6,20 +6,26 @@
|
|||||||
:80 {
|
:80 {
|
||||||
root * /app/public
|
root * /app/public
|
||||||
|
|
||||||
# Assets Symfony
|
# Assets Symfony (webpack encore)
|
||||||
handle /bundles/* {
|
handle /bundles/* {
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA React (fichiers statiques buildés dans public/spa/)
|
# Service worker (must be served from root scope)
|
||||||
handle /spa/* {
|
handle /sw.js {
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|
||||||
# Tout le reste → Symfony (front controller index.php)
|
# API → Symfony front controller
|
||||||
handle {
|
handle /api/* {
|
||||||
php_server
|
php_server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# SPA React : try static files first, fallback to index.html
|
||||||
|
handle {
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
encode gzip
|
encode gzip
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
- [x] Initialisation dépôt Git (bare repo VPS + hook post-receive)
|
- [x] Initialisation dépôt Git (bare repo VPS + hook post-receive)
|
||||||
- [x] Configuration Docker (FrankenPHP + PostgreSQL/PostGIS + Redis)
|
- [x] Configuration Docker (FrankenPHP + PostgreSQL/PostGIS + Redis)
|
||||||
- [x] Configuration Traefik (domaine radarsargasses971.com, SSL Let's Encrypt)
|
- [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] Initialisation projet Symfony (`symfony new backend`)
|
||||||
- [x] Installation API Platform
|
- [x] Installation API Platform
|
||||||
- [x] Initialisation projet React (`npm create vite@latest frontend`)
|
- [x] Initialisation projet React (`npm create vite@latest frontend`)
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
- [x] Migration : `CoastalPoint`
|
- [x] Migration : `CoastalPoint`
|
||||||
- [x] Migration : `ImpactScore`
|
- [x] Migration : `ImpactScore`
|
||||||
- [x] Migration : `UserFeedback`
|
- [x] Migration : `UserFeedback`
|
||||||
|
- [x] Migration : `PushSubscription`
|
||||||
- [x] Index spatiaux (GIST) + index BTREE définis
|
- [x] Index spatiaux (GIST) + index BTREE définis
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
- [x] Marqueurs CoastalPoints avec sélection
|
- [x] Marqueurs CoastalPoints avec sélection
|
||||||
- [x] SpotPanel : Spot Mode complet (score, niveau, distance, trend)
|
- [x] SpotPanel : Spot Mode complet (score, niveau, distance, trend)
|
||||||
- [x] Bouton feedback terrain (confirmation/infirmation anonyme)
|
- [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
|
### Données initiales
|
||||||
|
|
||||||
@@ -110,7 +111,6 @@
|
|||||||
- [x] Mise à jour dynamique polygones + score sur slider
|
- [x] Mise à jour dynamique polygones + score sur slider
|
||||||
- [x] Couche forecasts (violet pointillé) vs observations (orange plein)
|
- [x] Couche forecasts (violet pointillé) vs observations (orange plein)
|
||||||
- [ ] Gradients radiaux densité — différé
|
- [ ] Gradients radiaux densité — différé
|
||||||
- [ ] Vue mobile optimisée — différé
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,24 +118,32 @@
|
|||||||
|
|
||||||
### Boucle d'apprentissage
|
### Boucle d'apprentissage
|
||||||
|
|
||||||
- [ ] Bouton "Je confirme présence de sargasses"
|
- [x] Bouton "Je confirme présence de sargasses"
|
||||||
- [ ] `POST /api/feedback` — persistance `UserFeedback` anonyme
|
- [x] `POST /api/feedback` — persistance `UserFeedback` anonyme
|
||||||
- [ ] Intégration feedback dans calcul score (pondération)
|
- [x] Intégration feedback dans calcul score (bonus +8 pts si présence confirmée < 6h dans rayon 20 km)
|
||||||
|
|
||||||
### Alertes push
|
### Alertes push
|
||||||
|
|
||||||
- [ ] Définir modalité d'inscription légère (push token navigateur ou email)
|
- [x] Commande `app:generate-vapid-keys` (génération clés VAPID)
|
||||||
- [ ] Système d'abonnement par `CoastalPoint`
|
- [x] `GET /api/push/vapid-public-key` — clé publique VAPID
|
||||||
- [ ] Worker Symfony — envoi alertes si score dépasse seuil
|
- [x] `POST /api/push/subscribe` — enregistrement abonnement push
|
||||||
- [ ] Interface d'inscription (friction minimale)
|
- [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)
|
## 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)
|
- [ ] Monitoring pipeline (alertes si ingestion échoue > N fois)
|
||||||
- [ ] Logs structurés
|
- [ ] Logs structurés (Monolog JSON handler)
|
||||||
- [ ] Fallback Sentinel-2 → Sentinel-3
|
|
||||||
- [ ] Configuration Cloudflare free tier (cache tiles statiques)
|
- [ ] Configuration Cloudflare free tier (cache tiles statiques)
|
||||||
- [ ] Tests fonctionnels pipeline (ingestion → score)
|
- [ ] Tests fonctionnels pipeline (ingestion → score)
|
||||||
|
|||||||
42
frontend/public/sw.js
Normal file
42
frontend/public/sw.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -40,4 +40,16 @@ export const api = {
|
|||||||
feedback: {
|
feedback: {
|
||||||
create: (data) => post('/feedback', data),
|
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}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -121,3 +121,47 @@
|
|||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
border-top: 1px solid #334155;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
|
import usePushSubscription from '../../hooks/usePushSubscription';
|
||||||
import './SpotPanel.css';
|
import './SpotPanel.css';
|
||||||
|
|
||||||
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
|
const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' };
|
||||||
@@ -89,6 +90,7 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FeedbackButton spot={spot} />
|
<FeedbackButton spot={spot} />
|
||||||
|
<PushButton spotId={spot.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -123,3 +125,23 @@ function FeedbackButton({ spot }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PushButton({ spotId }) {
|
||||||
|
const { supported, subscribed, loading, error, toggle } = usePushSubscription(spotId);
|
||||||
|
|
||||||
|
if (!supported) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="spot-panel__push">
|
||||||
|
<button
|
||||||
|
className={`spot-panel__push-btn ${subscribed ? 'spot-panel__push-btn--active' : ''}`}
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={loading}
|
||||||
|
aria-pressed={subscribed}
|
||||||
|
>
|
||||||
|
{loading ? '…' : subscribed ? '🔔 Alertes activées' : '🔕 Activer les alertes'}
|
||||||
|
</button>
|
||||||
|
{error && <p className="spot-panel__push-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,3 +29,16 @@
|
|||||||
background: #0ea5e9;
|
background: #0ea5e9;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.timeline {
|
||||||
|
top: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.timeline__step {
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
70
frontend/src/hooks/usePushSubscription.js
Normal file
70
frontend/src/hooks/usePushSubscription.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: '../backend/public/spa',
|
outDir: '../backend/public',
|
||||||
emptyOutDir: true,
|
emptyOutDir: false, // ne pas supprimer index.php, .htaccess Symfony, etc.
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user