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