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

View File

@@ -0,0 +1,7 @@
services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
###< doctrine/doctrine-bundle ###

25
backend/compose.yaml Normal file
View File

@@ -0,0 +1,25 @@
services:
###> doctrine/doctrine-bundle ###
database:
image: postgres:${POSTGRES_VERSION:-16}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
# You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
POSTGRES_USER: ${POSTGRES_USER:-app}
healthcheck:
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/postgresql/data:rw
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###
volumes:
###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ###

View File

@@ -7,15 +7,31 @@
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/doctrine-orm": "^4.3",
"api-platform/symfony": "^4.3",
"doctrine/doctrine-bundle": "^2.18",
"doctrine/doctrine-migrations-bundle": "^3.7",
"doctrine/orm": "^3.6",
"jsor/doctrine-postgis": "^2.4",
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.4.*",
"symfony/console": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/uid": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/yaml": "7.4.*"
},
"require-dev": {
},
"config": {
"allow-plugins": {
"php-http/discovery": true,

5106
backend/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,4 +2,10 @@
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,7 @@
api_platform:
title: Hello API Platform
version: 1.0.0
defaults:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']

View File

@@ -0,0 +1,53 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
types:
geometry: Jsor\Doctrine\PostGIS\Types\GeometryType
geography: Jsor\Doctrine\PostGIS\Types\GeographyType
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
controller_resolver:
auto_mapping: false
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@@ -0,0 +1,10 @@
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null

View File

@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View File

@@ -0,0 +1,39 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
main:
lazy: true
provider: users_in_memory
# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Note: Only the *first* matching rule is applied
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test:
security:
password_hashers:
# Password hashers are resource-intensive by design to ensure security.
# In tests, it's safe to reduce their cost to improve performance.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /api

View File

@@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

0
backend/migrations/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260401000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Schéma initial — toutes les entités + extension PostGIS + index spatiaux';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE EXTENSION IF NOT EXISTS postgis');
// SargassumObservation
$this->addSql('
CREATE TABLE sargassum_observation (
id UUID NOT NULL DEFAULT gen_random_uuid(),
geometry geometry(MULTIPOLYGON, 4326) NOT NULL,
bbox geometry(POLYGON, 4326),
detected_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
source VARCHAR(50) NOT NULL,
tile_id VARCHAR(100) NOT NULL,
cloud_coverage DOUBLE PRECISION NOT NULL,
confidence DOUBLE PRECISION NOT NULL,
afai_mean DOUBLE PRECISION NOT NULL,
afai_std DOUBLE PRECISION NOT NULL,
coverage_area DOUBLE PRECISION,
processing_version VARCHAR(50),
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(id)
)
');
$this->addSql('CREATE INDEX CONCURRENTLY idx_observation_detected_at ON sargassum_observation (detected_at)');
$this->addSql('CREATE INDEX CONCURRENTLY idx_observation_source ON sargassum_observation (source)');
$this->addSql('CREATE INDEX CONCURRENTLY idx_observation_geometry ON sargassum_observation USING GIST (geometry)');
$this->addSql('CREATE INDEX CONCURRENTLY idx_observation_bbox ON sargassum_observation USING GIST (bbox)');
// SargassumForecast
$this->addSql('
CREATE TABLE sargassum_forecast (
id UUID NOT NULL DEFAULT gen_random_uuid(),
source_observation_id UUID NOT NULL,
geometry geometry(MULTIPOLYGON, 4326) NOT NULL,
valid_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
computed_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
model_version VARCHAR(50) NOT NULL,
time_horizon INT NOT NULL,
confidence DOUBLE PRECISION NOT NULL,
drift_vector_avg geometry(POINT, 4326),
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(id)
)
');
$this->addSql('ALTER TABLE sargassum_forecast ADD CONSTRAINT fk_forecast_observation FOREIGN KEY (source_observation_id) REFERENCES sargassum_observation (id) ON DELETE CASCADE');
$this->addSql('CREATE INDEX CONCURRENTLY idx_forecast_valid_at ON sargassum_forecast (valid_at)');
$this->addSql('CREATE INDEX CONCURRENTLY idx_forecast_horizon ON sargassum_forecast (time_horizon)');
$this->addSql('CREATE INDEX CONCURRENTLY idx_forecast_geometry ON sargassum_forecast USING GIST (geometry)');
// DataIngestionJob
$this->addSql('
CREATE TABLE data_ingestion_job (
id UUID NOT NULL DEFAULT gen_random_uuid(),
source VARCHAR(50) NOT NULL,
tile_id VARCHAR(100) NOT NULL,
requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
processed_at TIMESTAMP(0) WITHOUT TIME ZONE,
status VARCHAR(20) NOT NULL DEFAULT \'pending\',
retry_count INT NOT NULL DEFAULT 0,
error_message TEXT,
PRIMARY KEY(id)
)
');
$this->addSql('CREATE INDEX CONCURRENTLY idx_job_status ON data_ingestion_job (status)');
$this->addSql('CREATE INDEX CONCURRENTLY idx_job_requested_at ON data_ingestion_job (requested_at)');
// CoastalPoint
$this->addSql('
CREATE TABLE coastal_point (
id UUID NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(150) NOT NULL,
geometry geometry(POINT, 4326) NOT NULL,
type VARCHAR(30) NOT NULL,
region VARCHAR(100) NOT NULL,
PRIMARY KEY(id)
)
');
$this->addSql('CREATE INDEX CONCURRENTLY idx_coastal_type ON coastal_point (type)');
$this->addSql('CREATE INDEX CONCURRENTLY idx_coastal_region ON coastal_point (region)');
$this->addSql('CREATE INDEX CONCURRENTLY idx_coastal_geometry ON coastal_point USING GIST (geometry)');
// ImpactScore
$this->addSql('
CREATE TABLE impact_score (
id UUID NOT NULL DEFAULT gen_random_uuid(),
coastal_point_id UUID NOT NULL,
timestamp TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
score INT NOT NULL,
level VARCHAR(20) NOT NULL,
distance_to_nearest_sargassum DOUBLE PRECISION,
density_estimate DOUBLE PRECISION,
trend VARCHAR(20),
PRIMARY KEY(id)
)
');
$this->addSql('ALTER TABLE impact_score ADD CONSTRAINT fk_score_coastal_point FOREIGN KEY (coastal_point_id) REFERENCES coastal_point (id) ON DELETE CASCADE');
$this->addSql('CREATE INDEX CONCURRENTLY idx_score_timestamp ON impact_score (timestamp)');
$this->addSql('CREATE INDEX CONCURRENTLY idx_score_level ON impact_score (level)');
// UserFeedback
$this->addSql('
CREATE TABLE user_feedback (
id UUID NOT NULL DEFAULT gen_random_uuid(),
coastal_point_id UUID,
location geometry(POINT, 4326) NOT NULL,
timestamp TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
has_seaweed BOOLEAN NOT NULL,
density VARCHAR(20),
source VARCHAR(100),
PRIMARY KEY(id)
)
');
$this->addSql('ALTER TABLE user_feedback ADD CONSTRAINT fk_feedback_coastal_point FOREIGN KEY (coastal_point_id) REFERENCES coastal_point (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX CONCURRENTLY idx_feedback_timestamp ON user_feedback (timestamp)');
$this->addSql('CREATE INDEX CONCURRENTLY idx_feedback_location ON user_feedback USING GIST (location)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sargassum_forecast DROP CONSTRAINT fk_forecast_observation');
$this->addSql('ALTER TABLE impact_score DROP CONSTRAINT fk_score_coastal_point');
$this->addSql('ALTER TABLE user_feedback DROP CONSTRAINT fk_feedback_coastal_point');
$this->addSql('DROP TABLE IF EXISTS user_feedback');
$this->addSql('DROP TABLE IF EXISTS impact_score');
$this->addSql('DROP TABLE IF EXISTS sargassum_forecast');
$this->addSql('DROP TABLE IF EXISTS sargassum_observation');
$this->addSql('DROP TABLE IF EXISTS coastal_point');
$this->addSql('DROP TABLE IF EXISTS data_ingestion_job');
}
}

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

View File

@@ -1,4 +1,75 @@
{
"api-platform/symfony": {
"version": "4.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.0",
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
},
"files": [
"config/packages/api_platform.yaml",
"config/routes/api_platform.yaml",
"src/ApiResource/.gitignore"
]
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
}
},
"doctrine/doctrine-bundle": {
"version": "2.18",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.7",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"jsor/doctrine-postgis": {
"version": "2.4",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.7",
"ref": "211979f7917bf6edb8fc5006a17b2e84e8e84d50"
}
},
"nelmio/cors-bundle": {
"version": "2.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.5",
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
},
"files": [
"config/packages/nelmio_cors.yaml"
]
},
"symfony/console": {
"version": "7.4",
"recipe": {
@@ -44,6 +115,18 @@
".editorconfig"
]
},
"symfony/property-info": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.4",
"recipe": {
@@ -56,5 +139,52 @@
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "f250159ebe99153d0c640a3e7742876fc7453f2c"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/validator": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
}
}

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
{% endblock %}
{% block javascripts %}
{% endblock %}
{% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %}
{% if frankenphpHotReload %}
<meta name="frankenphp-hot-reload:url" content="{{ frankenphpHotReload }}">
<script src="https://cdn.jsdelivr.net/npm/idiomorph"></script>
<script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script>
{% endif %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>