Phase 0 : entités, migrations, API Platform, PostGIS

- API Platform + Doctrine ORM + jsor/doctrine-postgis installés
- 6 entités Symfony (SargassumObservation, SargassumForecast,
  DataIngestionJob, CoastalPoint, ImpactScore, UserFeedback)
- Migration initiale manuelle avec CREATE EXTENSION postgis,
  toutes les tables, index GIST et BTREE
- doctrine.yaml configuré pour PostgreSQL 16 + types PostGIS
- Roadmap Phase 0 complète

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-01 02:37:27 -04:00
parent 34e9675350
commit e5e729c487
36 changed files with 7102 additions and 23 deletions

0
backend/src/ApiResource/.gitignore vendored Normal file
View File

0
backend/src/Entity/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\CoastalPointRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: CoastalPointRepository::class)]
#[ORM\Index(columns: ['type'], name: 'idx_coastal_type')]
#[ORM\Index(columns: ['region'], name: 'idx_coastal_region')]
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
]
)]
class CoastalPoint
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?Uuid $id = null;
#[ORM\Column(length: 150)]
private ?string $name = null;
#[ORM\Column(type: 'geometry', options: ['geometry_type' => 'POINT', 'srid' => 4326])]
private mixed $geometry = null;
#[ORM\Column(length: 30)]
private ?string $type = null;
#[ORM\Column(length: 100)]
private ?string $region = null;
#[ORM\OneToMany(targetEntity: ImpactScore::class, mappedBy: 'coastalPoint')]
private Collection $impactScores;
#[ORM\OneToMany(targetEntity: UserFeedback::class, mappedBy: 'coastalPoint')]
private Collection $feedbacks;
public function __construct()
{
$this->impactScores = new ArrayCollection();
$this->feedbacks = new ArrayCollection();
}
public function getId(): ?Uuid { return $this->id; }
public function getName(): ?string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; }
public function getGeometry(): mixed { return $this->geometry; }
public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; }
public function getType(): ?string { return $this->type; }
public function setType(string $type): static { $this->type = $type; return $this; }
public function getRegion(): ?string { return $this->region; }
public function setRegion(string $region): static { $this->region = $region; return $this; }
/** @return Collection<int, ImpactScore> */
public function getImpactScores(): Collection { return $this->impactScores; }
/** @return Collection<int, UserFeedback> */
public function getFeedbacks(): Collection { return $this->feedbacks; }
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Entity;
use App\Repository\DataIngestionJobRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: DataIngestionJobRepository::class)]
#[ORM\Index(columns: ['status'], name: 'idx_job_status')]
#[ORM\Index(columns: ['requested_at'], name: 'idx_job_requested_at')]
class DataIngestionJob
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?Uuid $id = null;
#[ORM\Column(length: 50)]
private ?string $source = null;
#[ORM\Column(length: 100)]
private ?string $tileId = null;
#[ORM\Column]
private ?\DateTimeImmutable $requestedAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $processedAt = null;
#[ORM\Column(length: 20)]
private string $status = 'pending';
#[ORM\Column]
private int $retryCount = 0;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $errorMessage = null;
public function __construct()
{
$this->requestedAt = new \DateTimeImmutable();
}
public function getId(): ?Uuid { return $this->id; }
public function getSource(): ?string { return $this->source; }
public function setSource(string $source): static { $this->source = $source; return $this; }
public function getTileId(): ?string { return $this->tileId; }
public function setTileId(string $tileId): static { $this->tileId = $tileId; return $this; }
public function getRequestedAt(): ?\DateTimeImmutable { return $this->requestedAt; }
public function getProcessedAt(): ?\DateTimeImmutable { return $this->processedAt; }
public function setProcessedAt(?\DateTimeImmutable $processedAt): static { $this->processedAt = $processedAt; return $this; }
public function getStatus(): string { return $this->status; }
public function setStatus(string $status): static { $this->status = $status; return $this; }
public function getRetryCount(): int { return $this->retryCount; }
public function incrementRetry(): static { $this->retryCount++; return $this; }
public function getErrorMessage(): ?string { return $this->errorMessage; }
public function setErrorMessage(?string $errorMessage): static { $this->errorMessage = $errorMessage; return $this; }
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\ImpactScoreRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ImpactScoreRepository::class)]
#[ORM\Index(columns: ['timestamp'], name: 'idx_score_timestamp')]
#[ORM\Index(columns: ['level'], name: 'idx_score_level')]
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
]
)]
class ImpactScore
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?Uuid $id = null;
#[ORM\ManyToOne(inversedBy: 'impactScores')]
#[ORM\JoinColumn(nullable: false)]
private ?CoastalPoint $coastalPoint = null;
#[ORM\Column]
private ?\DateTimeImmutable $timestamp = null;
#[ORM\Column]
private ?int $score = null;
#[ORM\Column(length: 20)]
private ?string $level = null;
#[ORM\Column(nullable: true)]
private ?float $distanceToNearestSargassum = null;
#[ORM\Column(nullable: true)]
private ?float $densityEstimate = null;
#[ORM\Column(length: 20, nullable: true)]
private ?string $trend = null;
public function __construct()
{
$this->timestamp = new \DateTimeImmutable();
}
public function getId(): ?Uuid { return $this->id; }
public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; }
public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; }
public function getTimestamp(): ?\DateTimeImmutable { return $this->timestamp; }
public function setTimestamp(\DateTimeImmutable $timestamp): static { $this->timestamp = $timestamp; return $this; }
public function getScore(): ?int { return $this->score; }
public function setScore(int $score): static { $this->score = $score; return $this; }
public function getLevel(): ?string { return $this->level; }
public function setLevel(string $level): static { $this->level = $level; return $this; }
public function getDistanceToNearestSargassum(): ?float { return $this->distanceToNearestSargassum; }
public function setDistanceToNearestSargassum(?float $d): static { $this->distanceToNearestSargassum = $d; return $this; }
public function getDensityEstimate(): ?float { return $this->densityEstimate; }
public function setDensityEstimate(?float $d): static { $this->densityEstimate = $d; return $this; }
public function getTrend(): ?string { return $this->trend; }
public function setTrend(?string $trend): static { $this->trend = $trend; return $this; }
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\SargassumForecastRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: SargassumForecastRepository::class)]
#[ORM\Index(columns: ['valid_at'], name: 'idx_forecast_valid_at')]
#[ORM\Index(columns: ['time_horizon'], name: 'idx_forecast_horizon')]
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
]
)]
class SargassumForecast
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?Uuid $id = null;
#[ORM\ManyToOne(inversedBy: 'forecasts')]
#[ORM\JoinColumn(nullable: false)]
private ?SargassumObservation $sourceObservation = null;
#[ORM\Column(type: 'geometry', options: ['geometry_type' => 'MULTIPOLYGON', 'srid' => 4326])]
private mixed $geometry = null;
#[ORM\Column]
private ?\DateTimeImmutable $validAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $computedAt = null;
#[ORM\Column(length: 50)]
private ?string $modelVersion = null;
#[ORM\Column]
private ?int $timeHorizon = null;
#[ORM\Column]
private ?float $confidence = null;
#[ORM\Column(type: 'geometry', nullable: true, options: ['geometry_type' => 'POINT', 'srid' => 4326])]
private mixed $driftVectorAvg = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->computedAt = new \DateTimeImmutable();
}
public function getId(): ?Uuid { return $this->id; }
public function getSourceObservation(): ?SargassumObservation { return $this->sourceObservation; }
public function setSourceObservation(?SargassumObservation $sourceObservation): static { $this->sourceObservation = $sourceObservation; return $this; }
public function getGeometry(): mixed { return $this->geometry; }
public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; }
public function getValidAt(): ?\DateTimeImmutable { return $this->validAt; }
public function setValidAt(\DateTimeImmutable $validAt): static { $this->validAt = $validAt; return $this; }
public function getComputedAt(): ?\DateTimeImmutable { return $this->computedAt; }
public function setComputedAt(\DateTimeImmutable $computedAt): static { $this->computedAt = $computedAt; return $this; }
public function getModelVersion(): ?string { return $this->modelVersion; }
public function setModelVersion(string $modelVersion): static { $this->modelVersion = $modelVersion; return $this; }
public function getTimeHorizon(): ?int { return $this->timeHorizon; }
public function setTimeHorizon(int $timeHorizon): static { $this->timeHorizon = $timeHorizon; return $this; }
public function getConfidence(): ?float { return $this->confidence; }
public function setConfidence(float $confidence): static { $this->confidence = $confidence; return $this; }
public function getDriftVectorAvg(): mixed { return $this->driftVectorAvg; }
public function setDriftVectorAvg(mixed $driftVectorAvg): static { $this->driftVectorAvg = $driftVectorAvg; return $this; }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
}

View File

@@ -0,0 +1,105 @@
<?php
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;
use Doctrine\ORM\Mapping as ORM;
use Jsor\Doctrine\PostGIS\Types\PostGISType;
use Symfony\Bridge\Doctrine\Types\UuidType;
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]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?Uuid $id = null;
#[ORM\Column(type: 'geometry', options: ['geometry_type' => 'MULTIPOLYGON', 'srid' => 4326])]
private mixed $geometry = null;
#[ORM\Column(type: 'geometry', nullable: true, options: ['geometry_type' => 'POLYGON', 'srid' => 4326])]
private mixed $bbox = null;
#[ORM\Column(immutable: true)]
private ?\DateTimeImmutable $detectedAt = null;
#[ORM\Column(length: 50)]
private ?string $source = null;
#[ORM\Column(length: 100)]
private ?string $tileId = null;
#[ORM\Column]
private ?float $cloudCoverage = null;
#[ORM\Column]
private ?float $confidence = null;
#[ORM\Column]
private ?float $afaiMean = null;
#[ORM\Column]
private ?float $afaiStd = null;
#[ORM\Column(nullable: true)]
private ?float $coverageArea = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $processingVersion = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\OneToMany(targetEntity: SargassumForecast::class, mappedBy: 'sourceObservation')]
private Collection $forecasts;
public function __construct()
{
$this->forecasts = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?Uuid { return $this->id; }
public function getGeometry(): mixed { return $this->geometry; }
public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; }
public function getBbox(): mixed { return $this->bbox; }
public function setBbox(mixed $bbox): static { $this->bbox = $bbox; return $this; }
public function getDetectedAt(): ?\DateTimeImmutable { return $this->detectedAt; }
public function setDetectedAt(\DateTimeImmutable $detectedAt): static { $this->detectedAt = $detectedAt; return $this; }
public function getSource(): ?string { return $this->source; }
public function setSource(string $source): static { $this->source = $source; return $this; }
public function getTileId(): ?string { return $this->tileId; }
public function setTileId(string $tileId): static { $this->tileId = $tileId; return $this; }
public function getCloudCoverage(): ?float { return $this->cloudCoverage; }
public function setCloudCoverage(float $cloudCoverage): static { $this->cloudCoverage = $cloudCoverage; return $this; }
public function getConfidence(): ?float { return $this->confidence; }
public function setConfidence(float $confidence): static { $this->confidence = $confidence; return $this; }
public function getAfaiMean(): ?float { return $this->afaiMean; }
public function setAfaiMean(float $afaiMean): static { $this->afaiMean = $afaiMean; return $this; }
public function getAfaiStd(): ?float { return $this->afaiStd; }
public function setAfaiStd(float $afaiStd): static { $this->afaiStd = $afaiStd; return $this; }
public function getCoverageArea(): ?float { return $this->coverageArea; }
public function setCoverageArea(?float $coverageArea): static { $this->coverageArea = $coverageArea; return $this; }
public function getProcessingVersion(): ?string { return $this->processingVersion; }
public function setProcessingVersion(?string $processingVersion): static { $this->processingVersion = $processingVersion; return $this; }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
/** @return Collection<int, SargassumForecast> */
public function getForecasts(): Collection { return $this->forecasts; }
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Entity;
use App\Repository\UserFeedbackRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserFeedbackRepository::class)]
#[ORM\Index(columns: ['timestamp'], name: 'idx_feedback_timestamp')]
class UserFeedback
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?Uuid $id = null;
#[ORM\Column(type: 'geometry', options: ['geometry_type' => 'POINT', 'srid' => 4326])]
private mixed $location = null;
#[ORM\ManyToOne(inversedBy: 'feedbacks')]
#[ORM\JoinColumn(nullable: true)]
private ?CoastalPoint $coastalPoint = null;
#[ORM\Column]
private ?\DateTimeImmutable $timestamp = null;
#[ORM\Column]
private ?bool $hasSeaweed = null;
#[ORM\Column(length: 20, nullable: true)]
private ?string $density = null;
#[ORM\Column(length: 100, nullable: true)]
private ?string $source = null;
public function __construct()
{
$this->timestamp = new \DateTimeImmutable();
}
public function getId(): ?Uuid { return $this->id; }
public function getLocation(): mixed { return $this->location; }
public function setLocation(mixed $location): static { $this->location = $location; return $this; }
public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; }
public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; }
public function getTimestamp(): ?\DateTimeImmutable { return $this->timestamp; }
public function setTimestamp(\DateTimeImmutable $timestamp): static { $this->timestamp = $timestamp; return $this; }
public function getHasSeaweed(): ?bool { return $this->hasSeaweed; }
public function setHasSeaweed(bool $hasSeaweed): static { $this->hasSeaweed = $hasSeaweed; return $this; }
public function getDensity(): ?string { return $this->density; }
public function setDensity(?string $density): static { $this->density = $density; return $this; }
public function getSource(): ?string { return $this->source; }
public function setSource(?string $source): static { $this->source = $source; return $this; }
}

0
backend/src/Repository/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Repository;
use App\Entity\CoastalPoint;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class CoastalPointRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CoastalPoint::class);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Repository;
use App\Entity\DataIngestionJob;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class DataIngestionJobRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, DataIngestionJob::class);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Repository;
use App\Entity\ImpactScore;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ImpactScoreRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ImpactScore::class);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Repository;
use App\Entity\SargassumForecast;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SargassumForecastRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SargassumForecast::class);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Repository;
use App\Entity\SargassumObservation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SargassumObservationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SargassumObservation::class);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Repository;
use App\Entity\UserFeedback;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class UserFeedbackRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserFeedback::class);
}
}