[backend] stabilise codebase
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
.idea
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"php.version": "8.4"
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ This repository hosts a proof-of-concept "points of interest" application compos
|
|||||||
- npm 10+
|
- npm 10+
|
||||||
- PHP 8.2+
|
- PHP 8.2+
|
||||||
- Composer 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
|
## Running the API server
|
||||||
|
|
||||||
@@ -21,9 +22,13 @@ This repository hosts a proof-of-concept "points of interest" application compos
|
|||||||
cd server
|
cd server
|
||||||
composer install
|
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:database:create --if-not-exists
|
||||||
php bin/console doctrine:migrations:migrate --no-interaction
|
php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
php bin/console doctrine:fixtures:load
|
||||||
|
|
||||||
# Start the Symfony development server
|
# Start the Symfony development server
|
||||||
symfony server:start
|
symfony server:start
|
||||||
@@ -49,4 +54,5 @@ The client starts on `http://localhost:5173`. Set the `VITE_API_BASE`, `VITE_MER
|
|||||||
## Additional notes
|
## 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 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.
|
- Review the individual `README.md` files inside `client/` and `server/` for more detailed configuration guidance.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface Point {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiPoint {
|
export interface ApiPoint {
|
||||||
id: number;
|
id: string;
|
||||||
signalLocation: Point;
|
signalLocation: Point;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
userKey: string;
|
userKey: string;
|
||||||
|
|||||||
+1
-1
@@ -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
|
# 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
|
# 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 ###
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
###> nelmio/cors-bundle ###
|
###> nelmio/cors-bundle ###
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
|
postgres:
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
###> symfony/mercure-bundle ###
|
###> symfony/mercure-bundle ###
|
||||||
mercure:
|
mercure:
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
|
|
||||||
services:
|
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 ###
|
###> symfony/mercure-bundle ###
|
||||||
mercure:
|
mercure:
|
||||||
image: dunglas/mercure
|
image: dunglas/mercure
|
||||||
@@ -24,6 +38,7 @@ services:
|
|||||||
###< symfony/mercure-bundle ###
|
###< symfony/mercure-bundle ###
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
postgres_data:
|
||||||
###> symfony/mercure-bundle ###
|
###> symfony/mercure-bundle ###
|
||||||
mercure_data:
|
mercure_data:
|
||||||
mercure_config:
|
mercure_config:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2",
|
"php": ">=8.4",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"doctrine/dbal": "^3",
|
"doctrine/dbal": "^3",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"doctrine/doctrine-fixtures-bundle": "^4.1",
|
"doctrine/doctrine-fixtures-bundle": "^4.1",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||||
"doctrine/orm": "^3.5",
|
"doctrine/orm": "^3.5",
|
||||||
|
"jsor/doctrine-postgis": "^2.4",
|
||||||
"nelmio/cors-bundle": "^2.5",
|
"nelmio/cors-bundle": "^2.5",
|
||||||
"sentry/sentry-symfony": "^5.6",
|
"sentry/sentry-symfony": "^5.6",
|
||||||
"symfony/console": "7.3.*",
|
"symfony/console": "7.3.*",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"symfony/rate-limiter": "^7.3",
|
"symfony/rate-limiter": "^7.3",
|
||||||
"symfony/runtime": "7.3.*",
|
"symfony/runtime": "7.3.*",
|
||||||
"symfony/serializer": "^7.3",
|
"symfony/serializer": "^7.3",
|
||||||
|
"symfony/uid": "^7.3",
|
||||||
"symfony/validator": "^7.3",
|
"symfony/validator": "^7.3",
|
||||||
"symfony/yaml": "7.3.*"
|
"symfony/yaml": "7.3.*"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+233
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "6a8621d55b15988cb9bfd8c4d6494281",
|
"content-hash": "e13aa064b12d472d9d5b8538e4d637c0",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "doctrine/collections",
|
"name": "doctrine/collections",
|
||||||
@@ -1474,6 +1474,81 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-03-19T14:43:43+00:00"
|
"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",
|
"name": "lcobucci/clock",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
@@ -5137,6 +5212,89 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-06-24T13:30:11+00:00"
|
"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",
|
"name": "symfony/property-access",
|
||||||
"version": "v7.3.3",
|
"version": "v7.3.3",
|
||||||
@@ -6131,6 +6289,80 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-09-11T15:33:27+00:00"
|
"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",
|
"name": "symfony/validator",
|
||||||
"version": "v7.3.4",
|
"version": "v7.3.4",
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
|
server_version: '16'
|
||||||
# IMPORTANT: You MUST configure your server version,
|
|
||||||
# either here or in the DATABASE_URL env var (see .env file)
|
|
||||||
#server_version: '16'
|
|
||||||
|
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
use_savepoints: true
|
use_savepoints: true
|
||||||
|
mapping_types:
|
||||||
|
geography: string
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: true
|
auto_generate_proxy_classes: true
|
||||||
enable_lazy_ghost_objects: true
|
enable_lazy_ghost_objects: true
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -16,13 +16,46 @@ final class Version20240919000000 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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)');
|
$platform = $this->connection->getDatabasePlatform()->getName();
|
||||||
$this->addSql('CREATE INDEX idx_signals_created_at ON signals (created_at)');
|
|
||||||
$this->addSql('CREATE INDEX idx_signals_user_key ON signals (user_key)');
|
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
|
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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,20 @@ final class Version20251010102708 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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('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('DROP TABLE signals');
|
||||||
$this->addSql('ALTER TABLE __temp__signals RENAME TO 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
|
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('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('DROP TABLE signals');
|
||||||
$this->addSql('ALTER TABLE __temp__signals RENAME TO signals');
|
$this->addSql('ALTER TABLE __temp__signals RENAME TO signals');
|
||||||
|
|||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
@@ -40,19 +40,14 @@ class SignalController
|
|||||||
$clientKey = $this->clientKeyProvider->fromRequest($request);
|
$clientKey = $this->clientKeyProvider->fromRequest($request);
|
||||||
$signal = $this->submissionService->submit($clientKey, $payload);
|
$signal = $this->submissionService->submit($clientKey, $payload);
|
||||||
|
|
||||||
$signalLocation = $signal->getSignalLocation();
|
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'status' => 'stored',
|
'status' => 'stored',
|
||||||
'clientKey' => $clientKey,
|
'clientKey' => $clientKey,
|
||||||
'point' => [
|
'point' => [
|
||||||
'id' => $signal->getId(),
|
'id' => $signal->id->toString(),
|
||||||
'signalLocation' => [
|
'signalLocation' => $signal->signalLocation,
|
||||||
'lat' => $signalLocation->getLat(),
|
'createdAt' => $signal->createdAt->format(DATE_ATOM),
|
||||||
'lng' => $signalLocation->getLng(),
|
'userKey' => $signal->userKey,
|
||||||
],
|
|
||||||
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
|
|
||||||
'userKey' => $signal->getUserKey(),
|
|
||||||
],
|
],
|
||||||
], Response::HTTP_CREATED);
|
], Response::HTTP_CREATED);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\DataFixtures;
|
|
||||||
|
|
||||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
|
||||||
use Doctrine\Persistence\ObjectManager;
|
|
||||||
|
|
||||||
class AppFixtures extends Fixture
|
|
||||||
{
|
|
||||||
public function load(ObjectManager $manager): void
|
|
||||||
{
|
|
||||||
// $product = new Product();
|
|
||||||
// $manager->persist($product);
|
|
||||||
|
|
||||||
$manager->flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,9 +6,6 @@ namespace App\DataFixtures;
|
|||||||
|
|
||||||
use App\Entity\Signal;
|
use App\Entity\Signal;
|
||||||
use App\ValueObject\Point;
|
use App\ValueObject\Point;
|
||||||
use DateInterval;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
use Doctrine\Persistence\ObjectManager;
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
@@ -16,7 +13,6 @@ class SignalFixtures extends Fixture
|
|||||||
{
|
{
|
||||||
public function load(ObjectManager $manager): void
|
public function load(ObjectManager $manager): void
|
||||||
{
|
{
|
||||||
$baseTime = new DateTimeImmutable('2024-08-01 12:00:00', new DateTimeZone('UTC'));
|
|
||||||
$coordinates = [
|
$coordinates = [
|
||||||
[
|
[
|
||||||
'user' => 'user-alpha',
|
'user' => 'user-alpha',
|
||||||
@@ -41,11 +37,11 @@ class SignalFixtures extends Fixture
|
|||||||
foreach ($coordinates as $config) {
|
foreach ($coordinates as $config) {
|
||||||
$signalLocation = Point::fromLatLng($config['lat'], $config['lng']);
|
$signalLocation = Point::fromLatLng($config['lat'], $config['lng']);
|
||||||
|
|
||||||
$signal = new Signal()
|
$signal = Signal::create(
|
||||||
->setUserKey($config['user'])
|
$config['user'],
|
||||||
->setUserLocation(Point::fromLatLng($config['lat'], $config['lng']))
|
Point::fromLatLng($config['lat'], $config['lng']),
|
||||||
->setSignalLocation($signalLocation)
|
$signalLocation,
|
||||||
->setCreatedAt($baseTime->add(new DateInterval(sprintf('PT%dM', $config['offset']))));
|
);
|
||||||
|
|
||||||
$manager->persist($signal);
|
$manager->persist($signal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity\Identifier;
|
||||||
|
|
||||||
|
use Symfony\Component\Uid\UuidV7;
|
||||||
|
|
||||||
|
final class SignalId extends UuidV7
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -4,85 +4,33 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Identifier\SignalId;
|
||||||
use App\Repository\SignalRepository;
|
use App\Repository\SignalRepository;
|
||||||
use App\ValueObject\Point;
|
use App\ValueObject\Point;
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: SignalRepository::class)]
|
#[ORM\Entity(repositoryClass: SignalRepository::class)]
|
||||||
#[ORM\Table(name: 'signals')]
|
#[ORM\Table(name: 'signals')]
|
||||||
#[ORM\Index(columns: ['created_at'], name: 'idx_signals_created_at')]
|
#[ORM\Index(name: 'idx_signals_created_at', columns: ['created_at'])]
|
||||||
#[ORM\Index(columns: ['user_key'], name: 'idx_signals_user_key')]
|
#[ORM\Index(name: 'idx_signals_user_key', columns: ['user_key'])]
|
||||||
class Signal
|
readonly class Signal
|
||||||
{
|
{
|
||||||
#[ORM\Id]
|
private function __construct(
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\Column(type: Types::STRING, length: 64)] public string $userKey,
|
||||||
#[ORM\Column(type: Types::INTEGER)]
|
#[ORM\Embedded(class: Point::class, columnPrefix: 'user_')] public Point $userLocation,
|
||||||
private ?int $id = null; // @phpstan-ignore property.unusedType
|
#[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(type: Types::STRING, length: 64)]
|
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)] public \DateTimeImmutable $createdAt = new \DateTimeImmutable()
|
||||||
private string $userKey;
|
) {
|
||||||
|
|
||||||
#[ORM\Embedded(class: Point::class, columnPrefix: 'user_')]
|
|
||||||
private Point $userLocation;
|
|
||||||
|
|
||||||
#[ORM\Embedded(class: Point::class, columnPrefix: 'signal_')]
|
|
||||||
private Point $signalLocation;
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'created_at')]
|
|
||||||
private DateTimeImmutable $createdAt;
|
|
||||||
|
|
||||||
public function getId(): ?int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUserKey(): string
|
public static function create(string $userKey, Point $userLocation, Point $signalLocation): self
|
||||||
{
|
{
|
||||||
return $this->userKey;
|
return new self(
|
||||||
}
|
userKey: $userKey,
|
||||||
|
userLocation: $userLocation,
|
||||||
public function setUserKey(string $userKey): self
|
signalLocation: $signalLocation
|
||||||
{
|
);
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,8 @@ use function sprintf;
|
|||||||
#[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: [
|
#[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: [
|
||||||
'x-error-code' => 'invalid_coordinates',
|
'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
|
public static function invalidNumber(): self
|
||||||
{
|
{
|
||||||
return new self('Latitude and longitude must be finite numbers.');
|
return new self('Latitude and longitude must be finite numbers.');
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
|
|||||||
#[WithHttpStatus(Response::HTTP_BAD_REQUEST, headers: [
|
#[WithHttpStatus(Response::HTTP_BAD_REQUEST, headers: [
|
||||||
'x-error-code' => 'missing_client_key',
|
'x-error-code' => 'missing_client_key',
|
||||||
])]
|
])]
|
||||||
final class MissingClientKeyException extends \RuntimeException
|
final class MissingClientKeyException extends \DomainException
|
||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use function sprintf;
|
|||||||
#[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: [
|
#[WithHttpStatus(Response::HTTP_UNPROCESSABLE_ENTITY, headers: [
|
||||||
'x-error-code' => 'point_too_far',
|
'x-error-code' => 'point_too_far',
|
||||||
])]
|
])]
|
||||||
final class PointTooFarException extends \RuntimeException
|
final class PointTooFarException extends \DomainException
|
||||||
{
|
{
|
||||||
public function __construct(float $maximumDistanceKm)
|
public function __construct(float $maximumDistanceKm)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,21 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Exception;
|
namespace App\Exception;
|
||||||
|
|
||||||
use DateTimeInterface;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
|
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
|
||||||
use function sprintf;
|
|
||||||
|
|
||||||
#[WithHttpStatus(Response::HTTP_TOO_MANY_REQUESTS, headers: [
|
#[WithHttpStatus(Response::HTTP_TOO_MANY_REQUESTS, headers: [
|
||||||
'x-error-code' => 'submission_rate_limited',
|
'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.';
|
$message = 'Too many submissions from your network. Please wait before trying again.';
|
||||||
if ($retryAfter !== null) {
|
if ($retryAfter instanceof \DateTimeInterface) {
|
||||||
$message .= sprintf(' You can retry after %s.', $retryAfter->format(DATE_ATOM));
|
$message .= \sprintf(' You can retry after %s.', $retryAfter->format(DATE_ATOM));
|
||||||
}
|
}
|
||||||
|
|
||||||
parent::__construct($message);
|
parent::__construct($message);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Message;
|
namespace App\Message;
|
||||||
|
|
||||||
final class SignalCreatedMessage
|
use App\Entity\Identifier\SignalId;
|
||||||
|
|
||||||
|
final readonly class SignalCreatedMessage
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $signalId
|
public SignalId $signalId
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,30 +7,32 @@ namespace App\MessageHandler;
|
|||||||
use App\Message\SignalCreatedMessage;
|
use App\Message\SignalCreatedMessage;
|
||||||
use App\Repository\SignalRepository;
|
use App\Repository\SignalRepository;
|
||||||
use App\Service\SignalSnapshotBuilder;
|
use App\Service\SignalSnapshotBuilder;
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Component\Mercure\HubInterface;
|
||||||
use Symfony\Component\Mercure\Update;
|
use Symfony\Component\Mercure\Update;
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
use Symfony\Component\Serializer\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Component\Serializer\SerializerInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use function json_encode;
|
|
||||||
use const JSON_THROW_ON_ERROR;
|
|
||||||
|
|
||||||
#[AsMessageHandler]
|
#[AsMessageHandler]
|
||||||
final class SignalCreatedMessageHandler
|
final readonly class SignalCreatedMessageHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SignalRepository $signals,
|
private SignalRepository $signals,
|
||||||
private readonly SignalSnapshotBuilder $snapshotBuilder,
|
private SignalSnapshotBuilder $snapshotBuilder,
|
||||||
private readonly HubInterface $hub,
|
private HubInterface $hub,
|
||||||
#[Autowire('%app.signal_stream_topic%')] private readonly string $topic,
|
private SerializerInterface $serializer,
|
||||||
#[Autowire('%app.signal_snapshot_limit%')] private readonly int $snapshotLimit,
|
private LoggerInterface $logger,
|
||||||
private readonly ?LoggerInterface $logger = null,
|
#[Autowire('%app.signal_stream_topic%')] private string $topic,
|
||||||
|
#[Autowire('%app.signal_snapshot_limit%')] private int $snapshotLimit,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ExceptionInterface
|
||||||
|
*/
|
||||||
public function __invoke(SignalCreatedMessage $message): void
|
public function __invoke(SignalCreatedMessage $message): void
|
||||||
{
|
{
|
||||||
$recentSignals = $this->signals->findRecent($this->snapshotLimit);
|
$recentSignals = $this->signals->findRecent($this->snapshotLimit);
|
||||||
@@ -43,21 +45,17 @@ final class SignalCreatedMessageHandler
|
|||||||
'density' => $snapshot['density'],
|
'density' => $snapshot['density'],
|
||||||
'latestByUser' => $snapshot['latestByUser'],
|
'latestByUser' => $snapshot['latestByUser'],
|
||||||
'totals' => $snapshot['totals'],
|
'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);
|
$data = $this->serializer->serialize($payload, 'json');
|
||||||
|
$update = new Update(topics: $this->topic, data: $data);
|
||||||
$update = new Update(
|
|
||||||
topics: $this->topic,
|
|
||||||
data: $data,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->hub->publish($update);
|
$this->hub->publish($update);
|
||||||
} catch (Throwable $exception) {
|
} 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,
|
'exception' => $exception,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ use App\Exception\MissingClientKeyException;
|
|||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
final class ClientKeyProvider
|
final readonly class ClientKeyProvider
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ use App\ValueObject\Point;
|
|||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
final class PointProximityValidator
|
final readonly class PointProximityValidator
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire('%app.max_signal_distance_km%')] private readonly float $maximumDistanceKm,
|
#[Autowire('%app.max_signal_distance_km%')] private float $maximumDistanceKm,
|
||||||
#[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger,
|
#[Autowire(service: 'monolog.logger.signals')] private LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function assertWithinRange(Point $userLocation, Point $signalLocation): void
|
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.', [
|
$this->logger->debug('Calculated proximity between user and signal.', [
|
||||||
'distance_km' => $distance,
|
'distance_km' => $distance,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Entity\Signal;
|
use App\Entity\Signal;
|
||||||
|
use App\ValueObject\Point;
|
||||||
|
|
||||||
class SignalSnapshotBuilder
|
class SignalSnapshotBuilder
|
||||||
{
|
{
|
||||||
@@ -13,15 +14,15 @@ class SignalSnapshotBuilder
|
|||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
* points: list<array{
|
* points: list<array{
|
||||||
* id: int,
|
* id: string,
|
||||||
* signalLocation: array{lat: float, lng: float},
|
* signalLocation: Point,
|
||||||
* createdAt: string,
|
* createdAt: string,
|
||||||
* userKey: string,
|
* userKey: string,
|
||||||
* }>,
|
* }>,
|
||||||
* density: list<array{lat: float, lng: float, intensity: int}>,
|
* density: list<array{lat: float, lng: float, intensity: int}>,
|
||||||
* latestByUser: list<array{
|
* latestByUser: list<array{
|
||||||
* id: int,
|
* id: string,
|
||||||
* signalLocation: array{lat: float, lng: float},
|
* signalLocation: Point,
|
||||||
* createdAt: string,
|
* createdAt: string,
|
||||||
* userKey: string,
|
* userKey: string,
|
||||||
* }>,
|
* }>,
|
||||||
@@ -35,22 +36,17 @@ class SignalSnapshotBuilder
|
|||||||
$latestByUser = [];
|
$latestByUser = [];
|
||||||
|
|
||||||
foreach ($signals as $signal) {
|
foreach ($signals as $signal) {
|
||||||
$signalLocation = $signal->getSignalLocation();
|
|
||||||
|
|
||||||
$point = [
|
$point = [
|
||||||
'id' => (int) $signal->getId(),
|
'id' => $signal->id->toString(),
|
||||||
'signalLocation' => [
|
'signalLocation' => $signal->signalLocation,
|
||||||
'lat' => $signalLocation->getLat(),
|
'createdAt' => $signal->createdAt->format(DATE_ATOM),
|
||||||
'lng' => $signalLocation->getLng(),
|
'userKey' => $signal->userKey,
|
||||||
],
|
|
||||||
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
|
|
||||||
'userKey' => $signal->getUserKey(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$points[] = $point;
|
$points[] = $point;
|
||||||
|
|
||||||
$bucketLat = round($signalLocation->getLat(), 3);
|
$bucketLat = round($signal->signalLocation->getLat(), 3);
|
||||||
$bucketLng = round($signalLocation->getLng(), 3);
|
$bucketLng = round($signal->signalLocation->getLng(), 3);
|
||||||
$bucketKey = $bucketLat . ':' . $bucketLng;
|
$bucketKey = $bucketLat . ':' . $bucketLng;
|
||||||
|
|
||||||
if (! isset($densityBuckets[$bucketKey])) {
|
if (! isset($densityBuckets[$bucketKey])) {
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ use DateTimeZone;
|
|||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
final class SignalSnapshotService
|
final readonly class SignalSnapshotService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SignalRepository $signals,
|
private SignalRepository $signals,
|
||||||
private readonly SignalSnapshotBuilder $snapshotBuilder,
|
private SignalSnapshotBuilder $snapshotBuilder,
|
||||||
#[Autowire('%app.signal_snapshot_limit%')] private readonly int $snapshotLimit,
|
#[Autowire('%app.signal_snapshot_limit%')] private int $snapshotLimit,
|
||||||
#[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger,
|
#[Autowire(service: 'monolog.logger.signals')] private LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,21 +9,19 @@ use App\Exception\SubmissionRateLimitedException;
|
|||||||
use App\Message\SignalCreatedMessage;
|
use App\Message\SignalCreatedMessage;
|
||||||
use App\Payload\SignalPayload;
|
use App\Payload\SignalPayload;
|
||||||
use App\Repository\SignalRepository;
|
use App\Repository\SignalRepository;
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||||
|
|
||||||
final class SignalSubmissionService
|
final readonly class SignalSubmissionService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SignalRepository $signals,
|
private SignalRepository $signals,
|
||||||
#[Autowire(service: 'limiter.signal_submission')] private readonly RateLimiterFactory $submissionLimiter,
|
private MessageBusInterface $messageBus,
|
||||||
private readonly PointProximityValidator $proximityValidator,
|
#[Autowire(service: 'limiter.signal_submission')] private RateLimiterFactory $submissionLimiter,
|
||||||
private readonly MessageBusInterface $bus,
|
private PointProximityValidator $proximityValidator,
|
||||||
#[Autowire(service: 'monolog.logger.signals')] private readonly LoggerInterface $logger,
|
#[Autowire(service: 'monolog.logger.signals')] private LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,25 +45,17 @@ final class SignalSubmissionService
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$signal = (new Signal())
|
$signal = Signal::create($clientKey, $userLocation, $signalLocation);
|
||||||
->setUserKey($clientKey)
|
|
||||||
->setUserLocation($userLocation)
|
|
||||||
->setSignalLocation($signalLocation)
|
|
||||||
->setCreatedAt(new DateTimeImmutable('now', new DateTimeZone('UTC')));
|
|
||||||
|
|
||||||
$this->signals->save($signal);
|
$this->signals->save($signal);
|
||||||
|
|
||||||
$signalId = $signal->getId();
|
$this->logger->debug('Dispatching signal created message.', [
|
||||||
if ($signalId !== null) {
|
'signal_id' => $signal->id->toString(),
|
||||||
$this->logger->debug('Dispatching signal created message.', [
|
]);
|
||||||
'signal_id' => $signalId,
|
$this->messageBus->dispatch(new SignalCreatedMessage($signal->id));
|
||||||
]);
|
$this->logger->info('Signal stored successfully.', [
|
||||||
$this->bus->dispatch(new SignalCreatedMessage($signalId));
|
'signal_id' => $signal->id->toString(),
|
||||||
$this->logger->info('Signal stored successfully.', [
|
'client_key' => $clientKey,
|
||||||
'signal_id' => $signalId,
|
]);
|
||||||
'client_key' => $clientKey,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $signal;
|
return $signal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\ValueObject;
|
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 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());
|
$lat1 = deg2rad($from->getLat());
|
||||||
$lat2 = deg2rad($to->getLat());
|
$lat2 = deg2rad($to->getLat());
|
||||||
|
|||||||
@@ -47,6 +47,18 @@
|
|||||||
"migrations/.gitignore"
|
"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": {
|
"nelmio/cors-bundle": {
|
||||||
"version": "2.5",
|
"version": "2.5",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
@@ -201,6 +213,15 @@
|
|||||||
"config/routes.yaml"
|
"config/routes.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"symfony/uid": {
|
||||||
|
"version": "7.3",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "7.0",
|
||||||
|
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"symfony/validator": {
|
"symfony/validator": {
|
||||||
"version": "7.3",
|
"version": "7.3",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ final class DistanceTest extends TestCase
|
|||||||
$origin = Point::fromLatLng(48.8566, 2.3522); // Paris
|
$origin = Point::fromLatLng(48.8566, 2.3522); // Paris
|
||||||
$destination = Point::fromLatLng(51.5074, -0.1278); // London
|
$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);
|
self::assertEqualsWithDelta(343.4, $distance->inKilometers(), 0.5);
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ final class DistanceTest extends TestCase
|
|||||||
$origin = Point::fromLatLng(0.0, 0.0);
|
$origin = Point::fromLatLng(0.0, 0.0);
|
||||||
$destination = Point::fromLatLng(0.0, 0.009); // ~1km east on equator
|
$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);
|
self::assertEqualsWithDelta(1000, $distance->inMeters(), 10);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user