Phase 1 : endpoints API complets
- CoastalPoint → shortName Spot → /api/spots (API Platform)
- SargassumForecast → shortName Forecast → /api/forecasts
- ObservationController : GET /api/observations avec filtres
bbox (PostGIS ST_Intersects), date, source + geometry ST_AsGeoJSON
- SpotScoreController : GET /api/spots/{id}/score avec ?at= optionnel
- FeedbackController : POST /api/feedback, anonyme, fingerprint SHA256
- CORS : autorisation radarsargasses971.com + localhost dev
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
79
backend/src/Controller/FeedbackController.php
Normal file
79
backend/src/Controller/FeedbackController.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\CoastalPoint;
|
||||||
|
use App\Entity\UserFeedback;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/api/feedback', name: 'api_feedback_')]
|
||||||
|
class FeedbackController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private EntityManagerInterface $em) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/feedback
|
||||||
|
*
|
||||||
|
* Corps attendu :
|
||||||
|
* {
|
||||||
|
* "lat": 14.65,
|
||||||
|
* "lng": -61.05,
|
||||||
|
* "hasSeaweed": true,
|
||||||
|
* "density": "medium", // optionnel : low|medium|high
|
||||||
|
* "coastalPointId": "uuid" // optionnel
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
#[Route('', name: 'create', methods: ['POST'])]
|
||||||
|
public function create(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return $this->json(['error' => 'Invalid JSON body'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation minimale
|
||||||
|
if (!isset($data['lat'], $data['lng'], $data['hasSeaweed'])) {
|
||||||
|
return $this->json(['error' => 'Missing required fields: lat, lng, hasSeaweed'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lat = filter_var($data['lat'], FILTER_VALIDATE_FLOAT);
|
||||||
|
$lng = filter_var($data['lng'], FILTER_VALIDATE_FLOAT);
|
||||||
|
|
||||||
|
if ($lat === false || $lng === false) {
|
||||||
|
return $this->json(['error' => 'lat and lng must be valid floats'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180) {
|
||||||
|
return $this->json(['error' => 'lat/lng out of valid range'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$feedback = new UserFeedback();
|
||||||
|
$feedback->setLocation(sprintf('SRID=4326;POINT(%f %f)', $lng, $lat));
|
||||||
|
$feedback->setHasSeaweed((bool) $data['hasSeaweed']);
|
||||||
|
|
||||||
|
if (isset($data['density']) && in_array($data['density'], ['low', 'medium', 'high'], true)) {
|
||||||
|
$feedback->setDensity($data['density']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprint anonyme : IP hachée (pas de données personnelles)
|
||||||
|
$ip = $request->getClientIp() ?? 'unknown';
|
||||||
|
$feedback->setSource(hash('sha256', $ip . date('Y-m-d')));
|
||||||
|
|
||||||
|
if (!empty($data['coastalPointId'])) {
|
||||||
|
$spot = $this->em->getRepository(CoastalPoint::class)->find($data['coastalPointId']);
|
||||||
|
if ($spot !== null) {
|
||||||
|
$feedback->setCoastalPoint($spot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($feedback);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->json(['id' => (string) $feedback->getId()], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
backend/src/Controller/ObservationController.php
Normal file
126
backend/src/Controller/ObservationController.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/api/observations', name: 'api_observations_')]
|
||||||
|
class ObservationController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private Connection $connection) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/observations
|
||||||
|
*
|
||||||
|
* Paramètres optionnels :
|
||||||
|
* ?bbox=minLon,minLat,maxLon,maxLat filtre spatial PostGIS
|
||||||
|
* ?date=YYYY-MM-DD filtre sur detected_at
|
||||||
|
* ?source=Sentinel-2 filtre sur source
|
||||||
|
*/
|
||||||
|
#[Route('', name: 'collection', methods: ['GET'])]
|
||||||
|
public function collection(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$sql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
detected_at AS "detectedAt",
|
||||||
|
source,
|
||||||
|
tile_id AS "tileId",
|
||||||
|
cloud_coverage AS "cloudCoverage",
|
||||||
|
confidence,
|
||||||
|
afai_mean AS "afaiMean",
|
||||||
|
afai_std AS "afaiStd",
|
||||||
|
coverage_area AS "coverageArea",
|
||||||
|
processing_version AS "processingVersion",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
ST_AsGeoJSON(geometry)::json AS geometry,
|
||||||
|
ST_AsGeoJSON(bbox)::json AS bbox
|
||||||
|
FROM sargassum_observation
|
||||||
|
WHERE 1=1
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$params = [];
|
||||||
|
$types = [];
|
||||||
|
|
||||||
|
// Filtre bbox
|
||||||
|
$bboxParam = $request->query->get('bbox');
|
||||||
|
if ($bboxParam !== null) {
|
||||||
|
$parts = array_map('floatval', explode(',', $bboxParam));
|
||||||
|
if (count($parts) === 4) {
|
||||||
|
[$minLon, $minLat, $maxLon, $maxLat] = $parts;
|
||||||
|
$sql .= ' AND ST_Intersects(geometry, ST_MakeEnvelope(:minLon, :minLat, :maxLon, :maxLat, 4326))';
|
||||||
|
$params['minLon'] = $minLon;
|
||||||
|
$params['minLat'] = $minLat;
|
||||||
|
$params['maxLon'] = $maxLon;
|
||||||
|
$params['maxLat'] = $maxLat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre date
|
||||||
|
$dateParam = $request->query->get('date');
|
||||||
|
if ($dateParam !== null) {
|
||||||
|
$sql .= ' AND detected_at::date = :date::date';
|
||||||
|
$params['date'] = $dateParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre source
|
||||||
|
$sourceParam = $request->query->get('source');
|
||||||
|
if ($sourceParam !== null) {
|
||||||
|
$sql .= ' AND source = :source';
|
||||||
|
$params['source'] = $sourceParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY detected_at DESC LIMIT 100';
|
||||||
|
|
||||||
|
$rows = $this->connection->fetchAllAssociative($sql, $params, $types);
|
||||||
|
|
||||||
|
// Décoder les champs JSON imbriqués
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$row['geometry'] = $row['geometry'] ? json_decode($row['geometry'], true) : null;
|
||||||
|
$row['bbox'] = $row['bbox'] ? json_decode($row['bbox'], true) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json(['items' => $rows, 'total' => count($rows)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/observations/{id}
|
||||||
|
*/
|
||||||
|
#[Route('/{id}', name: 'item', methods: ['GET'])]
|
||||||
|
public function item(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$sql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
detected_at AS "detectedAt",
|
||||||
|
source,
|
||||||
|
tile_id AS "tileId",
|
||||||
|
cloud_coverage AS "cloudCoverage",
|
||||||
|
confidence,
|
||||||
|
afai_mean AS "afaiMean",
|
||||||
|
afai_std AS "afaiStd",
|
||||||
|
coverage_area AS "coverageArea",
|
||||||
|
processing_version AS "processingVersion",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
ST_AsGeoJSON(geometry)::json AS geometry,
|
||||||
|
ST_AsGeoJSON(bbox)::json AS bbox
|
||||||
|
FROM sargassum_observation
|
||||||
|
WHERE id = :id
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$row = $this->connection->fetchAssociative($sql, ['id' => $id]);
|
||||||
|
|
||||||
|
if ($row === false) {
|
||||||
|
return $this->json(['error' => 'Observation not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['geometry'] = $row['geometry'] ? json_decode($row['geometry'], true) : null;
|
||||||
|
$row['bbox'] = $row['bbox'] ? json_decode($row['bbox'], true) : null;
|
||||||
|
|
||||||
|
return $this->json($row);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
backend/src/Controller/SpotScoreController.php
Normal file
67
backend/src/Controller/SpotScoreController.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\CoastalPoint;
|
||||||
|
use App\Entity\ImpactScore;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/api/spots', name: 'api_spots_')]
|
||||||
|
class SpotScoreController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private EntityManagerInterface $em) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/spots/{id}/score
|
||||||
|
*
|
||||||
|
* Retourne le score courant pour un CoastalPoint.
|
||||||
|
* Paramètre optionnel : ?at=YYYY-MM-DDTHH:MM:SSZ (score à un instant donné)
|
||||||
|
*/
|
||||||
|
#[Route('/{id}/score', name: 'score', methods: ['GET'])]
|
||||||
|
public function score(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$spot = $this->em->getRepository(CoastalPoint::class)->find($id);
|
||||||
|
|
||||||
|
if ($spot === null) {
|
||||||
|
return $this->json(['error' => 'Spot not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb = $this->em->getRepository(ImpactScore::class)
|
||||||
|
->createQueryBuilder('s')
|
||||||
|
->where('s.coastalPoint = :spot')
|
||||||
|
->setParameter('spot', $spot)
|
||||||
|
->orderBy('s.timestamp', 'DESC')
|
||||||
|
->setMaxResults(1);
|
||||||
|
|
||||||
|
$atParam = $request->query->get('at');
|
||||||
|
if ($atParam !== null) {
|
||||||
|
try {
|
||||||
|
$at = new \DateTimeImmutable($atParam);
|
||||||
|
$qb->andWhere('s.timestamp <= :at')->setParameter('at', $at);
|
||||||
|
} catch (\Exception) {
|
||||||
|
return $this->json(['error' => 'Invalid "at" parameter format'], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = $qb->getQuery()->getOneOrNullResult();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'spotId' => (string) $spot->getId(),
|
||||||
|
'name' => $spot->getName(),
|
||||||
|
'type' => $spot->getType(),
|
||||||
|
'region' => $spot->getRegion(),
|
||||||
|
'score' => $latest ? [
|
||||||
|
'value' => $latest->getScore(),
|
||||||
|
'level' => $latest->getLevel(),
|
||||||
|
'distance' => $latest->getDistanceToNearestSargassum(),
|
||||||
|
'density' => $latest->getDensityEstimate(),
|
||||||
|
'trend' => $latest->getTrend(),
|
||||||
|
'computedAt' => $latest->getTimestamp()?->format(\DateTimeInterface::ATOM),
|
||||||
|
] : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use ApiPlatform\Metadata\ApiResource;
|
|||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use App\Repository\CoastalPointRepository;
|
use App\Repository\CoastalPointRepository;
|
||||||
|
use Symfony\Component\Serializer\Annotation\Groups;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
@@ -16,10 +17,13 @@ use Symfony\Component\Uid\Uuid;
|
|||||||
#[ORM\Index(columns: ['type'], name: 'idx_coastal_type')]
|
#[ORM\Index(columns: ['type'], name: 'idx_coastal_type')]
|
||||||
#[ORM\Index(columns: ['region'], name: 'idx_coastal_region')]
|
#[ORM\Index(columns: ['region'], name: 'idx_coastal_region')]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
|
shortName: 'Spot',
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(),
|
new GetCollection(),
|
||||||
new Get(),
|
new Get(),
|
||||||
]
|
],
|
||||||
|
normalizationContext: ['groups' => ['spot:read']],
|
||||||
|
order: ['name' => 'ASC'],
|
||||||
)]
|
)]
|
||||||
class CoastalPoint
|
class CoastalPoint
|
||||||
{
|
{
|
||||||
@@ -30,15 +34,19 @@ class CoastalPoint
|
|||||||
private ?Uuid $id = null;
|
private ?Uuid $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 150)]
|
#[ORM\Column(length: 150)]
|
||||||
|
#[Groups(['spot:read'])]
|
||||||
private ?string $name = null;
|
private ?string $name = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'geometry', options: ['geometry_type' => 'POINT', 'srid' => 4326])]
|
#[ORM\Column(type: 'geometry', options: ['geometry_type' => 'POINT', 'srid' => 4326])]
|
||||||
|
#[Groups(['spot:read'])]
|
||||||
private mixed $geometry = null;
|
private mixed $geometry = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
|
#[Groups(['spot:read'])]
|
||||||
private ?string $type = null;
|
private ?string $type = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
#[ORM\Column(length: 100)]
|
||||||
|
#[Groups(['spot:read'])]
|
||||||
private ?string $region = null;
|
private ?string $region = null;
|
||||||
|
|
||||||
#[ORM\OneToMany(targetEntity: ImpactScore::class, mappedBy: 'coastalPoint')]
|
#[ORM\OneToMany(targetEntity: ImpactScore::class, mappedBy: 'coastalPoint')]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Symfony\Component\Uid\Uuid;
|
|||||||
#[ORM\Index(columns: ['valid_at'], name: 'idx_forecast_valid_at')]
|
#[ORM\Index(columns: ['valid_at'], name: 'idx_forecast_valid_at')]
|
||||||
#[ORM\Index(columns: ['time_horizon'], name: 'idx_forecast_horizon')]
|
#[ORM\Index(columns: ['time_horizon'], name: 'idx_forecast_horizon')]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
|
shortName: 'Forecast',
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(),
|
new GetCollection(),
|
||||||
new Get(),
|
new Get(),
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use App\Repository\SargassumObservationRepository;
|
use App\Repository\SargassumObservationRepository;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
@@ -16,12 +13,6 @@ use Symfony\Component\Uid\Uuid;
|
|||||||
#[ORM\Entity(repositoryClass: SargassumObservationRepository::class)]
|
#[ORM\Entity(repositoryClass: SargassumObservationRepository::class)]
|
||||||
#[ORM\Index(columns: ['detected_at'], name: 'idx_observation_detected_at')]
|
#[ORM\Index(columns: ['detected_at'], name: 'idx_observation_detected_at')]
|
||||||
#[ORM\Index(columns: ['source'], name: 'idx_observation_source')]
|
#[ORM\Index(columns: ['source'], name: 'idx_observation_source')]
|
||||||
#[ApiResource(
|
|
||||||
operations: [
|
|
||||||
new GetCollection(),
|
|
||||||
new Get(),
|
|
||||||
]
|
|
||||||
)]
|
|
||||||
class SargassumObservation
|
class SargassumObservation
|
||||||
{
|
{
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
|
|||||||
@@ -56,9 +56,13 @@
|
|||||||
|
|
||||||
### API Backend
|
### API Backend
|
||||||
|
|
||||||
- [ ] `GET /api/spots` — liste CoastalPoints
|
- [x] `GET /api/spots` — liste CoastalPoints (API Platform)
|
||||||
- [ ] `GET /api/spots/{id}` — détail
|
- [x] `GET /api/spots/{id}` — détail (API Platform)
|
||||||
- [ ] `GET /api/observations?bbox=&date=` — observations par zone
|
- [x] `GET /api/spots/{id}/score` — score + niveau + trend
|
||||||
|
- [x] `GET /api/observations?bbox=&date=&source=` — PostGIS ST_Intersects
|
||||||
|
- [x] `GET /api/observations/{id}` — détail avec geometry GeoJSON
|
||||||
|
- [x] `GET /api/forecasts` — API Platform
|
||||||
|
- [x] `POST /api/feedback` — feedback anonyme (IP hachée)
|
||||||
|
|
||||||
### Frontend React
|
### Frontend React
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user