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:
Gwadaking
2026-04-01 02:50:06 -04:00
parent bf64340525
commit 4af5e62465
7 changed files with 289 additions and 13 deletions

View 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);
}
}

View 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);
}
}

View 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,
]);
}
}

View File

@@ -6,6 +6,7 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\CoastalPointRepository;
use Symfony\Component\Serializer\Annotation\Groups;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
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: ['region'], name: 'idx_coastal_region')]
#[ApiResource(
shortName: 'Spot',
operations: [
new GetCollection(),
new Get(),
]
],
normalizationContext: ['groups' => ['spot:read']],
order: ['name' => 'ASC'],
)]
class CoastalPoint
{
@@ -30,15 +34,19 @@ class CoastalPoint
private ?Uuid $id = null;
#[ORM\Column(length: 150)]
#[Groups(['spot:read'])]
private ?string $name = null;
#[ORM\Column(type: 'geometry', options: ['geometry_type' => 'POINT', 'srid' => 4326])]
#[Groups(['spot:read'])]
private mixed $geometry = null;
#[ORM\Column(length: 30)]
#[Groups(['spot:read'])]
private ?string $type = null;
#[ORM\Column(length: 100)]
#[Groups(['spot:read'])]
private ?string $region = null;
#[ORM\OneToMany(targetEntity: ImpactScore::class, mappedBy: 'coastalPoint')]

View File

@@ -14,6 +14,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Index(columns: ['valid_at'], name: 'idx_forecast_valid_at')]
#[ORM\Index(columns: ['time_horizon'], name: 'idx_forecast_horizon')]
#[ApiResource(
shortName: 'Forecast',
operations: [
new GetCollection(),
new Get(),

View File

@@ -2,9 +2,6 @@
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\SargassumObservationRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -16,12 +13,6 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: SargassumObservationRepository::class)]
#[ORM\Index(columns: ['detected_at'], name: 'idx_observation_detected_at')]
#[ORM\Index(columns: ['source'], name: 'idx_observation_source')]
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
]
)]
class SargassumObservation
{
#[ORM\Id]