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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user