diff --git a/backend/src/Controller/FeedbackController.php b/backend/src/Controller/FeedbackController.php new file mode 100644 index 0000000..fab3199 --- /dev/null +++ b/backend/src/Controller/FeedbackController.php @@ -0,0 +1,79 @@ +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); + } +} diff --git a/backend/src/Controller/ObservationController.php b/backend/src/Controller/ObservationController.php new file mode 100644 index 0000000..41c5623 --- /dev/null +++ b/backend/src/Controller/ObservationController.php @@ -0,0 +1,126 @@ +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 = <<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); + } +} diff --git a/backend/src/Controller/SpotScoreController.php b/backend/src/Controller/SpotScoreController.php new file mode 100644 index 0000000..ece6077 --- /dev/null +++ b/backend/src/Controller/SpotScoreController.php @@ -0,0 +1,67 @@ +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, + ]); + } +} diff --git a/backend/src/Entity/CoastalPoint.php b/backend/src/Entity/CoastalPoint.php index d33ebee..b106cdc 100644 --- a/backend/src/Entity/CoastalPoint.php +++ b/backend/src/Entity/CoastalPoint.php @@ -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')] diff --git a/backend/src/Entity/SargassumForecast.php b/backend/src/Entity/SargassumForecast.php index 52b844d..97f5058 100644 --- a/backend/src/Entity/SargassumForecast.php +++ b/backend/src/Entity/SargassumForecast.php @@ -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(), diff --git a/backend/src/Entity/SargassumObservation.php b/backend/src/Entity/SargassumObservation.php index 7e95a28..18b0214 100644 --- a/backend/src/Entity/SargassumObservation.php +++ b/backend/src/Entity/SargassumObservation.php @@ -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] diff --git a/docs/roadmap.md b/docs/roadmap.md index 63fccbb..f6e5d7f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -56,9 +56,13 @@ ### API Backend -- [ ] `GET /api/spots` — liste CoastalPoints -- [ ] `GET /api/spots/{id}` — détail -- [ ] `GET /api/observations?bbox=&date=` — observations par zone +- [x] `GET /api/spots` — liste CoastalPoints (API Platform) +- [x] `GET /api/spots/{id}` — détail (API Platform) +- [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