From 1e812c93a65c95ab35ef4334186cbd44721c25c0 Mon Sep 17 00:00:00 2001 From: bernard-ng Date: Sat, 11 Oct 2025 20:09:05 +0200 Subject: [PATCH] [backend] stabilise codebase --- .gitignore | 1 + .vscode/settings.json | 3 + README.md | 10 +- client/src/types/api.ts | 2 +- server/.env | 2 +- server/compose.override.yaml | 3 + server/compose.yaml | 15 ++ server/composer.json | 4 +- server/composer.lock | 234 +++++++++++++++++- server/config/packages/doctrine.yaml | 7 +- .../packages/jsor_doctrine_postgis.yaml | 16 ++ server/migrations/Version20240919000000.php | 41 ++- server/migrations/Version20251010102708.php | 30 ++- server/package-lock.json | 6 + server/src/Controller/.gitignore | 0 server/src/Controller/SignalController.php | 13 +- server/src/DataFixtures/AppFixtures.php | 17 -- server/src/DataFixtures/SignalFixtures.php | 14 +- server/src/Entity/.gitignore | 0 server/src/Entity/Identifier/SignalId.php | 11 + server/src/Entity/Signal.php | 86 ++----- .../src/Exception/InvalidPointException.php | 7 +- .../Exception/MissingClientKeyException.php | 2 +- server/src/Exception/PointTooFarException.php | 2 +- .../SubmissionRateLimitedException.php | 10 +- server/src/Message/SignalCreatedMessage.php | 6 +- .../SignalCreatedMessageHandler.php | 36 ++- server/src/Repository/.gitignore | 0 server/src/Service/ClientKeyProvider.php | 4 +- .../src/Service/PointProximityValidator.php | 8 +- server/src/Service/SignalSnapshotBuilder.php | 26 +- server/src/Service/SignalSnapshotService.php | 10 +- .../src/Service/SignalSubmissionService.php | 40 ++- server/src/ValueObject/Distance.php | 8 +- server/symfony.lock | 21 ++ .../tests/Unit/ValueObject/DistanceTest.php | 4 +- 36 files changed, 487 insertions(+), 212 deletions(-) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 server/config/packages/jsor_doctrine_postgis.yaml create mode 100644 server/package-lock.json delete mode 100644 server/src/Controller/.gitignore delete mode 100644 server/src/DataFixtures/AppFixtures.php delete mode 100644 server/src/Entity/.gitignore create mode 100644 server/src/Entity/Identifier/SignalId.php delete mode 100644 server/src/Repository/.gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fa5c670 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "php.version": "8.4" +} \ No newline at end of file diff --git a/README.md b/README.md index bfd1049..fb4a9f9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ This repository hosts a proof-of-concept "points of interest" application compos - npm 10+ - PHP 8.2+ - Composer 2+ -- A running database supported by Doctrine (SQLite is used by default for local development) +- Docker (to run the bundled Postgres + PostGIS service) +- PostgreSQL 16 with the PostGIS extension (a ready-to-use container is provided via Docker Compose) ## Running the API server @@ -21,9 +22,13 @@ This repository hosts a proof-of-concept "points of interest" application compos cd server composer install -# Create the database schema (SQLite by default) +# Boot the Postgres + PostGIS container +docker compose up -d postgres + +# Prepare the database schema php bin/console doctrine:database:create --if-not-exists php bin/console doctrine:migrations:migrate --no-interaction +php bin/console doctrine:fixtures:load # Start the Symfony development server symfony server:start @@ -49,4 +54,5 @@ The client starts on `http://localhost:5173`. Set the `VITE_API_BASE`, `VITE_MER ## Additional notes - The API uses Mercure for real-time updates. Ensure a Mercure hub is running and reachable by the client when testing streaming features. +- The default `DATABASE_URL` now targets the bundled PostgreSQL/PostGIS container. Update it if you run the database elsewhere, and rerun migrations after switching databases. - Review the individual `README.md` files inside `client/` and `server/` for more detailed configuration guidance. diff --git a/client/src/types/api.ts b/client/src/types/api.ts index dcca6d5..3c5a6d1 100644 --- a/client/src/types/api.ts +++ b/client/src/types/api.ts @@ -6,7 +6,7 @@ export interface Point { } export interface ApiPoint { - id: number; + id: string; signalLocation: Point; createdAt: string; userKey: string; diff --git a/server/.env b/server/.env index f81eec7..f9a013b 100644 --- a/server/.env +++ b/server/.env @@ -29,7 +29,7 @@ DEFAULT_URI=http://localhost # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml # -DATABASE_URL="sqlite:///%kernel.project_dir%/var/points.sqlite" +DATABASE_URL="postgresql://symfony:symfony@postgres:5432/points_of_interest?serverVersion=16&charset=utf8" ###< doctrine/doctrine-bundle ### ###> nelmio/cors-bundle ### diff --git a/server/compose.override.yaml b/server/compose.override.yaml index 355576e..57092c2 100644 --- a/server/compose.override.yaml +++ b/server/compose.override.yaml @@ -1,5 +1,8 @@ services: + postgres: + ports: + - "5432:5432" ###> symfony/mercure-bundle ### mercure: ports: diff --git a/server/compose.yaml b/server/compose.yaml index 7379e84..988d9f3 100644 --- a/server/compose.yaml +++ b/server/compose.yaml @@ -1,5 +1,19 @@ services: + postgres: + image: postgis/postgis:16-3.4 + restart: unless-stopped + environment: + POSTGRES_DB: points_of_interest + POSTGRES_USER: symfony + POSTGRES_PASSWORD: symfony + healthcheck: + test: ["CMD-SHELL", "pg_isready -U symfony -d points_of_interest"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - postgres_data:/var/lib/postgresql/data ###> symfony/mercure-bundle ### mercure: image: dunglas/mercure @@ -24,6 +38,7 @@ services: ###< symfony/mercure-bundle ### volumes: + postgres_data: ###> symfony/mercure-bundle ### mercure_data: mercure_config: diff --git a/server/composer.json b/server/composer.json index e5389ec..c719566 100644 --- a/server/composer.json +++ b/server/composer.json @@ -4,7 +4,7 @@ "minimum-stability": "stable", "prefer-stable": true, "require": { - "php": ">=8.2", + "php": ">=8.4", "ext-ctype": "*", "ext-iconv": "*", "doctrine/dbal": "^3", @@ -12,6 +12,7 @@ "doctrine/doctrine-fixtures-bundle": "^4.1", "doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/orm": "^3.5", + "jsor/doctrine-postgis": "^2.4", "nelmio/cors-bundle": "^2.5", "sentry/sentry-symfony": "^5.6", "symfony/console": "7.3.*", @@ -27,6 +28,7 @@ "symfony/rate-limiter": "^7.3", "symfony/runtime": "7.3.*", "symfony/serializer": "^7.3", + "symfony/uid": "^7.3", "symfony/validator": "^7.3", "symfony/yaml": "7.3.*" }, diff --git a/server/composer.lock b/server/composer.lock index 7d61f07..edf9993 100644 --- a/server/composer.lock +++ b/server/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6a8621d55b15988cb9bfd8c4d6494281", + "content-hash": "e13aa064b12d472d9d5b8538e4d637c0", "packages": [ { "name": "doctrine/collections", @@ -1474,6 +1474,81 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "jsor/doctrine-postgis", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/jsor/doctrine-postgis.git", + "reference": "0db64e93f18b7d3347beca1f9f0d724212d4ea8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsor/doctrine-postgis/zipball/0db64e93f18b7d3347beca1f9f0d724212d4ea8a", + "reference": "0db64e93f18b7d3347beca1f9f0d724212d4ea8a", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.7 || ^4.0", + "doctrine/deprecations": "^0.5.3 || ^1.0", + "php": "^8.1" + }, + "conflict": { + "doctrine/orm": "<2.18" + }, + "require-dev": { + "doctrine/collections": "^2.0 || ^3.0", + "doctrine/orm": "^2.19 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.13", + "phpunit/phpunit": "^9.6", + "symfony/doctrine-bridge": "^6.4", + "symfony/doctrine-messenger": "^6.4", + "vimeo/psalm": "^6.13" + }, + "suggest": { + "doctrine/orm": "For using with the Doctrine ORM" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jsor\\Doctrine\\PostGIS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com" + } + ], + "description": "Spatial and Geographic Data with PostGIS and Doctrine.", + "keywords": [ + "database", + "dbal", + "doctrine", + "geo", + "geography", + "geometry", + "gis", + "orm", + "postgis", + "spatial" + ], + "support": { + "issues": "https://github.com/jsor/doctrine-postgis/issues", + "source": "https://github.com/jsor/doctrine-postgis/tree/v2.4.0" + }, + "time": "2025-10-09T07:18:57+00:00" + }, { "name": "lcobucci/clock", "version": "2.2.0", @@ -5137,6 +5212,89 @@ ], "time": "2025-06-24T13:30:11+00:00" }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/property-access", "version": "v7.3.3", @@ -6131,6 +6289,80 @@ ], "time": "2025-09-11T15:33:27+00:00" }, + { + "name": "symfony/uid", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, { "name": "symfony/validator", "version": "v7.3.4", diff --git a/server/config/packages/doctrine.yaml b/server/config/packages/doctrine.yaml index f43116d..cc9b0a8 100644 --- a/server/config/packages/doctrine.yaml +++ b/server/config/packages/doctrine.yaml @@ -1,13 +1,12 @@ doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' - - # IMPORTANT: You MUST configure your server version, - # either here or in the DATABASE_URL env var (see .env file) - #server_version: '16' + server_version: '16' profiling_collect_backtrace: '%kernel.debug%' use_savepoints: true + mapping_types: + geography: string orm: auto_generate_proxy_classes: true enable_lazy_ghost_objects: true diff --git a/server/config/packages/jsor_doctrine_postgis.yaml b/server/config/packages/jsor_doctrine_postgis.yaml new file mode 100644 index 0000000..59816ef --- /dev/null +++ b/server/config/packages/jsor_doctrine_postgis.yaml @@ -0,0 +1,16 @@ +services: + Jsor\Doctrine\PostGIS\Event\ORMSchemaEventSubscriber: + tags: [{ name: doctrine.event_subscriber, connection: default }] + +doctrine: + dbal: + mapping_types: + _text: string + types: + geometry: 'Jsor\Doctrine\PostGIS\Types\GeometryType' + geography: 'Jsor\Doctrine\PostGIS\Types\GeographyType' + orm: + dql: + string_functions: + ST_AsGeoJSON: 'Jsor\Doctrine\PostGIS\Functions\ST_AsGeoJSON' + ST_GeomFromGeoJSON: 'Jsor\Doctrine\PostGIS\Functions\ST_GeomFromGeoJSON' diff --git a/server/migrations/Version20240919000000.php b/server/migrations/Version20240919000000.php index 54f4d7a..fe339d2 100644 --- a/server/migrations/Version20240919000000.php +++ b/server/migrations/Version20240919000000.php @@ -16,13 +16,46 @@ final class Version20240919000000 extends AbstractMigration public function up(Schema $schema): void { - $this->addSql('CREATE TABLE signals (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_key VARCHAR(64) NOT NULL, lat DOUBLE PRECISION NOT NULL, lng DOUBLE PRECISION NOT NULL, created_at DATETIME NOT NULL)'); - $this->addSql('CREATE INDEX idx_signals_created_at ON signals (created_at)'); - $this->addSql('CREATE INDEX idx_signals_user_key ON signals (user_key)'); + $platform = $this->connection->getDatabasePlatform()->getName(); + + if ($platform === 'postgresql') { + $this->addSql('CREATE EXTENSION IF NOT EXISTS postgis'); + $this->addSql('CREATE TABLE signals (id UUID NOT NULL, user_key VARCHAR(64) NOT NULL, user_lat DOUBLE PRECISION NOT NULL, user_lng DOUBLE PRECISION NOT NULL, signal_lat DOUBLE PRECISION NOT NULL, signal_lng DOUBLE PRECISION NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX idx_signals_created_at ON signals (created_at)'); + $this->addSql('CREATE INDEX idx_signals_user_key ON signals (user_key)'); + $this->addSql('CREATE INDEX idx_signals_signal_location_gix ON signals USING GIST (ST_SetSRID(ST_MakePoint(signal_lng, signal_lat), 4326))'); + + return; + } + + if ($platform === 'sqlite') { + $this->addSql('CREATE TABLE signals (id CHAR(36) NOT NULL, user_key VARCHAR(64) NOT NULL, user_lat DOUBLE PRECISION NOT NULL, user_lng DOUBLE PRECISION NOT NULL, signal_lat DOUBLE PRECISION NOT NULL, signal_lng DOUBLE PRECISION NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX idx_signals_created_at ON signals (created_at)'); + $this->addSql('CREATE INDEX idx_signals_user_key ON signals (user_key)'); + + return; + } + + $this->abortIf(true, 'Migration can only be executed on postgresql or sqlite.'); } public function down(Schema $schema): void { - $this->addSql('DROP TABLE signals'); + $platform = $this->connection->getDatabasePlatform()->getName(); + + if ($platform === 'postgresql') { + $this->addSql('DROP TABLE signals'); + $this->addSql('DROP EXTENSION IF EXISTS postgis'); + + return; + } + + if ($platform === 'sqlite') { + $this->addSql('DROP TABLE signals'); + + return; + } + + $this->abortIf(true, 'Migration can only be executed on postgresql or sqlite.'); } } diff --git a/server/migrations/Version20251010102708.php b/server/migrations/Version20251010102708.php index cd7aad2..a79d3cc 100644 --- a/server/migrations/Version20251010102708.php +++ b/server/migrations/Version20251010102708.php @@ -16,7 +16,20 @@ final class Version20251010102708 extends AbstractMigration public function up(Schema $schema): void { - $this->addSql('CREATE TABLE __temp__signals (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_key VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL, user_lat DOUBLE PRECISION NOT NULL, user_lng DOUBLE PRECISION NOT NULL, signal_lat DOUBLE PRECISION NOT NULL, signal_lng DOUBLE PRECISION NOT NULL)'); + if ($this->connection->getDatabasePlatform()->getName() !== 'sqlite') { + return; + } + + if (! $schema->hasTable('signals')) { + return; + } + + $table = $schema->getTable('signals'); + if (! $table->hasColumn('lat') || ! $table->hasColumn('lng')) { + return; + } + + $this->addSql('CREATE TABLE __temp__signals (id CHAR(36) NOT NULL, user_key VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL, user_lat DOUBLE PRECISION NOT NULL, user_lng DOUBLE PRECISION NOT NULL, signal_lat DOUBLE PRECISION NOT NULL, signal_lng DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); $this->addSql('INSERT INTO __temp__signals (id, user_key, created_at, user_lat, user_lng, signal_lat, signal_lng) SELECT id, user_key, created_at, lat, lng, lat, lng FROM signals'); $this->addSql('DROP TABLE signals'); $this->addSql('ALTER TABLE __temp__signals RENAME TO signals'); @@ -26,7 +39,20 @@ final class Version20251010102708 extends AbstractMigration public function down(Schema $schema): void { - $this->addSql('CREATE TABLE __temp__signals (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_key VARCHAR(64) NOT NULL, lat DOUBLE PRECISION NOT NULL, lng DOUBLE PRECISION NOT NULL, created_at DATETIME NOT NULL)'); + if ($this->connection->getDatabasePlatform()->getName() !== 'sqlite') { + return; + } + + if (! $schema->hasTable('signals')) { + return; + } + + $table = $schema->getTable('signals'); + if (! $table->hasColumn('user_lat') || ! $table->hasColumn('user_lng')) { + return; + } + + $this->addSql('CREATE TABLE __temp__signals (id CHAR(36) NOT NULL, user_key VARCHAR(64) NOT NULL, lat DOUBLE PRECISION NOT NULL, lng DOUBLE PRECISION NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id))'); $this->addSql('INSERT INTO __temp__signals (id, user_key, lat, lng, created_at) SELECT id, user_key, signal_lat, signal_lng, created_at FROM signals'); $this->addSql('DROP TABLE signals'); $this->addSql('ALTER TABLE __temp__signals RENAME TO signals'); diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..b280e8b --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "server", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/server/src/Controller/.gitignore b/server/src/Controller/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/server/src/Controller/SignalController.php b/server/src/Controller/SignalController.php index c7622fa..604cf97 100644 --- a/server/src/Controller/SignalController.php +++ b/server/src/Controller/SignalController.php @@ -40,19 +40,14 @@ class SignalController $clientKey = $this->clientKeyProvider->fromRequest($request); $signal = $this->submissionService->submit($clientKey, $payload); - $signalLocation = $signal->getSignalLocation(); - return new JsonResponse([ 'status' => 'stored', 'clientKey' => $clientKey, 'point' => [ - 'id' => $signal->getId(), - 'signalLocation' => [ - 'lat' => $signalLocation->getLat(), - 'lng' => $signalLocation->getLng(), - ], - 'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM), - 'userKey' => $signal->getUserKey(), + 'id' => $signal->id->toString(), + 'signalLocation' => $signal->signalLocation, + 'createdAt' => $signal->createdAt->format(DATE_ATOM), + 'userKey' => $signal->userKey, ], ], Response::HTTP_CREATED); } diff --git a/server/src/DataFixtures/AppFixtures.php b/server/src/DataFixtures/AppFixtures.php deleted file mode 100644 index 987f6fe..0000000 --- a/server/src/DataFixtures/AppFixtures.php +++ /dev/null @@ -1,17 +0,0 @@ -persist($product); - - $manager->flush(); - } -} diff --git a/server/src/DataFixtures/SignalFixtures.php b/server/src/DataFixtures/SignalFixtures.php index e416e59..6983561 100644 --- a/server/src/DataFixtures/SignalFixtures.php +++ b/server/src/DataFixtures/SignalFixtures.php @@ -6,9 +6,6 @@ namespace App\DataFixtures; use App\Entity\Signal; use App\ValueObject\Point; -use DateInterval; -use DateTimeImmutable; -use DateTimeZone; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; @@ -16,7 +13,6 @@ class SignalFixtures extends Fixture { public function load(ObjectManager $manager): void { - $baseTime = new DateTimeImmutable('2024-08-01 12:00:00', new DateTimeZone('UTC')); $coordinates = [ [ 'user' => 'user-alpha', @@ -41,11 +37,11 @@ class SignalFixtures extends Fixture foreach ($coordinates as $config) { $signalLocation = Point::fromLatLng($config['lat'], $config['lng']); - $signal = new Signal() - ->setUserKey($config['user']) - ->setUserLocation(Point::fromLatLng($config['lat'], $config['lng'])) - ->setSignalLocation($signalLocation) - ->setCreatedAt($baseTime->add(new DateInterval(sprintf('PT%dM', $config['offset'])))); + $signal = Signal::create( + $config['user'], + Point::fromLatLng($config['lat'], $config['lng']), + $signalLocation, + ); $manager->persist($signal); } diff --git a/server/src/Entity/.gitignore b/server/src/Entity/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/server/src/Entity/Identifier/SignalId.php b/server/src/Entity/Identifier/SignalId.php new file mode 100644 index 0000000..2696bcb --- /dev/null +++ b/server/src/Entity/Identifier/SignalId.php @@ -0,0 +1,11 @@ +id; + private function __construct( + #[ORM\Column(type: Types::STRING, length: 64)] public string $userKey, + #[ORM\Embedded(class: Point::class, columnPrefix: 'user_')] public Point $userLocation, + #[ORM\Embedded(class: Point::class, columnPrefix: 'signal_')] public Point $signalLocation, + #[ORM\Id] #[ORM\GeneratedValue(strategy: 'NONE')] #[ORM\Column(type: 'uuid')] public SignalId $id = new SignalId(), + #[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)] public \DateTimeImmutable $createdAt = new \DateTimeImmutable() + ) { } - public function getUserKey(): string + public static function create(string $userKey, Point $userLocation, Point $signalLocation): self { - return $this->userKey; - } - - public function setUserKey(string $userKey): self - { - $this->userKey = $userKey; - - return $this; - } - - public function getUserLocation(): Point - { - return $this->userLocation; - } - - public function setUserLocation(Point $userLocation): self - { - $this->userLocation = $userLocation; - - return $this; - } - - public function getSignalLocation(): Point - { - return $this->signalLocation; - } - - public function setSignalLocation(Point $signalLocation): self - { - $this->signalLocation = $signalLocation; - - return $this; - } - - public function getCreatedAt(): DateTimeImmutable - { - return $this->createdAt; - } - - public function setCreatedAt(DateTimeImmutable $createdAt): self - { - $this->createdAt = $createdAt; - - return $this; + return new self( + userKey: $userKey, + userLocation: $userLocation, + signalLocation: $signalLocation + ); } } diff --git a/server/src/Exception/InvalidPointException.php b/server/src/Exception/InvalidPointException.php index 8d62f6c..ff2c487 100644 --- a/server/src/Exception/InvalidPointException.php +++ b/server/src/Exception/InvalidPointException.php @@ -11,13 +11,8 @@ use function sprintf; #[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: [ 'x-error-code' => 'invalid_coordinates', ])] -final class InvalidPointException extends \RuntimeException +final class InvalidPointException extends \DomainException { - private function __construct(string $message) - { - parent::__construct($message); - } - public static function invalidNumber(): self { return new self('Latitude and longitude must be finite numbers.'); diff --git a/server/src/Exception/MissingClientKeyException.php b/server/src/Exception/MissingClientKeyException.php index a3fc0d0..a0bb8c9 100644 --- a/server/src/Exception/MissingClientKeyException.php +++ b/server/src/Exception/MissingClientKeyException.php @@ -10,7 +10,7 @@ use Symfony\Component\HttpKernel\Attribute\WithHttpStatus; #[WithHttpStatus(Response::HTTP_BAD_REQUEST, headers: [ 'x-error-code' => 'missing_client_key', ])] -final class MissingClientKeyException extends \RuntimeException +final class MissingClientKeyException extends \DomainException { public function __construct() { diff --git a/server/src/Exception/PointTooFarException.php b/server/src/Exception/PointTooFarException.php index 2b8ef88..cf325e1 100644 --- a/server/src/Exception/PointTooFarException.php +++ b/server/src/Exception/PointTooFarException.php @@ -11,7 +11,7 @@ use function sprintf; #[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: [ 'x-error-code' => 'point_too_far', ])] -final class PointTooFarException extends \RuntimeException +final class PointTooFarException extends \DomainException { public function __construct(float $maximumDistanceKm) { diff --git a/server/src/Exception/SubmissionRateLimitedException.php b/server/src/Exception/SubmissionRateLimitedException.php index b6e63f4..6f71acb 100644 --- a/server/src/Exception/SubmissionRateLimitedException.php +++ b/server/src/Exception/SubmissionRateLimitedException.php @@ -4,21 +4,19 @@ declare(strict_types=1); namespace App\Exception; -use DateTimeInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\WithHttpStatus; -use function sprintf; #[WithHttpStatus(Response::HTTP_TOO_MANY_REQUESTS, headers: [ 'x-error-code' => 'submission_rate_limited', ])] -final class SubmissionRateLimitedException extends \RuntimeException +final class SubmissionRateLimitedException extends \DomainException { - public function __construct(?DateTimeInterface $retryAfter) + public function __construct(?\DateTimeInterface $retryAfter) { $message = 'Too many submissions from your network. Please wait before trying again.'; - if ($retryAfter !== null) { - $message .= sprintf(' You can retry after %s.', $retryAfter->format(DATE_ATOM)); + if ($retryAfter instanceof \DateTimeInterface) { + $message .= \sprintf(' You can retry after %s.', $retryAfter->format(DATE_ATOM)); } parent::__construct($message); diff --git a/server/src/Message/SignalCreatedMessage.php b/server/src/Message/SignalCreatedMessage.php index a28ec50..f5f5306 100644 --- a/server/src/Message/SignalCreatedMessage.php +++ b/server/src/Message/SignalCreatedMessage.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace App\Message; -final class SignalCreatedMessage +use App\Entity\Identifier\SignalId; + +final readonly class SignalCreatedMessage { public function __construct( - public readonly int $signalId + public SignalId $signalId ) { } } diff --git a/server/src/MessageHandler/SignalCreatedMessageHandler.php b/server/src/MessageHandler/SignalCreatedMessageHandler.php index 6481333..8ffa06c 100644 --- a/server/src/MessageHandler/SignalCreatedMessageHandler.php +++ b/server/src/MessageHandler/SignalCreatedMessageHandler.php @@ -7,30 +7,32 @@ namespace App\MessageHandler; use App\Message\SignalCreatedMessage; use App\Repository\SignalRepository; use App\Service\SignalSnapshotBuilder; -use DateTimeImmutable; -use DateTimeZone; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Serializer\Exception\ExceptionInterface; +use Symfony\Component\Serializer\SerializerInterface; use Throwable; -use function json_encode; -use const JSON_THROW_ON_ERROR; #[AsMessageHandler] -final class SignalCreatedMessageHandler +final readonly class SignalCreatedMessageHandler { public function __construct( - private readonly SignalRepository $signals, - private readonly SignalSnapshotBuilder $snapshotBuilder, - private readonly HubInterface $hub, - #[Autowire('%app.signal_stream_topic%')] private readonly string $topic, - #[Autowire('%app.signal_snapshot_limit%')] private readonly int $snapshotLimit, - private readonly ?LoggerInterface $logger = null, + private SignalRepository $signals, + private SignalSnapshotBuilder $snapshotBuilder, + private HubInterface $hub, + private SerializerInterface $serializer, + private LoggerInterface $logger, + #[Autowire('%app.signal_stream_topic%')] private string $topic, + #[Autowire('%app.signal_snapshot_limit%')] private int $snapshotLimit, ) { } + /** + * @throws ExceptionInterface + */ public function __invoke(SignalCreatedMessage $message): void { $recentSignals = $this->signals->findRecent($this->snapshotLimit); @@ -43,21 +45,17 @@ final class SignalCreatedMessageHandler 'density' => $snapshot['density'], 'latestByUser' => $snapshot['latestByUser'], 'totals' => $snapshot['totals'], - 'updatedAt' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM), + 'updatedAt' => new \DateTimeImmutable('now', new \DateTimeZone('UTC'))->format(DATE_ATOM), ], ]; - $data = json_encode($payload, JSON_THROW_ON_ERROR); - - $update = new Update( - topics: $this->topic, - data: $data, - ); + $data = $this->serializer->serialize($payload, 'json'); + $update = new Update(topics: $this->topic, data: $data); try { $this->hub->publish($update); } catch (Throwable $exception) { - $this->logger?->error('Failed to publish signal update to Mercure.', [ + $this->logger->error('Failed to publish signal update to Mercure.', [ 'exception' => $exception, ]); } diff --git a/server/src/Repository/.gitignore b/server/src/Repository/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/server/src/Service/ClientKeyProvider.php b/server/src/Service/ClientKeyProvider.php index d6d409d..45733f6 100644 --- a/server/src/Service/ClientKeyProvider.php +++ b/server/src/Service/ClientKeyProvider.php @@ -8,10 +8,10 @@ use App\Exception\MissingClientKeyException; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; -final class ClientKeyProvider +final readonly class ClientKeyProvider { public function __construct( - private readonly LoggerInterface $logger, + private LoggerInterface $logger, ) { } diff --git a/server/src/Service/PointProximityValidator.php b/server/src/Service/PointProximityValidator.php index 75c18c2..93bf6d6 100644 --- a/server/src/Service/PointProximityValidator.php +++ b/server/src/Service/PointProximityValidator.php @@ -10,17 +10,17 @@ use App\ValueObject\Point; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; -final class PointProximityValidator +final readonly class PointProximityValidator { public function __construct( - #[Autowire('%app.max_signal_distance_km%')] private readonly float $maximumDistanceKm, - #[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger, + #[Autowire('%app.max_signal_distance_km%')] private float $maximumDistanceKm, + #[Autowire(service: 'monolog.logger.signals')] private LoggerInterface $logger, ) { } public function assertWithinRange(Point $userLocation, Point $signalLocation): void { - $distance = Distance::betweenPoints($userLocation, $signalLocation)->inKilometers(); + $distance = Distance::between($userLocation, $signalLocation)->inKilometers(); $this->logger->debug('Calculated proximity between user and signal.', [ 'distance_km' => $distance, diff --git a/server/src/Service/SignalSnapshotBuilder.php b/server/src/Service/SignalSnapshotBuilder.php index b57ec43..41dc71b 100644 --- a/server/src/Service/SignalSnapshotBuilder.php +++ b/server/src/Service/SignalSnapshotBuilder.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service; use App\Entity\Signal; +use App\ValueObject\Point; class SignalSnapshotBuilder { @@ -13,15 +14,15 @@ class SignalSnapshotBuilder * * @return array{ * points: list, * density: list, * latestByUser: list, @@ -35,22 +36,17 @@ class SignalSnapshotBuilder $latestByUser = []; foreach ($signals as $signal) { - $signalLocation = $signal->getSignalLocation(); - $point = [ - 'id' => (int) $signal->getId(), - 'signalLocation' => [ - 'lat' => $signalLocation->getLat(), - 'lng' => $signalLocation->getLng(), - ], - 'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM), - 'userKey' => $signal->getUserKey(), + 'id' => $signal->id->toString(), + 'signalLocation' => $signal->signalLocation, + 'createdAt' => $signal->createdAt->format(DATE_ATOM), + 'userKey' => $signal->userKey, ]; $points[] = $point; - $bucketLat = round($signalLocation->getLat(), 3); - $bucketLng = round($signalLocation->getLng(), 3); + $bucketLat = round($signal->signalLocation->getLat(), 3); + $bucketLng = round($signal->signalLocation->getLng(), 3); $bucketKey = $bucketLat . ':' . $bucketLng; if (! isset($densityBuckets[$bucketKey])) { diff --git a/server/src/Service/SignalSnapshotService.php b/server/src/Service/SignalSnapshotService.php index 8a02034..12b8743 100644 --- a/server/src/Service/SignalSnapshotService.php +++ b/server/src/Service/SignalSnapshotService.php @@ -10,13 +10,13 @@ use DateTimeZone; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; -final class SignalSnapshotService +final readonly class SignalSnapshotService { public function __construct( - private readonly SignalRepository $signals, - private readonly SignalSnapshotBuilder $snapshotBuilder, - #[Autowire('%app.signal_snapshot_limit%')] private readonly int $snapshotLimit, - #[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger, + private SignalRepository $signals, + private SignalSnapshotBuilder $snapshotBuilder, + #[Autowire('%app.signal_snapshot_limit%')] private int $snapshotLimit, + #[Autowire(service: 'monolog.logger.signals')] private LoggerInterface $logger, ) { } diff --git a/server/src/Service/SignalSubmissionService.php b/server/src/Service/SignalSubmissionService.php index 9422cec..8b9ddec 100644 --- a/server/src/Service/SignalSubmissionService.php +++ b/server/src/Service/SignalSubmissionService.php @@ -9,21 +9,19 @@ use App\Exception\SubmissionRateLimitedException; use App\Message\SignalCreatedMessage; use App\Payload\SignalPayload; use App\Repository\SignalRepository; -use DateTimeImmutable; -use DateTimeZone; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\RateLimiter\RateLimiterFactory; -final class SignalSubmissionService +final readonly class SignalSubmissionService { public function __construct( - private readonly SignalRepository $signals, - #[Autowire(service: 'limiter.signal_submission')] private readonly RateLimiterFactory $submissionLimiter, - private readonly PointProximityValidator $proximityValidator, - private readonly MessageBusInterface $bus, - #[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger, + private SignalRepository $signals, + private MessageBusInterface $messageBus, + #[Autowire(service: 'limiter.signal_submission')] private RateLimiterFactory $submissionLimiter, + private PointProximityValidator $proximityValidator, + #[Autowire(service: 'monolog.logger.signals')] private LoggerInterface $logger, ) { } @@ -47,25 +45,17 @@ final class SignalSubmissionService ], ]); - $signal = (new Signal()) - ->setUserKey($clientKey) - ->setUserLocation($userLocation) - ->setSignalLocation($signalLocation) - ->setCreatedAt(new DateTimeImmutable('now', new DateTimeZone('UTC'))); - + $signal = Signal::create($clientKey, $userLocation, $signalLocation); $this->signals->save($signal); - $signalId = $signal->getId(); - if ($signalId !== null) { - $this->logger->debug('Dispatching signal created message.', [ - 'signal_id' => $signalId, - ]); - $this->bus->dispatch(new SignalCreatedMessage($signalId)); - $this->logger->info('Signal stored successfully.', [ - 'signal_id' => $signalId, - 'client_key' => $clientKey, - ]); - } + $this->logger->debug('Dispatching signal created message.', [ + 'signal_id' => $signal->id->toString(), + ]); + $this->messageBus->dispatch(new SignalCreatedMessage($signal->id)); + $this->logger->info('Signal stored successfully.', [ + 'signal_id' => $signal->id->toString(), + 'client_key' => $clientKey, + ]); return $signal; } diff --git a/server/src/ValueObject/Distance.php b/server/src/ValueObject/Distance.php index 060df69..63a1bab 100644 --- a/server/src/ValueObject/Distance.php +++ b/server/src/ValueObject/Distance.php @@ -4,16 +4,16 @@ declare(strict_types=1); namespace App\ValueObject; -final class Distance +final readonly class Distance { - private const EARTH_RADIUS_KM = 6371; + private const int EARTH_RADIUS_KM = 6371; private function __construct( - private readonly float $kilometers, + private float $kilometers, ) { } - public static function betweenPoints(Point $from, Point $to): self + public static function between(Point $from, Point $to): self { $lat1 = deg2rad($from->getLat()); $lat2 = deg2rad($to->getLat()); diff --git a/server/symfony.lock b/server/symfony.lock index 413f423..38e699a 100644 --- a/server/symfony.lock +++ b/server/symfony.lock @@ -47,6 +47,18 @@ "migrations/.gitignore" ] }, + "jsor/doctrine-postgis": { + "version": "2.4", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.7", + "ref": "211979f7917bf6edb8fc5006a17b2e84e8e84d50" + }, + "files": [ + "config/packages/jsor_doctrine_postgis.yaml" + ] + }, "nelmio/cors-bundle": { "version": "2.5", "recipe": { @@ -201,6 +213,15 @@ "config/routes.yaml" ] }, + "symfony/uid": { + "version": "7.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" + } + }, "symfony/validator": { "version": "7.3", "recipe": { diff --git a/server/tests/Unit/ValueObject/DistanceTest.php b/server/tests/Unit/ValueObject/DistanceTest.php index be047fe..a31885a 100644 --- a/server/tests/Unit/ValueObject/DistanceTest.php +++ b/server/tests/Unit/ValueObject/DistanceTest.php @@ -15,7 +15,7 @@ final class DistanceTest extends TestCase $origin = Point::fromLatLng(48.8566, 2.3522); // Paris $destination = Point::fromLatLng(51.5074, -0.1278); // London - $distance = Distance::betweenPoints($origin, $destination); + $distance = Distance::between($origin, $destination); self::assertEqualsWithDelta(343.4, $distance->inKilometers(), 0.5); } @@ -25,7 +25,7 @@ final class DistanceTest extends TestCase $origin = Point::fromLatLng(0.0, 0.0); $destination = Point::fromLatLng(0.0, 0.009); // ~1km east on equator - $distance = Distance::betweenPoints($origin, $destination); + $distance = Distance::between($origin, $destination); self::assertEqualsWithDelta(1000, $distance->inMeters(), 10); }