From 5803affb2fc049d39b81f5246a7e23f7ac259643 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 30 Jul 2026 16:26:46 +0200 Subject: [PATCH 01/81] fix: upgrade goteo/benzina to 0.6 --- composer.json | 2 +- composer.lock | 79 +++++---------------------------------------------- 2 files changed, 8 insertions(+), 73 deletions(-) diff --git a/composer.json b/composer.json index 1d8c99d..f52297a 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "doctrine/orm": "^3.3", "embed/embed": "^4.4", "gedmo/doctrine-extensions": "^3.22", - "goteo/benzina": "^0.5", + "goteo/benzina": "^0.6.0", "jolicode/automapper": "9.2.1", "knpuniversity/oauth2-client-bundle": "^2.20", "kyzegs/doctrine-encryption-bundle": "^1.1", diff --git a/composer.lock b/composer.lock index c47d913..21164cc 100644 --- a/composer.lock +++ b/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": "f9b4c951ba8f2afcc4cedfba6827a654", + "content-hash": "f98b0b0be5bc6564bb8523cefd2233f7", "packages": [ { "name": "api-platform/doctrine-common", @@ -2958,23 +2958,22 @@ }, { "name": "goteo/benzina", - "version": "0.5.2", + "version": "0.6", "source": { "type": "git", "url": "https://github.com/goteo/benzina.git", - "reference": "5fafb74117704e5b778232d39811b6e66884ba56" + "reference": "acceb2150fc08457a019bd85da46fdd5888dab11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/goteo/benzina/zipball/5fafb74117704e5b778232d39811b6e66884ba56", - "reference": "5fafb74117704e5b778232d39811b6e66884ba56", + "url": "https://api.github.com/repos/goteo/benzina/zipball/acceb2150fc08457a019bd85da46fdd5888dab11", + "reference": "acceb2150fc08457a019bd85da46fdd5888dab11", "shasum": "" }, "require": { "doctrine/orm": "^3.3", "ext-ctype": "*", "ext-iconv": "*", - "ocramius/doctrine-batch-utils": "^2.10", "php": ">=8.1", "symfony/console": "6.4.*", "symfony/dotenv": "6.4.*", @@ -3013,9 +3012,9 @@ "description": "Migration tool to pump v3 data into a v4 schema", "support": { "issues": "https://github.com/goteo/benzina/issues", - "source": "https://github.com/goteo/benzina/tree/0.5.2" + "source": "https://github.com/goteo/benzina/tree/0.6" }, - "time": "2025-10-21T10:06:39+00:00" + "time": "2026-07-30T14:13:45+00:00" }, { "name": "guzzlehttp/guzzle", @@ -4588,70 +4587,6 @@ ], "time": "2024-09-09T07:06:30+00:00" }, - { - "name": "ocramius/doctrine-batch-utils", - "version": "2.11.0", - "source": { - "type": "git", - "url": "https://github.com/Ocramius/DoctrineBatchUtils.git", - "reference": "35b164ea216da00ecccde6a0152f8be67bfb841f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Ocramius/DoctrineBatchUtils/zipball/35b164ea216da00ecccde6a0152f8be67bfb841f", - "reference": "35b164ea216da00ecccde6a0152f8be67bfb841f", - "shasum": "" - }, - "require": { - "doctrine/orm": "^3.3.2", - "doctrine/persistence": "^4.0.0", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0" - }, - "require-dev": { - "doctrine/coding-standard": "^12.0.0", - "doctrine/dbal": "^4.2.2", - "phpunit/phpunit": "^11.5.6", - "psalm/plugin-phpunit": "^0.19.2", - "roave/infection-static-analysis-plugin": "^1.36.0", - "vimeo/psalm": "^6.3.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "DoctrineBatchUtils\\": "src/DoctrineBatchUtils" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "http://ocramius.github.io/" - } - ], - "description": "A set of utilities to operate with Doctrine ORM's batch processing functionality", - "homepage": "https://github.com/Ocramius/DoctrineBatchUtils", - "keywords": [ - "batch processing", - "doctrine", - "doctrine2", - "orm" - ], - "support": { - "issues": "https://github.com/Ocramius/DoctrineBatchUtils/issues", - "source": "https://github.com/Ocramius/DoctrineBatchUtils/tree/2.11.0" - }, - "funding": [ - { - "url": "https://github.com/Ocramius", - "type": "github" - } - ], - "time": "2025-02-05T12:04:35+00:00" - }, { "name": "oscarotero/html-parser", "version": "v0.1.8", -- 2.54.0 From 995eef64fe1ddbd46a5084ac0dad4b9b503e76a8 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Mon, 10 Aug 2026 15:57:17 +0200 Subject: [PATCH 02/81] feat: increase Nominatim cache TTL --- src/Service/Nominatim/NominatimService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Service/Nominatim/NominatimService.php b/src/Service/Nominatim/NominatimService.php index 7d4fa75..e574548 100644 --- a/src/Service/Nominatim/NominatimService.php +++ b/src/Service/Nominatim/NominatimService.php @@ -13,10 +13,10 @@ class NominatimService private float $lastRequestTime = 0.0; /** - * Cache calls to Nominatim for 1 hour. + * Cache calls to Nominatim for 24 hours. * This ensures freshness of the data but minimizes bulk-operations hit rates to Nominatim. */ - public const NOMINATIM_CACHE_TTL = 3600; + public const NOMINATIM_CACHE_TTL = 86400; private HttpClientInterface $httpClient; -- 2.54.0 From 3b3fa996f81786bf149d76befb901ddea399129d Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 12 Aug 2026 14:42:00 +0200 Subject: [PATCH 03/81] fix: allow long addresses for territory data --- migrations/Version20260812124026.php | 30 ++++++++++++++++++++++++++++ src/Entity/Territory.php | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20260812124026.php diff --git a/migrations/Version20260812124026.php b/migrations/Version20260812124026.php new file mode 100644 index 0000000..0ea0a06 --- /dev/null +++ b/migrations/Version20260812124026.php @@ -0,0 +1,30 @@ +addSql('ALTER TABLE match_call CHANGE territory_address territory_address LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE project CHANGE territory_address territory_address LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE user CHANGE territory_address territory_address LONGTEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE user CHANGE territory_address territory_address VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE project CHANGE territory_address territory_address VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE ext_translations CHANGE foreign_key foreign_key INT NOT NULL'); + } +} diff --git a/src/Entity/Territory.php b/src/Entity/Territory.php index 30b416f..ca444b1 100644 --- a/src/Entity/Territory.php +++ b/src/Entity/Territory.php @@ -40,7 +40,7 @@ class Territory * Plain-text address.\ * e.g: Forn de l’Olivera 22B, 07012 Palma, Illes Balears. */ - #[ORM\Column(type: Types::STRING, nullable: true)] + #[ORM\Column(type: Types::TEXT, nullable: true)] public readonly ?string $address; public function __construct( -- 2.54.0 From 72a04d412b932ed02c00dd9e60fe94a0a1933430 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 12 Aug 2026 14:44:41 +0200 Subject: [PATCH 04/81] fix: build project addresses from goteo 3 territory fields --- src/Benzina/ProjectsPump.php | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 537ba7b..8fb8657 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -194,14 +194,30 @@ class ProjectsPump implements PumpInterface private function getProjectTerritory(array $record): Territory { - if ($record['project_location'] === null) { + $address = $record['project_location']; + + if ($address === null) { + if ($record['address'] !== null) { + $address = $record['address']; + } + + if ($record['location'] !== null) { + $address = $record['location']; + } + + if ($record['address'] !== null && $record['location'] !== null) { + $address = \sprintf('%s, %s', $record['address'], $record['location']); + } + } + + if ($address === null) { return Territory::unknown(); } - $cleanAddress = $this->cleanLocation($record['project_location'], 2); + $cleanAddress = $this->cleanLocation($address, 2); if ($cleanAddress === '') { - return Territory::unknown($record['project_location']); + return Territory::unknown($address); } return $this->territoryService->search($cleanAddress); -- 2.54.0 From c6dad06cf5e82e2e43619f57dde25227571ee6c9 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 13 Aug 2026 15:18:09 +0200 Subject: [PATCH 05/81] fix: handle empty strings for video sources --- src/Benzina/ProjectsPump.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 8fb8657..30dfe21 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -225,12 +225,14 @@ class ProjectsPump implements PumpInterface private function getProjectVideoSource(array $record): ?string { - if ($record['video'] !== null) { - return \trim($record['video']); + $video = \trim($record['video']); + if ($video !== '' && $record['video'] !== null) { + return $video; } - if ($record['media'] !== null) { - return \trim($record['media']); + $media = \trim($record['media']); + if ($media !== '' && $record['media'] !== null) { + return $media; } return null; -- 2.54.0 From 79ef243d114fc7d83fa83d31f562651586aaabcc Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 13 Aug 2026 15:18:26 +0200 Subject: [PATCH 06/81] fix: update project drafts deadline to 2026 --- src/Benzina/ProjectsPump.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 30dfe21..7990ed9 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -58,7 +58,7 @@ class ProjectsPump implements PumpInterface } $created = new \DateTime($record['created']); - if (\in_array($status, [ProjectStatus::InDraft]) && $created < new \DateTime('2025-01-01')) { + if (\in_array($status, [ProjectStatus::InDraft]) && $created < new \DateTime('2026-01-01')) { return; } -- 2.54.0 From c0fab8a0988f98b9c8dfa2bbd8841b05cdec8cb5 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 13 Aug 2026 16:47:15 +0200 Subject: [PATCH 07/81] feat: split project description into 4 fields --- migrations/Version20260813144440.php | 26 ++++++++ .../Project/ProjectApiResource.php | 21 +++++-- src/Benzina/ProjectsPump.php | 38 +++--------- src/Dto/ProjectUpdationDto.php | 23 ++++++- src/Entity/Project/Project.php | 61 ++++++++++++++++--- 5 files changed, 125 insertions(+), 44 deletions(-) create mode 100644 migrations/Version20260813144440.php diff --git a/migrations/Version20260813144440.php b/migrations/Version20260813144440.php new file mode 100644 index 0000000..0f1e6fd --- /dev/null +++ b/migrations/Version20260813144440.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE project ADD desc_about LONGTEXT DEFAULT NULL, ADD desc_goal LONGTEXT DEFAULT NULL, ADD desc_team LONGTEXT DEFAULT NULL, CHANGE description desc_brief LONGTEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project ADD description LONGTEXT DEFAULT NULL, DROP desc_brief, DROP desc_about, DROP desc_goal, DROP desc_team'); + } +} diff --git a/src/ApiResource/Project/ProjectApiResource.php b/src/ApiResource/Project/ProjectApiResource.php index 87a23f0..95fb1a4 100644 --- a/src/ApiResource/Project/ProjectApiResource.php +++ b/src/ApiResource/Project/ProjectApiResource.php @@ -141,11 +141,24 @@ class ProjectApiResource public Territory $territory; /** - * Free-form rich text description for the Project. + * Rich-text (markdown) introduction to the project. */ - #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'partial')] - #[Assert\NotBlank()] - public string $description; + public string $descBrief; + + /** + * Rich-text (markdown) description on the main features of the project. + */ + public string $descAbout; + + /** + * Rich-text (markdown) about why this project is important. + */ + public string $descGoal; + + /** + * Rich-text (markdown) about team and previous experience. + */ + public string $descTeam; /** * Extracted embedding data from the Project's video. diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 7990ed9..a53a9af 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -92,7 +92,10 @@ class ProjectsPump implements PumpInterface $project->addLocale($record['lang']); $project->setTitle($record['name'] ?? ''); $project->setSubtitle($record['subtitle'] ?? ''); - $project->setDescription($this->getProjectDescription($record)); + $project->setDescBrief($record['description']); + $project->setDescAbout($record['about']); + $project->setDescGoal($record['motivation']); + $project->setDescTeam($record['related']); $this->setPreventFlushAndClear(true); $this->persist($project, $context); @@ -103,7 +106,10 @@ class ProjectsPump implements PumpInterface $this->localize($project, $localizations, $context, [ 'title' => fn($l) => $l['name'], 'subtitle' => fn($l) => $l['subtitle'], - 'description' => fn($l) => $this->getProjectDescription($l), + 'descBrief' => fn($l) => $l['description'], + 'descAbout' => fn($l) => $l['about'], + 'descGoal' => fn($l) => $l['motivation'], + 'descTeam' => fn($l) => $l['related'], ]); } @@ -123,34 +129,6 @@ class ProjectsPump implements PumpInterface return $this->userRepository->matching($criteria)->first() ?? null; } - private function getProjectDescription(array $record): string - { - $lang = $record['lang']; - $hasTitles = \array_key_exists($lang, self::PROJECT_DESC_TITLES); - - $description = $record['description']; - - if ($hasTitles) { - $description .= \sprintf("\n\n## %s", self::PROJECT_DESC_TITLES[$lang]['about']); - } - - $description .= \sprintf("\n%s", $record['about']); - - if ($hasTitles) { - $description .= \sprintf("\n\n## %s", self::PROJECT_DESC_TITLES[$lang]['motivation']); - } - - $description .= \sprintf("\n\n%s", $record['motivation']); - - if ($hasTitles) { - $description .= \sprintf("\n\n## %s", self::PROJECT_DESC_TITLES[$lang]['related']); - } - - $description .= \sprintf("\n%s", $record['related']); - - return $description; - } - private function getProjectLocalizations(Project $project, array $context): array { $query = $this->getDbConnection($context)->prepare( diff --git a/src/Dto/ProjectUpdationDto.php b/src/Dto/ProjectUpdationDto.php index 5d5857c..85244cb 100644 --- a/src/Dto/ProjectUpdationDto.php +++ b/src/Dto/ProjectUpdationDto.php @@ -47,9 +47,28 @@ class ProjectUpdationDto public Territory $territory; /** - * Free-form rich text description for the Project. + * Rich-text (markdown allowed) introduction to the project. */ - public string $description; + #[Assert\NotBlank()] + public string $descBrief; + + /** + * Rich-text (markdown allowed) description on the main features of the project. + */ + #[Assert\NotBlank()] + public string $descAbout; + + /** + * Rich-text (markdown allowed) about why this project is important. + */ + #[Assert\NotBlank()] + public string $descGoal; + + /** + * Rich-text (markdown allowed) about team and previous experience. + */ + #[Assert\NotBlank()] + public string $descTeam; /** * On `minimum`, Project will campaign until the minimum deadline.\ diff --git a/src/Entity/Project/Project.php b/src/Entity/Project/Project.php index 763b79c..b6c9b91 100644 --- a/src/Entity/Project/Project.php +++ b/src/Entity/Project/Project.php @@ -78,12 +78,21 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized #[ORM\Embedded(class: Territory::class)] private ?Territory $territory; - /** - * The description body for the Project. - */ #[ORM\Column(type: Types::TEXT, nullable: true)] #[Gedmo\Translatable()] - private ?string $description = null; + private ?string $descBrief = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Gedmo\Translatable()] + private ?string $descAbout = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Gedmo\Translatable()] + private ?string $descGoal = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Gedmo\Translatable()] + private ?string $descTeam = null; /** * A video showcasing the Project. @@ -277,14 +286,50 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this; } - public function getDescription(): ?string + public function getDescBrief(): ?string { - return $this->description; + return $this->descBrief; } - public function setDescription(string $description): static + public function setDescBrief(?string $descBrief): static { - $this->description = $description; + $this->descBrief = $descBrief; + + return $this; + } + + public function getDescAbout(): ?string + { + return $this->descAbout; + } + + public function setDescAbout(?string $descAbout): static + { + $this->descAbout = $descAbout; + + return $this; + } + + public function getDescGoal(): ?string + { + return $this->descGoal; + } + + public function setDescGoal(?string $descGoal): static + { + $this->descGoal = $descGoal; + + return $this; + } + + public function getDescTeam(): ?string + { + return $this->descTeam; + } + + public function setDescTeam(?string $descTeam): static + { + $this->descTeam = $descTeam; return $this; } -- 2.54.0 From d0e48952083a88bede5dc6967d572069dc1d0c17 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 13 Aug 2026 18:10:20 +0200 Subject: [PATCH 08/81] feat: bump doctrine related dependencies --- composer.lock | 57 +++++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/composer.lock b/composer.lock index 21164cc..b201ee1 100644 --- a/composer.lock +++ b/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": "f98b0b0be5bc6564bb8523cefd2233f7", + "content-hash": "9230762cbf676fa02e262fc9ffa99c80", "packages": [ { "name": "api-platform/doctrine-common", @@ -1637,16 +1637,16 @@ }, { "name": "doctrine/dbal", - "version": "3.10.5", + "version": "3.10.6", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "95d84866bf3c04b2ddca1df7c049714660959aef" + "reference": "c95589d775a0b2e543467d40f8c3ecccf586f2b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef", - "reference": "95d84866bf3c04b2ddca1df7c049714660959aef", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/c95589d775a0b2e543467d40f8c3ecccf586f2b4", + "reference": "c95589d775a0b2e543467d40f8c3ecccf586f2b4", "shasum": "" }, "require": { @@ -1731,7 +1731,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.10.5" + "source": "https://github.com/doctrine/dbal/tree/3.10.6" }, "funding": [ { @@ -1747,7 +1747,7 @@ "type": "tidelift" } ], - "time": "2026-02-24T08:03:57+00:00" + "time": "2026-07-21T12:57:49+00:00" }, { "name": "doctrine/deprecations", @@ -1799,16 +1799,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.18.3", + "version": "2.19.0", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "241d61f6bbc77275d5a9f95d514241c058bf2d0a" + "reference": "07b90f707b82981097731c419f546e7ba97fba3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/241d61f6bbc77275d5a9f95d514241c058bf2d0a", - "reference": "241d61f6bbc77275d5a9f95d514241c058bf2d0a", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/07b90f707b82981097731c419f546e7ba97fba3c", + "reference": "07b90f707b82981097731c419f546e7ba97fba3c", "shasum": "" }, "require": { @@ -1830,7 +1830,7 @@ "doctrine/cache": "< 1.11", "doctrine/orm": "<2.17 || >=4.0", "symfony/var-exporter": "< 6.4.1 || 7.0.0", - "twig/twig": "<2.13 || >=3.0 <3.0.4" + "twig/twig": "<2.13 || >=3.0 <3.0.4 || >=5" }, "require-dev": { "doctrine/annotations": "^1 || ^2", @@ -1844,10 +1844,12 @@ "phpunit/phpunit": "^10.5.53 || ^12.3.10", "psr/log": "^1.1.4 || ^2.0 || ^3.0", "symfony/doctrine-messenger": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", "symfony/expression-language": "^6.4 || ^7.0", "symfony/http-kernel": "^6.4 || ^7.0", "symfony/messenger": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.0", + "symfony/runtime": "^6.4 || ^7.0", "symfony/security-bundle": "^6.4 || ^7.0", "symfony/stopwatch": "^6.4 || ^7.0", "symfony/string": "^6.4 || ^7.0", @@ -1856,7 +1858,7 @@ "symfony/var-exporter": "^6.4.1 || ^7.0.1", "symfony/web-profiler-bundle": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0", - "twig/twig": "^2.14.7 || ^3.0.4" + "twig/twig": "^2.14.7 || ^3.0.4 || ^4" }, "suggest": { "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", @@ -1901,7 +1903,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.3" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.0" }, "funding": [ { @@ -1917,7 +1919,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T08:22:50+00:00" + "time": "2026-07-23T14:52:05+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", @@ -2437,16 +2439,16 @@ }, { "name": "doctrine/orm", - "version": "3.6.7", + "version": "3.6.8", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c" + "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/bc217c0e19c3a9eadfa67697143b87c9ba01272c", - "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c", + "url": "https://api.github.com/repos/doctrine/orm/zipball/a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", + "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", "shasum": "" }, "require": { @@ -2476,6 +2478,7 @@ "symfony/cache": "^5.4 || ^6.2 || ^7.0 || ^8.0" }, "suggest": { + "ext-deepclone": "Improves performance when not using native lazy objects (Symfony 8.1+)", "ext-dom": "Provides support for XSD validation for XML mapping files", "symfony/cache": "Provides cache support for Setup Tool with doctrine/cache 2.0" }, @@ -2519,9 +2522,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.7" + "source": "https://github.com/doctrine/orm/tree/3.6.8" }, - "time": "2026-05-25T16:45:47+00:00" + "time": "2026-08-05T19:05:32+00:00" }, { "name": "doctrine/persistence", @@ -2826,16 +2829,16 @@ }, { "name": "gedmo/doctrine-extensions", - "version": "v3.22.0", + "version": "v3.22.1", "source": { "type": "git", "url": "https://github.com/doctrine-extensions/DoctrineExtensions.git", - "reference": "e4350ee2daa7f34aa5806f2e1ea11fa4b5800e57" + "reference": "a973a68a990392b96f5ccbd4d713ae33e4bde95f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/e4350ee2daa7f34aa5806f2e1ea11fa4b5800e57", - "reference": "e4350ee2daa7f34aa5806f2e1ea11fa4b5800e57", + "url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/a973a68a990392b96f5ccbd4d713ae33e4bde95f", + "reference": "a973a68a990392b96f5ccbd4d713ae33e4bde95f", "shasum": "" }, "require": { @@ -2934,7 +2937,7 @@ "support": { "docs": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc", "issues": "https://github.com/doctrine-extensions/DoctrineExtensions/issues", - "source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.22.0" + "source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.22.1" }, "funding": [ { @@ -2954,7 +2957,7 @@ "type": "github" } ], - "time": "2025-12-13T19:37:35+00:00" + "time": "2026-08-01T13:57:40+00:00" }, { "name": "goteo/benzina", -- 2.54.0 From e95383774fef1e48b1490cfe5da46e0ad8bebf6c Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 13 Aug 2026 18:11:58 +0200 Subject: [PATCH 09/81] feat: bump symfony related dependencies --- composer.lock | 356 +++++++++++++++++++++++++------------------------- 1 file changed, 178 insertions(+), 178 deletions(-) diff --git a/composer.lock b/composer.lock index b201ee1..d7af667 100644 --- a/composer.lock +++ b/composer.lock @@ -5934,16 +5934,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "704edf29f1c97ff6e36e248def6ff4a51f765ce1" + "reference": "8f0b5de5d9f5131aa99fdab78d7802efef9322dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/704edf29f1c97ff6e36e248def6ff4a51f765ce1", - "reference": "704edf29f1c97ff6e36e248def6ff4a51f765ce1", + "url": "https://api.github.com/repos/symfony/cache/zipball/8f0b5de5d9f5131aa99fdab78d7802efef9322dc", + "reference": "8f0b5de5d9f5131aa99fdab78d7802efef9322dc", "shasum": "" }, "require": { @@ -6010,7 +6010,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.42" + "source": "https://github.com/symfony/cache/tree/v6.4.43" }, "funding": [ { @@ -6030,7 +6030,7 @@ "type": "tidelift" } ], - "time": "2026-06-17T14:17:34+00:00" + "time": "2026-07-28T21:33:22+00:00" }, { "name": "symfony/cache-contracts", @@ -6192,16 +6192,16 @@ }, { "name": "symfony/config", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "922d980c90a69ffdd63e6ee551efb338b58be677" + "reference": "e39cdd91b2adf67a117bea29660b73d896937d86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/922d980c90a69ffdd63e6ee551efb338b58be677", - "reference": "922d980c90a69ffdd63e6ee551efb338b58be677", + "url": "https://api.github.com/repos/symfony/config/zipball/e39cdd91b2adf67a117bea29660b73d896937d86", + "reference": "e39cdd91b2adf67a117bea29660b73d896937d86", "shasum": "" }, "require": { @@ -6247,7 +6247,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.42" + "source": "https://github.com/symfony/config/tree/v6.4.43" }, "funding": [ { @@ -6267,20 +6267,20 @@ "type": "tidelift" } ], - "time": "2026-06-09T07:36:15+00:00" + "time": "2026-07-21T09:46:53+00:00" }, { "name": "symfony/console", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9ef84af84a7b66396da483634227650506428639" + "reference": "3b643aa587acbc42f967a429af088a56ed8f046d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9ef84af84a7b66396da483634227650506428639", - "reference": "9ef84af84a7b66396da483634227650506428639", + "url": "https://api.github.com/repos/symfony/console/zipball/3b643aa587acbc42f967a429af088a56ed8f046d", + "reference": "3b643aa587acbc42f967a429af088a56ed8f046d", "shasum": "" }, "require": { @@ -6345,7 +6345,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.42" + "source": "https://github.com/symfony/console/tree/v6.4.43" }, "funding": [ { @@ -6365,20 +6365,20 @@ "type": "tidelift" } ], - "time": "2026-06-15T05:35:29+00:00" + "time": "2026-07-26T14:44:19+00:00" }, { "name": "symfony/dependency-injection", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "ba3538ae8d14dd9a3a8758229e8ddaeff1a23643" + "reference": "f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/ba3538ae8d14dd9a3a8758229e8ddaeff1a23643", - "reference": "ba3538ae8d14dd9a3a8758229e8ddaeff1a23643", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905", + "reference": "f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905", "shasum": "" }, "require": { @@ -6430,7 +6430,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.42" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.43" }, "funding": [ { @@ -6450,7 +6450,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T07:06:12+00:00" + "time": "2026-07-20T15:13:12+00:00" }, { "name": "symfony/deprecation-contracts", @@ -6525,16 +6525,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "5508d06e0b97526c2ebcba094412ac25d221af8d" + "reference": "653001640a2ba86bb5fcec7f58832b74e6b3aa76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/5508d06e0b97526c2ebcba094412ac25d221af8d", - "reference": "5508d06e0b97526c2ebcba094412ac25d221af8d", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/653001640a2ba86bb5fcec7f58832b74e6b3aa76", + "reference": "653001640a2ba86bb5fcec7f58832b74e6b3aa76", "shasum": "" }, "require": { @@ -6613,7 +6613,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.42" + "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.43" }, "funding": [ { @@ -6633,20 +6633,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T10:12:24+00:00" + "time": "2026-07-21T10:19:36+00:00" }, { "name": "symfony/dotenv", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "d71597dab17919afbdffa4de14177137a4d56fdc" + "reference": "9b827002b54f89a5ac605c21c349a42161996afb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/d71597dab17919afbdffa4de14177137a4d56fdc", - "reference": "d71597dab17919afbdffa4de14177137a4d56fdc", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/9b827002b54f89a5ac605c21c349a42161996afb", + "reference": "9b827002b54f89a5ac605c21c349a42161996afb", "shasum": "" }, "require": { @@ -6691,7 +6691,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v6.4.42" + "source": "https://github.com/symfony/dotenv/tree/v6.4.43" }, "funding": [ { @@ -6711,20 +6711,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:18:55+00:00" + "time": "2026-07-26T10:10:07+00:00" }, { "name": "symfony/error-handler", - "version": "v6.4.36", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "2ea68f0e1835ad6a126f93bbc14cd236c10ab361" + "reference": "ecec4c500623ee0ac0c925c3426bcb3e7f429b14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/2ea68f0e1835ad6a126f93bbc14cd236c10ab361", - "reference": "2ea68f0e1835ad6a126f93bbc14cd236c10ab361", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/ecec4c500623ee0ac0c925c3426bcb3e7f429b14", + "reference": "ecec4c500623ee0ac0c925c3426bcb3e7f429b14", "shasum": "" }, "require": { @@ -6770,7 +6770,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.36" + "source": "https://github.com/symfony/error-handler/tree/v6.4.43" }, "funding": [ { @@ -6790,20 +6790,20 @@ "type": "tidelift" } ], - "time": "2026-03-10T15:56:14+00:00" + "time": "2026-07-21T10:11:50+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.37", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "2e3bf817ba9347341ab15926700fb6320367c0e1" + "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/2e3bf817ba9347341ab15926700fb6320367c0e1", - "reference": "2e3bf817ba9347341ab15926700fb6320367c0e1", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ac405d324c10ebbbde6a6e58379bf81db10f1dbf", + "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf", "shasum": "" }, "require": { @@ -6854,7 +6854,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.37" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.43" }, "funding": [ { @@ -6874,7 +6874,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T14:11:12+00:00" + "time": "2026-07-21T14:00:19+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7026,16 +7026,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.39", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca" + "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/c507b077756b4e3e09adbbe7975fac81cd3722ca", - "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16", + "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16", "shasum": "" }, "require": { @@ -7072,7 +7072,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.39" + "source": "https://github.com/symfony/filesystem/tree/v6.4.43" }, "funding": [ { @@ -7092,7 +7092,7 @@ "type": "tidelift" } ], - "time": "2026-05-07T13:11:42+00:00" + "time": "2026-06-27T10:13:35+00:00" }, { "name": "symfony/finder", @@ -7237,16 +7237,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "fde24f8e5f6aeadbc092ed894d569846c8bc1304" + "reference": "dcec940410874561f58edb75e2f3574c8a1340d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/fde24f8e5f6aeadbc092ed894d569846c8bc1304", - "reference": "fde24f8e5f6aeadbc092ed894d569846c8bc1304", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/dcec940410874561f58edb75e2f3574c8a1340d3", + "reference": "dcec940410874561f58edb75e2f3574c8a1340d3", "shasum": "" }, "require": { @@ -7274,7 +7274,7 @@ "symfony/asset": "<5.4", "symfony/asset-mapper": "<6.4", "symfony/clock": "<6.3", - "symfony/console": "<5.4|>=7.0", + "symfony/console": "<6.4.43|>=7.0", "symfony/dom-crawler": "<6.4", "symfony/dotenv": "<5.4", "symfony/form": "<5.4", @@ -7308,7 +7308,7 @@ "symfony/asset-mapper": "^6.4|^7.0", "symfony/browser-kit": "^5.4|^6.0|^7.0", "symfony/clock": "^6.2|^7.0", - "symfony/console": "^5.4.9|^6.0.9|^7.0", + "symfony/console": "^6.4.43|^7.0", "symfony/css-selector": "^5.4|^6.0|^7.0", "symfony/dom-crawler": "^6.4|^7.0", "symfony/dotenv": "^5.4|^6.0|^7.0", @@ -7338,7 +7338,7 @@ "symfony/web-link": "^5.4|^6.0|^7.0", "symfony/workflow": "^6.4|^7.0", "symfony/yaml": "^5.4|^6.0|^7.0", - "twig/twig": "^2.10|^3.0.4" + "twig/twig": "^2.10|^3.0.4|^4.0" }, "type": "symfony-bundle", "autoload": { @@ -7366,7 +7366,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.4.42" + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.43" }, "funding": [ { @@ -7386,20 +7386,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T07:50:05+00:00" + "time": "2026-07-26T09:00:38+00:00" }, { "name": "symfony/http-client", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "b71b312ca0f211fbb19a20a51bde50fbefb66a99" + "reference": "38540911e9e3c3ca12e562dcce758d9bfcfa7cdd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/b71b312ca0f211fbb19a20a51bde50fbefb66a99", - "reference": "b71b312ca0f211fbb19a20a51bde50fbefb66a99", + "url": "https://api.github.com/repos/symfony/http-client/zipball/38540911e9e3c3ca12e562dcce758d9bfcfa7cdd", + "reference": "38540911e9e3c3ca12e562dcce758d9bfcfa7cdd", "shasum": "" }, "require": { @@ -7464,7 +7464,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.42" + "source": "https://github.com/symfony/http-client/tree/v6.4.43" }, "funding": [ { @@ -7484,7 +7484,7 @@ "type": "tidelift" } ], - "time": "2026-06-12T09:42:32+00:00" + "time": "2026-07-29T06:36:08+00:00" }, { "name": "symfony/http-client-contracts", @@ -7570,16 +7570,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "23dcf8e0f59cbbfa9d2894f58fd8be6833794372" + "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dcf8e0f59cbbfa9d2894f58fd8be6833794372", - "reference": "23dcf8e0f59cbbfa9d2894f58fd8be6833794372", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ea0c801ec34e9017a8c9363e55c8ef5f1717216e", + "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e", "shasum": "" }, "require": { @@ -7627,7 +7627,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.42" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.43" }, "funding": [ { @@ -7647,20 +7647,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T10:12:24+00:00" + "time": "2026-07-29T06:55:26+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "51fd5bb7d4f221ceeac927e14ade63962e27a47c" + "reference": "cc35556a9bc9e9a6a35fb1114609d1e3c9a63738" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/51fd5bb7d4f221ceeac927e14ade63962e27a47c", - "reference": "51fd5bb7d4f221ceeac927e14ade63962e27a47c", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cc35556a9bc9e9a6a35fb1114609d1e3c9a63738", + "reference": "cc35556a9bc9e9a6a35fb1114609d1e3c9a63738", "shasum": "" }, "require": { @@ -7717,7 +7717,7 @@ "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^5.4|^6.4|^7.0", "symfony/var-exporter": "^6.2|^7.0", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^2.13|^3.0.4|^4.0" }, "type": "library", "autoload": { @@ -7745,7 +7745,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.42" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.43" }, "funding": [ { @@ -7765,7 +7765,7 @@ "type": "tidelift" } ], - "time": "2026-06-27T09:10:20+00:00" + "time": "2026-07-29T11:30:30+00:00" }, { "name": "symfony/intl", @@ -8038,16 +8038,16 @@ }, { "name": "symfony/monolog-bridge", - "version": "v6.4.40", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "85500da808ce785c6c95fafdbc226cd8af349007" + "reference": "68e9e40663e8af73901653f49ca149d13b6b581e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/85500da808ce785c6c95fafdbc226cd8af349007", - "reference": "85500da808ce785c6c95fafdbc226cd8af349007", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/68e9e40663e8af73901653f49ca149d13b6b581e", + "reference": "68e9e40663e8af73901653f49ca149d13b6b581e", "shasum": "" }, "require": { @@ -8097,7 +8097,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.40" + "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.43" }, "funding": [ { @@ -8117,7 +8117,7 @@ "type": "tidelift" } ], - "time": "2026-05-19T20:33:22+00:00" + "time": "2026-07-26T13:45:43+00:00" }, { "name": "symfony/monolog-bundle", @@ -8277,16 +8277,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -8335,7 +8335,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -8355,7 +8355,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-normalizer", @@ -8529,16 +8529,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", "shasum": "" }, "require": { @@ -8585,7 +8585,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { @@ -8605,7 +8605,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-php84", @@ -8918,16 +8918,16 @@ }, { "name": "symfony/property-info", - "version": "v6.4.34", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "916455e4c9dcddbebfd101f29d7983841c3564e0" + "reference": "ff32a0d7885f6f8997ee0aca499fd008852ad5bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/916455e4c9dcddbebfd101f29d7983841c3564e0", - "reference": "916455e4c9dcddbebfd101f29d7983841c3564e0", + "url": "https://api.github.com/repos/symfony/property-info/zipball/ff32a0d7885f6f8997ee0aca499fd008852ad5bd", + "reference": "ff32a0d7885f6f8997ee0aca499fd008852ad5bd", "shasum": "" }, "require": { @@ -8984,7 +8984,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v6.4.34" + "source": "https://github.com/symfony/property-info/tree/v6.4.43" }, "funding": [ { @@ -9004,7 +9004,7 @@ "type": "tidelift" } ], - "time": "2026-02-13T09:42:46+00:00" + "time": "2026-07-22T12:35:29+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -9095,16 +9095,16 @@ }, { "name": "symfony/routing", - "version": "v6.4.41", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "af04c79671fd8df0805a44c83fa2b0ba56c8329e" + "reference": "f8dedd42e7a511906aa54be6cebffbbe826ff20e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/af04c79671fd8df0805a44c83fa2b0ba56c8329e", - "reference": "af04c79671fd8df0805a44c83fa2b0ba56c8329e", + "url": "https://api.github.com/repos/symfony/routing/zipball/f8dedd42e7a511906aa54be6cebffbbe826ff20e", + "reference": "f8dedd42e7a511906aa54be6cebffbbe826ff20e", "shasum": "" }, "require": { @@ -9158,7 +9158,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.41" + "source": "https://github.com/symfony/routing/tree/v6.4.43" }, "funding": [ { @@ -9178,7 +9178,7 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:18:16+00:00" + "time": "2026-07-21T13:51:56+00:00" }, { "name": "symfony/runtime", @@ -9265,16 +9265,16 @@ }, { "name": "symfony/security-bundle", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "c74078bc1949fbd407a87656c7e683617afd5829" + "reference": "c77d9d6076e6bb8ba706b8616519e3f52f6b2660" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c74078bc1949fbd407a87656c7e683617afd5829", - "reference": "c74078bc1949fbd407a87656c7e683617afd5829", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c77d9d6076e6bb8ba706b8616519e3f52f6b2660", + "reference": "c77d9d6076e6bb8ba706b8616519e3f52f6b2660", "shasum": "" }, "require": { @@ -9323,7 +9323,7 @@ "symfony/twig-bundle": "^5.4|^6.0|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/yaml": "^5.4|^6.0|^7.0", - "twig/twig": "^2.13|^3.0.4", + "twig/twig": "^2.13|^3.0.4|^4.0", "web-token/jwt-checker": "^3.1", "web-token/jwt-signature-algorithm-ecdsa": "^3.1", "web-token/jwt-signature-algorithm-eddsa": "^3.1", @@ -9357,7 +9357,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v6.4.42" + "source": "https://github.com/symfony/security-bundle/tree/v6.4.43" }, "funding": [ { @@ -9377,20 +9377,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T12:40:39+00:00" + "time": "2026-07-29T06:43:01+00:00" }, { "name": "symfony/security-core", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "7ce2c661de04368e8c000ed7b6e96e613ed1ad55" + "reference": "9d2741948b0eb1fc08fd5431909dac8316ba49c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/7ce2c661de04368e8c000ed7b6e96e613ed1ad55", - "reference": "7ce2c661de04368e8c000ed7b6e96e613ed1ad55", + "url": "https://api.github.com/repos/symfony/security-core/zipball/9d2741948b0eb1fc08fd5431909dac8316ba49c2", + "reference": "9d2741948b0eb1fc08fd5431909dac8316ba49c2", "shasum": "" }, "require": { @@ -9447,7 +9447,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v6.4.42" + "source": "https://github.com/symfony/security-core/tree/v6.4.43" }, "funding": [ { @@ -9467,7 +9467,7 @@ "type": "tidelift" } ], - "time": "2026-06-09T07:36:15+00:00" + "time": "2026-07-13T05:00:30+00:00" }, { "name": "symfony/security-csrf", @@ -9635,16 +9635,16 @@ }, { "name": "symfony/serializer", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "dd3c671b794d4a4c936420aa4709743953a444b5" + "reference": "9a74371980b9eea34a39804fcfd7d0e4c81ccff1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/dd3c671b794d4a4c936420aa4709743953a444b5", - "reference": "dd3c671b794d4a4c936420aa4709743953a444b5", + "url": "https://api.github.com/repos/symfony/serializer/zipball/9a74371980b9eea34a39804fcfd7d0e4c81ccff1", + "reference": "9a74371980b9eea34a39804fcfd7d0e4c81ccff1", "shasum": "" }, "require": { @@ -9658,7 +9658,7 @@ "phpdocumentor/type-resolver": "<1.4.0", "symfony/dependency-injection": "<5.4", "symfony/property-access": "<5.4", - "symfony/property-info": "<5.4.24|>=6,<6.2.11", + "symfony/property-info": "<6.4.43", "symfony/uid": "<5.4", "symfony/validator": "<6.4", "symfony/yaml": "<5.4" @@ -9679,7 +9679,7 @@ "symfony/messenger": "^5.4|^6.0|^7.0", "symfony/mime": "^5.4|^6.0|^7.0", "symfony/property-access": "^5.4.26|^6.3|^7.0", - "symfony/property-info": "^5.4.24|^6.2.11|^7.0", + "symfony/property-info": "^6.4.43|^7.4.15", "symfony/translation-contracts": "^2.5|^3", "symfony/uid": "^5.4|^6.0|^7.0", "symfony/validator": "^6.4|^7.0", @@ -9713,7 +9713,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.4.42" + "source": "https://github.com/symfony/serializer/tree/v6.4.43" }, "funding": [ { @@ -9733,7 +9733,7 @@ "type": "tidelift" } ], - "time": "2026-06-26T12:38:00+00:00" + "time": "2026-07-29T07:59:11+00:00" }, { "name": "symfony/service-contracts", @@ -9963,16 +9963,16 @@ }, { "name": "symfony/string", - "version": "v6.4.39", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "62e3c927de664edadb5bef260987eb047a17a113" + "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/62e3c927de664edadb5bef260987eb047a17a113", - "reference": "62e3c927de664edadb5bef260987eb047a17a113", + "url": "https://api.github.com/repos/symfony/string/zipball/2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", + "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", "shasum": "" }, "require": { @@ -10028,7 +10028,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.39" + "source": "https://github.com/symfony/string/tree/v6.4.43" }, "funding": [ { @@ -10048,7 +10048,7 @@ "type": "tidelift" } ], - "time": "2026-05-12T11:44:19+00:00" + "time": "2026-07-28T07:28:15+00:00" }, { "name": "symfony/translation", @@ -10233,23 +10233,23 @@ }, { "name": "symfony/twig-bridge", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "bc8a4cad8d95722666ce4a572cdbc96f7a50bdbb" + "reference": "a94c869d1dd4df385a30cf8a9a86d1f7ba70466b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/bc8a4cad8d95722666ce4a572cdbc96f7a50bdbb", - "reference": "bc8a4cad8d95722666ce4a572cdbc96f7a50bdbb", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/a94c869d1dd4df385a30cf8a9a86d1f7ba70466b", + "reference": "a94c869d1dd4df385a30cf8a9a86d1f7ba70466b", "shasum": "" }, "require": { "php": ">=8.1", "symfony/deprecation-contracts": "^2.5|^3", "symfony/translation-contracts": "^2.5|^3", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^2.13|^3.0.4|^4.0" }, "conflict": { "phpdocumentor/reflection-docblock": "<3.2.2", @@ -10322,7 +10322,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v6.4.42" + "source": "https://github.com/symfony/twig-bridge/tree/v6.4.43" }, "funding": [ { @@ -10342,20 +10342,20 @@ "type": "tidelift" } ], - "time": "2026-06-09T07:36:15+00:00" + "time": "2026-07-21T10:37:57+00:00" }, { "name": "symfony/twig-bundle", - "version": "v6.4.32", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "a5c8dcc11a5bf9c96320da20070d2e158a4e0b30" + "reference": "15bad71434a8bbc64db852a14ac6360af7f4ea4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/a5c8dcc11a5bf9c96320da20070d2e158a4e0b30", - "reference": "a5c8dcc11a5bf9c96320da20070d2e158a4e0b30", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/15bad71434a8bbc64db852a14ac6360af7f4ea4a", + "reference": "15bad71434a8bbc64db852a14ac6360af7f4ea4a", "shasum": "" }, "require": { @@ -10366,7 +10366,7 @@ "symfony/http-foundation": "^5.4|^6.0|^7.0", "symfony/http-kernel": "^6.2", "symfony/twig-bridge": "^6.4", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^2.13|^3.0.4|^4.0" }, "conflict": { "symfony/framework-bundle": "<5.4", @@ -10410,7 +10410,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v6.4.32" + "source": "https://github.com/symfony/twig-bundle/tree/v6.4.43" }, "funding": [ { @@ -10430,7 +10430,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T12:44:39+00:00" + "time": "2026-07-06T08:03:42+00:00" }, { "name": "symfony/type-info", @@ -10595,16 +10595,16 @@ }, { "name": "symfony/validator", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "3044bdf8079efafdc8164094d32c53d10b8d6c19" + "reference": "ddde49bfa032b6c7f7b70fd3c82310d3d8cf952e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/3044bdf8079efafdc8164094d32c53d10b8d6c19", - "reference": "3044bdf8079efafdc8164094d32c53d10b8d6c19", + "url": "https://api.github.com/repos/symfony/validator/zipball/ddde49bfa032b6c7f7b70fd3c82310d3d8cf952e", + "reference": "ddde49bfa032b6c7f7b70fd3c82310d3d8cf952e", "shasum": "" }, "require": { @@ -10672,7 +10672,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.4.42" + "source": "https://github.com/symfony/validator/tree/v6.4.43" }, "funding": [ { @@ -10692,20 +10692,20 @@ "type": "tidelift" } ], - "time": "2026-06-26T05:21:23+00:00" + "time": "2026-07-28T13:57:03+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "29adc5e34255f42ca9b8ae61c22b4d9e3f611317" + "reference": "6638cfe00907b2b980b749fcf2cd4cd6b2b1396e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/29adc5e34255f42ca9b8ae61c22b4d9e3f611317", - "reference": "29adc5e34255f42ca9b8ae61c22b4d9e3f611317", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6638cfe00907b2b980b749fcf2cd4cd6b2b1396e", + "reference": "6638cfe00907b2b980b749fcf2cd4cd6b2b1396e", "shasum": "" }, "require": { @@ -10722,7 +10722,7 @@ "symfony/http-kernel": "^5.4|^6.0|^7.0", "symfony/process": "^5.4|^6.0|^7.0", "symfony/uid": "^5.4|^6.0|^7.0", - "twig/twig": "^2.13|^3.0.4" + "twig/twig": "^2.13|^3.0.4|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -10760,7 +10760,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.42" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.43" }, "funding": [ { @@ -10780,7 +10780,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T07:06:12+00:00" + "time": "2026-07-06T08:03:42+00:00" }, { "name": "symfony/var-exporter", @@ -11028,16 +11028,16 @@ }, { "name": "symfony/yaml", - "version": "v6.4.42", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "989dfb75049b77884f3f8cf9e99f103c8f91ca1c" + "reference": "d2ee2daa59be8cd0864835ea918ea209149796a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/989dfb75049b77884f3f8cf9e99f103c8f91ca1c", - "reference": "989dfb75049b77884f3f8cf9e99f103c8f91ca1c", + "url": "https://api.github.com/repos/symfony/yaml/zipball/d2ee2daa59be8cd0864835ea918ea209149796a3", + "reference": "d2ee2daa59be8cd0864835ea918ea209149796a3", "shasum": "" }, "require": { @@ -11080,7 +11080,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.42" + "source": "https://github.com/symfony/yaml/tree/v6.4.43" }, "funding": [ { @@ -11100,7 +11100,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T07:06:12+00:00" + "time": "2026-07-20T15:18:49+00:00" }, { "name": "twig/twig", @@ -14465,16 +14465,16 @@ }, { "name": "symfony/phpunit-bridge", - "version": "v7.4.14", + "version": "v7.4.16", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "11eeee9d109963145e66f5b1919e5cf5411da58b" + "reference": "49757f773056f1180f16667e0a5a533d262cbe9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/11eeee9d109963145e66f5b1919e5cf5411da58b", - "reference": "11eeee9d109963145e66f5b1919e5cf5411da58b", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/49757f773056f1180f16667e0a5a533d262cbe9a", + "reference": "49757f773056f1180f16667e0a5a533d262cbe9a", "shasum": "" }, "require": { @@ -14526,7 +14526,7 @@ "testing" ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.14" + "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.16" }, "funding": [ { @@ -14546,7 +14546,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-08-07T14:56:57+00:00" }, { "name": "theseer/tokenizer", -- 2.54.0 From 3d800b6326a468b9cc2a3795d949cd95ba9c5336 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 13 Aug 2026 19:35:36 +0200 Subject: [PATCH 10/81] feat: make categories work on idOrSlug --- migrations/Version20260813171902.php | 39 +++++++++++++++++++++++++ src/ApiResource/CategoryApiResource.php | 23 +++++++++++++-- src/Entity/Category.php | 18 +++++++++--- src/State/CategoryStateProvider.php | 39 +++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 migrations/Version20260813171902.php create mode 100644 src/State/CategoryStateProvider.php diff --git a/migrations/Version20260813171902.php b/migrations/Version20260813171902.php new file mode 100644 index 0000000..4aa19e8 --- /dev/null +++ b/migrations/Version20260813171902.php @@ -0,0 +1,39 @@ +addSql('DROP TABLE IF EXISTS category'); + $this->addSql('DROP TABLE IF EXISTS project_category'); + $this->addSql('CREATE TABLE category (id INT AUTO_INCREMENT NOT NULL, slug VARCHAR(56) NOT NULL, name VARCHAR(255) NOT NULL, locales JSON NOT NULL COMMENT \'(DC2Type:json)\', UNIQUE INDEX UNIQ_64C19C1989D9B62 (slug), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE project_category (project_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_3B02921A166D1F9C (project_id), INDEX IDX_3B02921A12469DE2 (category_id), PRIMARY KEY(project_id, category_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE project_category ADD CONSTRAINT FK_3B02921A166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_category ADD CONSTRAINT FK_3B02921A12469DE2 FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS category'); + $this->addSql('DROP TABLE IF EXISTS project_category'); + $this->addSql('CREATE TABLE category (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, locales JSON NOT NULL COMMENT \'(DC2Type:json)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE project_category (project_id INT NOT NULL, category_id VARCHAR(255) NOT NULL, INDEX IDX_3B02921A166D1F9C (project_id), INDEX IDX_3B02921A12469DE2 (category_id), PRIMARY KEY(project_id, category_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE project_category ADD CONSTRAINT FK_3B02921A166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_category ADD CONSTRAINT FK_3B02921A12469DE2 FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE'); + } +} diff --git a/src/ApiResource/CategoryApiResource.php b/src/ApiResource/CategoryApiResource.php index 0f22b46..71f7b49 100644 --- a/src/ApiResource/CategoryApiResource.php +++ b/src/ApiResource/CategoryApiResource.php @@ -2,11 +2,13 @@ namespace App\ApiResource; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata as API; use App\Entity\Category; use App\State\ApiResourceStateProcessor; use App\State\ApiResourceStateProvider; +use App\State\CategoryStateProvider; use Symfony\Component\Validator\Constraints as Assert; /** @@ -25,7 +27,15 @@ use Symfony\Component\Validator\Constraints as Assert; )] #[API\GetCollection()] #[API\Post(security: 'is_granted("ROLE_ADMIN")')] -#[API\Get()] +#[API\Get( + provider: CategoryStateProvider::class, + uriTemplate: '/categories/{idOrSlug}', + uriVariables: [ + 'idOrSlug' => new API\Link( + description: 'Category identifier or slug', + ), + ] +)] #[API\Patch(security: 'is_granted("ROLE_ADMIN")')] #[API\Delete(security: 'is_granted("ROLE_ADMIN")')] class CategoryApiResource @@ -36,13 +46,20 @@ class CategoryApiResource * This value will identify this Category in relationships with other resources. */ #[API\ApiProperty(identifier: true)] - #[Assert\NotBlank()] public string $id; + /** + * A unique, non white space, string identifier for this Category. + */ + #[API\ApiProperty(writable: false)] + #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] + public string $slug; + /** * A human-readable self-descriptive string of what this Category is about. */ - #[API\ApiProperty()] #[Assert\NotBlank()] + #[API\ApiProperty()] + #[API\ApiFilter(SearchFilter::class, strategy: 'partial')] public string $name; } diff --git a/src/Entity/Category.php b/src/Entity/Category.php index 85544cd..07a1f12 100644 --- a/src/Entity/Category.php +++ b/src/Entity/Category.php @@ -15,8 +15,13 @@ class Category implements LocalizedInterface use LocalizedTrait; #[ORM\Id] - #[ORM\Column(length: 255)] - private ?string $id = null; + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 56, unique: true)] + #[Gedmo\Slug(fields: ['name'])] + private ?string $slug = null; #[ORM\Column(length: 255)] #[Gedmo\Translatable()] @@ -27,9 +32,14 @@ class Category implements LocalizedInterface return $this->id; } - public function setId(string $id): static + public function getSlug(): ?string { - $this->id = $id; + return $this->slug; + } + + public function setSlug(string $slug): static + { + $this->slug = $slug; return $this; } diff --git a/src/State/CategoryStateProvider.php b/src/State/CategoryStateProvider.php new file mode 100644 index 0000000..f832bff --- /dev/null +++ b/src/State/CategoryStateProvider.php @@ -0,0 +1,39 @@ +categoryRepository->createQueryBuilder('c'); + $queryBuilder->where(\is_numeric($idOrSlug) ? 'c.id = :value' : 'c.slug = :value'); + $queryBuilder->setParameter('value', $idOrSlug); + + $query = $this->addLocalizationHints($queryBuilder, $this->getAcceptedLanguages($context)); + + $project = $query->getOneOrNullResult(); + + if ($project === null) { + return null; + } + + return $this->autoMapper->map($project, CategoryApiResource::class); + } +} -- 2.54.0 From e3ef9ea80d1770056f048678f29d3a079084edfb Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 14 Aug 2026 15:11:24 +0200 Subject: [PATCH 11/81] fix: allow filtering on project descBrief property --- src/ApiResource/Project/ProjectApiResource.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ApiResource/Project/ProjectApiResource.php b/src/ApiResource/Project/ProjectApiResource.php index 95fb1a4..3a850f8 100644 --- a/src/ApiResource/Project/ProjectApiResource.php +++ b/src/ApiResource/Project/ProjectApiResource.php @@ -143,6 +143,7 @@ class ProjectApiResource /** * Rich-text (markdown) introduction to the project. */ + #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'partial')] public string $descBrief; /** -- 2.54.0 From c1d05ce47b1edcb0f53b7b66cf2fd6edb1e5acd5 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 14 Aug 2026 15:14:59 +0200 Subject: [PATCH 12/81] fix: update constraints on project update to allow optional updates of properties --- src/Dto/ProjectUpdationDto.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Dto/ProjectUpdationDto.php b/src/Dto/ProjectUpdationDto.php index 85244cb..79adc99 100644 --- a/src/Dto/ProjectUpdationDto.php +++ b/src/Dto/ProjectUpdationDto.php @@ -24,6 +24,7 @@ class ProjectUpdationDto * Main headline for the Project. */ #[Assert\Regex('/[a-zA-Z]{1,}/')] + #[Assert\Length(min: 3)] public string $title; /** @@ -49,25 +50,25 @@ class ProjectUpdationDto /** * Rich-text (markdown allowed) introduction to the project. */ - #[Assert\NotBlank()] + #[Assert\Length(min: 20)] public string $descBrief; /** * Rich-text (markdown allowed) description on the main features of the project. */ - #[Assert\NotBlank()] + #[Assert\Length(min: 20)] public string $descAbout; /** * Rich-text (markdown allowed) about why this project is important. */ - #[Assert\NotBlank()] + #[Assert\Length(min: 20)] public string $descGoal; /** * Rich-text (markdown allowed) about team and previous experience. */ - #[Assert\NotBlank()] + #[Assert\Length(min: 20)] public string $descTeam; /** -- 2.54.0 From e9bb6aa28ff4b43ee4880389c726cc54ca5f8908 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 14 Aug 2026 15:15:22 +0200 Subject: [PATCH 13/81] fix: update Projects test suite to new project shape --- src/Factory/CategoryFactory.php | 2 -- src/Factory/Project/ProjectFactory.php | 10 ++++++++-- tests/Entity/ProjectApi/GetAllTest.php | 2 +- tests/Entity/ProjectApi/GetOneTest.php | 1 - tests/Entity/ProjectApi/ProjectTestCase.php | 2 +- tests/Entity/ProjectApi/UpdateTest.php | 2 +- tests/Entity/ProjectCalendarTest.php | 22 ++++++++++----------- 7 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/Factory/CategoryFactory.php b/src/Factory/CategoryFactory.php index 86d6c15..34e00b3 100644 --- a/src/Factory/CategoryFactory.php +++ b/src/Factory/CategoryFactory.php @@ -16,11 +16,9 @@ final class CategoryFactory extends PersistentProxyObjectFactory } protected function defaults( - string $id = 'test', string $name = 'Test Category', ): array|callable { return [ - 'id' => $id, 'name' => $name, ]; } diff --git a/src/Factory/Project/ProjectFactory.php b/src/Factory/Project/ProjectFactory.php index 56262aa..fcb895d 100644 --- a/src/Factory/Project/ProjectFactory.php +++ b/src/Factory/Project/ProjectFactory.php @@ -41,8 +41,11 @@ final class ProjectFactory extends PersistentProxyObjectFactory { return [ 'deadline' => ProjectDeadline::Minimum, - 'description' => '', 'territory' => new Territory('ES'), + 'descBrief' => 'Brief information on this Project', + 'descAbout' => 'About this Project', + 'descGoal' => 'Project\'s goal', + 'descTeam' => 'Project\'s team', 'owner' => new User(), 'status' => ProjectStatus::InDraft, 'subtitle' => 'Subtitle', @@ -54,7 +57,10 @@ final class ProjectFactory extends PersistentProxyObjectFactory { return [ 'deadline' => self::faker()->randomElement(ProjectDeadline::cases()), - 'description' => self::faker()->text(), + 'descBrief' => self::faker()->text(), + 'descAbout' => self::faker()->text(), + 'descGoal' => self::faker()->text(), + 'descTeam' => self::faker()->text(), 'territory' => new Territory('ES'), 'owner' => UserFactory::createWithMode(), 'status' => self::faker()->randomElement(ProjectStatus::cases()), diff --git a/tests/Entity/ProjectApi/GetAllTest.php b/tests/Entity/ProjectApi/GetAllTest.php index 09b9ee3..8e801c5 100644 --- a/tests/Entity/ProjectApi/GetAllTest.php +++ b/tests/Entity/ProjectApi/GetAllTest.php @@ -249,7 +249,7 @@ class GetAllTest extends ProjectTestCase $partialDescription = 'Physically defined as a modulable space'; $this->testGetAllByParam( - 'description', + 'descBrief', "lorem ipsum $partialDescription", 'lorem ipsum vitae' ); diff --git a/tests/Entity/ProjectApi/GetOneTest.php b/tests/Entity/ProjectApi/GetOneTest.php index fc06954..4c815c5 100644 --- a/tests/Entity/ProjectApi/GetOneTest.php +++ b/tests/Entity/ProjectApi/GetOneTest.php @@ -21,7 +21,6 @@ class GetOneTest extends ProjectTestCase 'title' => $project->getTitle(), 'subtitle' => $project->getSubtitle(), 'territory' => ['country' => $project->getTerritory()->country], - 'description' => $project->getDescription(), 'deadline' => $project->getDeadline()->value, 'status' => $project->getStatus()->value, ]; diff --git a/tests/Entity/ProjectApi/ProjectTestCase.php b/tests/Entity/ProjectApi/ProjectTestCase.php index 5fe1154..5044229 100644 --- a/tests/Entity/ProjectApi/ProjectTestCase.php +++ b/tests/Entity/ProjectApi/ProjectTestCase.php @@ -26,7 +26,7 @@ abstract class ProjectTestCase extends ApiTestCase { self::bootKernel(); - save(CategoryFactory::createOne(['id' => 'test'])); + save(CategoryFactory::createOne(['name' => 'test'])); } // Auxiliary Methods diff --git a/tests/Entity/ProjectApi/UpdateTest.php b/tests/Entity/ProjectApi/UpdateTest.php index d925a33..e7e9560 100644 --- a/tests/Entity/ProjectApi/UpdateTest.php +++ b/tests/Entity/ProjectApi/UpdateTest.php @@ -25,7 +25,7 @@ class UpdateTest extends ProjectTestCase 'headers' => $this->withAuthHeader(TestUser::get()), 'json' => [ 'title' => 'New project title', - 'description' => 'Updated project description', + 'descBrief' => 'Updated Project\'s brief description', ], ]); diff --git a/tests/Entity/ProjectCalendarTest.php b/tests/Entity/ProjectCalendarTest.php index 1bbe5a5..cfc2b4f 100644 --- a/tests/Entity/ProjectCalendarTest.php +++ b/tests/Entity/ProjectCalendarTest.php @@ -6,14 +6,16 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use App\Entity\Project\Project; use App\Entity\Project\ProjectDeadline; use App\Entity\Project\ProjectStatus; -use App\Entity\Territory; use App\Entity\User\User; +use App\Factory\Project\ProjectFactory; use Doctrine\ORM\EntityManagerInterface; +use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; class ProjectCalendarTest extends ApiTestCase { use ResetDatabase; + use Factories; private EntityManagerInterface $entityManager; private User $owner; @@ -40,16 +42,10 @@ class ProjectCalendarTest extends ApiTestCase private function createTestProject(ProjectDeadline $deadline = ProjectDeadline::Minimum): Project { - $project = new Project(); - $project->setTitle('Test Project'); - $project->setSubtitle('Test Project Subtitle'); - $project->setDeadline($deadline); - $project->setDescription('Test Project Description'); - $project->setTerritory(new Territory('ES')); - $project->setOwner($this->owner); - $project->setStatus(ProjectStatus::InCampaignReview); - - return $project; + return ProjectFactory::createOne([ + 'owner' => $this->owner, + 'deadline' => $deadline, + ])->_real(); } private function createProjectAndSetToInCampaign( @@ -104,6 +100,8 @@ class ProjectCalendarTest extends ApiTestCase $this->entityManager->persist($project); $this->entityManager->flush(); - $this->assertNull($project->getCalendar()); + $this->assertNull($project->getCalendar()->release); + $this->assertNull($project->getCalendar()->minimum); + $this->assertNull($project->getCalendar()->optimum); } } -- 2.54.0 From 4f8b0a1361fa7e8e2dfb42afe6092e2349f4d8f1 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 14 Aug 2026 18:13:18 +0200 Subject: [PATCH 14/81] fix: order of DROP operations in migration Version20260813171902 --- migrations/Version20260813171902.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/Version20260813171902.php b/migrations/Version20260813171902.php index 4aa19e8..30f177a 100644 --- a/migrations/Version20260813171902.php +++ b/migrations/Version20260813171902.php @@ -19,8 +19,8 @@ final class Version20260813171902 extends AbstractMigration public function up(Schema $schema): void { - $this->addSql('DROP TABLE IF EXISTS category'); $this->addSql('DROP TABLE IF EXISTS project_category'); + $this->addSql('DROP TABLE IF EXISTS category'); $this->addSql('CREATE TABLE category (id INT AUTO_INCREMENT NOT NULL, slug VARCHAR(56) NOT NULL, name VARCHAR(255) NOT NULL, locales JSON NOT NULL COMMENT \'(DC2Type:json)\', UNIQUE INDEX UNIQ_64C19C1989D9B62 (slug), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE project_category (project_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_3B02921A166D1F9C (project_id), INDEX IDX_3B02921A12469DE2 (category_id), PRIMARY KEY(project_id, category_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('ALTER TABLE project_category ADD CONSTRAINT FK_3B02921A166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); @@ -29,8 +29,8 @@ final class Version20260813171902 extends AbstractMigration public function down(Schema $schema): void { - $this->addSql('DROP TABLE IF EXISTS category'); $this->addSql('DROP TABLE IF EXISTS project_category'); + $this->addSql('DROP TABLE IF EXISTS category'); $this->addSql('CREATE TABLE category (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, locales JSON NOT NULL COMMENT \'(DC2Type:json)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE project_category (project_id INT NOT NULL, category_id VARCHAR(255) NOT NULL, INDEX IDX_3B02921A166D1F9C (project_id), INDEX IDX_3B02921A12469DE2 (category_id), PRIMARY KEY(project_id, category_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('ALTER TABLE project_category ADD CONSTRAINT FK_3B02921A166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); -- 2.54.0 From 799112a3c21ff890cb81a3140b5ac315799f7ae3 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 14 Aug 2026 20:40:44 +0200 Subject: [PATCH 15/81] fix: move accounting balance and checkout to supports listeners to onFlush events --- .../AccountingBalanceListener.php | 41 ++++++-- .../CheckoutSupportsListener.php | 80 ---------------- .../CheckoutToSupportsListener.php | 94 +++++++++++++++++++ 3 files changed, 125 insertions(+), 90 deletions(-) delete mode 100644 src/EventListener/CheckoutSupportsListener.php create mode 100644 src/EventListener/CheckoutToSupportsListener.php diff --git a/src/EventListener/AccountingBalanceListener.php b/src/EventListener/AccountingBalanceListener.php index efd3bc8..1713ae9 100644 --- a/src/EventListener/AccountingBalanceListener.php +++ b/src/EventListener/AccountingBalanceListener.php @@ -5,21 +5,38 @@ namespace App\EventListener; use App\Entity\Accounting\Transaction; use App\Entity\EmbeddableMoney; use App\Money\MoneyService; -use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; -use Doctrine\ORM\Event\PostPersistEventArgs; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Events; -#[AsEntityListener( - entity: Transaction::class, - event: Events::postPersist -)] +#[AsDoctrineListener(event: Events::onFlush)] class AccountingBalanceListener { public function __construct( private MoneyService $moneyService, ) {} - public function postPersist(Transaction $transaction, PostPersistEventArgs $event): void + public function onFlush(OnFlushEventArgs $event): void + { + $em = $event->getObjectManager(); + + if (!$em instanceof EntityManagerInterface) { + return; + } + + $uow = $em->getUnitOfWork(); + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if (!$entity instanceof Transaction) { + continue; + } + + $this->updateBalances($em, $entity); + } + } + + private function updateBalances(EntityManagerInterface $em, Transaction $transaction): void { $money = $transaction->getMoney(); @@ -33,8 +50,12 @@ class AccountingBalanceListener $this->moneyService->add($money, $target->getBalance()) )); - $event->getObjectManager()->persist($origin); - $event->getObjectManager()->persist($target); - $event->getObjectManager()->flush(); + $uow = $em->getUnitOfWork(); + + $originMetadata = $em->getClassMetadata($origin::class); + $targetMetadata = $em->getClassMetadata($target::class); + + $uow->computeChangeSet($originMetadata, $origin); + $uow->computeChangeSet($targetMetadata, $target); } } diff --git a/src/EventListener/CheckoutSupportsListener.php b/src/EventListener/CheckoutSupportsListener.php deleted file mode 100644 index 6ae1fd2..0000000 --- a/src/EventListener/CheckoutSupportsListener.php +++ /dev/null @@ -1,80 +0,0 @@ -hasChangedField('status')) { - return; - } - - if ($event->getNewValue('status') !== CheckoutStatus::Charged) { - return; - } - - $supports = $this->prepareSupports($checkout); - - foreach ($supports as $support) { - $this->computeChangeSet($event->getObjectManager(), $support); - } - } - - /** - * @return Support[] - */ - public function prepareSupports(Checkout $checkout): array - { - /** @var Charge[] */ - $charges = $checkout->getCharges()->toArray(); - $origin = $checkout->getOrigin(); - - $projects = []; - $transactionsByProject = []; - foreach ($charges as $charge) { - $project = $charge->getTarget()->getProject(); - - if (!$project) { - continue; - } - - $projectId = $project->getId(); - - $projects[$projectId] = $project; - $transactionsByProject[$projectId] = [ - ...$transactionsByProject[$projectId] ?? [], - ...$charge->getTransactions(), - ]; - } - - $supports = []; - foreach ($transactionsByProject as $projectId => $transactions) { - $support = $this->supportService->getSupport($projects[$projectId], $origin); - $support = $this->supportService->withTransactions($support, $transactions); - $supports[] = $support; - } - - return $supports; - } -} diff --git a/src/EventListener/CheckoutToSupportsListener.php b/src/EventListener/CheckoutToSupportsListener.php new file mode 100644 index 0000000..c02ccdd --- /dev/null +++ b/src/EventListener/CheckoutToSupportsListener.php @@ -0,0 +1,94 @@ +getObjectManager(); + + if (!$em instanceof EntityManagerInterface) { + return; + } + + $uow = $em->getUnitOfWork(); + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if (!$entity instanceof Checkout) { + continue; + } + + $changeSet = $uow->getEntityChangeSet($entity); + + if (!isset($changeSet['status'])) { + continue; + } + + [, $newStatus] = $changeSet['status']; + + if ($newStatus !== CheckoutStatus::Charged->value) { + continue; + } + + $this->updateSupports($em, $entity); + } + } + + private function updateSupports(EntityManagerInterface $em, Checkout $checkout): void + { + $origin = $checkout->getOrigin(); + + /** @var Charge[] $charges */ + $charges = $checkout->getCharges()->toArray(); + + /** @var array $projects */ + $projects = []; + + /** @var array $transactionsByProject */ + $transactionsByProject = []; + + foreach ($charges as $charge) { + $project = $charge->getTarget()->getProject(); + if ($project === null) { + continue; + } + + $projectId = $project->getId(); + + $projects[$projectId] = $project; + + $transactionsByProject[$projectId] ??= []; + + foreach ($charge->getTransactions() as $transaction) { + $transactionsByProject[$projectId][] = $transaction; + } + } + + $uow = $em->getUnitOfWork(); + $metadata = $em->getClassMetadata(Support::class); + + foreach ($projects as $projectId => $project) { + $support = $this->supportService->getSupport($project, $origin); + $support = $this->supportService->withTransactions($support, $transactionsByProject[$projectId] ?? []); + + $em->persist($support); + $uow->computeChangeSet($metadata, $support); + } + } +} -- 2.54.0 From 4e60b980dc362221ec30821f78ddb5590188b489 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Mon, 17 Aug 2026 15:04:10 +0200 Subject: [PATCH 16/81] fix: bad order of filter safeguards --- src/Filter/EncryptedSearchFilter.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Filter/EncryptedSearchFilter.php b/src/Filter/EncryptedSearchFilter.php index 89708eb..bcb8417 100644 --- a/src/Filter/EncryptedSearchFilter.php +++ b/src/Filter/EncryptedSearchFilter.php @@ -39,16 +39,16 @@ final class EncryptedSearchFilter extends AbstractFilter array $context = [], ): void { if ( - !$this->isFilteringGranted($this->properties[$property]) - || !$this->isPropertyEnabled($property, $resourceClass) + !$this->isPropertyEnabled($property, $resourceClass) || !$this->isPropertyMapped($property, $resourceClass) + || !$this->isFilteringGranted($this->properties[$property]) ) { return; } $values = (array) $value; $rootAlias = $queryBuilder->getRootAliases()[0]; - $parameterName = ':'.$queryNameGenerator->generateParameterName($property); + $parameterName = ':' . $queryNameGenerator->generateParameterName($property); $alias = $rootAlias; if ($this->isPropertyNested($property, $resourceClass)) { @@ -87,7 +87,7 @@ final class EncryptedSearchFilter extends AbstractFilter 'is_collection' => false, ]; - $description[$property.'[]'] = [ + $description[$property . '[]'] = [ 'property' => $property, 'type' => Type::BUILTIN_TYPE_STRING, 'required' => false, @@ -96,7 +96,7 @@ final class EncryptedSearchFilter extends AbstractFilter if ($strategy !== null) { $description[$property]['description'] = \sprintf('Secured by `%s`', $strategy); - $description[$property.'[]']['description'] = \sprintf('Secured by `%s`', $strategy); + $description[$property . '[]']['description'] = \sprintf('Secured by `%s`', $strategy); } } -- 2.54.0 From e1c9b295930e33d45211b699c56f196faf6bbf66 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Mon, 17 Aug 2026 15:05:18 +0200 Subject: [PATCH 17/81] chore: code style by php-cs-fixer --- src/Filter/EncryptedSearchFilter.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Filter/EncryptedSearchFilter.php b/src/Filter/EncryptedSearchFilter.php index bcb8417..d0e3de3 100644 --- a/src/Filter/EncryptedSearchFilter.php +++ b/src/Filter/EncryptedSearchFilter.php @@ -48,7 +48,7 @@ final class EncryptedSearchFilter extends AbstractFilter $values = (array) $value; $rootAlias = $queryBuilder->getRootAliases()[0]; - $parameterName = ':' . $queryNameGenerator->generateParameterName($property); + $parameterName = ':'.$queryNameGenerator->generateParameterName($property); $alias = $rootAlias; if ($this->isPropertyNested($property, $resourceClass)) { @@ -87,7 +87,7 @@ final class EncryptedSearchFilter extends AbstractFilter 'is_collection' => false, ]; - $description[$property . '[]'] = [ + $description[$property.'[]'] = [ 'property' => $property, 'type' => Type::BUILTIN_TYPE_STRING, 'required' => false, @@ -96,7 +96,7 @@ final class EncryptedSearchFilter extends AbstractFilter if ($strategy !== null) { $description[$property]['description'] = \sprintf('Secured by `%s`', $strategy); - $description[$property . '[]']['description'] = \sprintf('Secured by `%s`', $strategy); + $description[$property.'[]']['description'] = \sprintf('Secured by `%s`', $strategy); } } -- 2.54.0 From 492a211d024e9a8dd8184d7d0b2b1bfff8a1b82e Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Mon, 17 Aug 2026 16:12:03 +0200 Subject: [PATCH 18/81] fix: prevent uninitialized fields on api resource from breaking AccountingVoter --- src/Security/Voter/AccountingVoter.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Security/Voter/AccountingVoter.php b/src/Security/Voter/AccountingVoter.php index d8f1af3..5651367 100644 --- a/src/Security/Voter/AccountingVoter.php +++ b/src/Security/Voter/AccountingVoter.php @@ -2,10 +2,10 @@ namespace App\Security\Voter; -use ApiPlatform\Metadata\IriConverterInterface; use App\ApiResource\Accounting\AccountingApiResource; use App\ApiResource\User\UserApiResource; use App\Entity\User\User; +use App\Repository\Accounting\AccountingRepository; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -20,7 +20,7 @@ class AccountingVoter extends Voter public function __construct( private Security $security, - private IriConverterInterface $iriConverter, + private AccountingRepository $accountingRepository, ) {} protected function supports(string $attribute, mixed $subject): bool @@ -35,16 +35,14 @@ class AccountingVoter extends Voter protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { $user = $token->getUser(); - $owner = $this->iriConverter->getResourceFromIri($subject->owner); + $accounting = $this->accountingRepository->find($subject->id); - switch ($owner::class) { + switch ($accounting->getOwnerClass()) { case UserApiResource::class: - return $this->voteOnUser($attribute, $owner, $user); + return $this->voteOnUser($attribute, $accounting->getOwner(), $user); default: return $this->voteOn($attribute, $subject, $user); } - - return false; } private function voteOn(string $attribute, mixed $subject, ?UserInterface $user): bool @@ -60,7 +58,7 @@ class AccountingVoter extends Voter return false; } - private function voteOnUser(string $attribute, UserApiResource $owner, ?UserInterface $user): bool + private function voteOnUser(string $attribute, User $owner, ?UserInterface $user): bool { if (!$user instanceof User) { return false; -- 2.54.0 From 4b7648dc99aec36925b7c0540a435ae913d87bef Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Mon, 17 Aug 2026 17:08:42 +0200 Subject: [PATCH 19/81] fix: small bug preventing code improvements on Accounting api resource --- src/ApiResource/Accounting/AccountingApiResource.php | 2 +- src/Security/Voter/AccountingVoter.php | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/ApiResource/Accounting/AccountingApiResource.php b/src/ApiResource/Accounting/AccountingApiResource.php index cd65e2f..a61f455 100644 --- a/src/ApiResource/Accounting/AccountingApiResource.php +++ b/src/ApiResource/Accounting/AccountingApiResource.php @@ -28,9 +28,9 @@ use AutoMapper\Attribute\MapTo; )] #[API\GetCollection()] #[API\Get()] -#[API\Patch(security: 'is_granted("ACCOUNTING_EDIT", object)')] class AccountingApiResource { + #[API\ApiProperty(identifier: true)] public int $id; /** diff --git a/src/Security/Voter/AccountingVoter.php b/src/Security/Voter/AccountingVoter.php index 5651367..82e97a8 100644 --- a/src/Security/Voter/AccountingVoter.php +++ b/src/Security/Voter/AccountingVoter.php @@ -3,7 +3,6 @@ namespace App\Security\Voter; use App\ApiResource\Accounting\AccountingApiResource; -use App\ApiResource\User\UserApiResource; use App\Entity\User\User; use App\Repository\Accounting\AccountingRepository; use Symfony\Bundle\SecurityBundle\Security; @@ -38,10 +37,10 @@ class AccountingVoter extends Voter $accounting = $this->accountingRepository->find($subject->id); switch ($accounting->getOwnerClass()) { - case UserApiResource::class: + case User::class: return $this->voteOnUser($attribute, $accounting->getOwner(), $user); default: - return $this->voteOn($attribute, $subject, $user); + return $this->voteOn($attribute, $accounting, $user); } } @@ -51,11 +50,9 @@ class AccountingVoter extends Voter case self::EDIT: return $this->security->isGranted('ROLE_ADMIN', $user) || $this->isOwnerOf($subject, $user); - case self::VIEW: + default: return true; } - - return false; } private function voteOnUser(string $attribute, User $owner, ?UserInterface $user): bool -- 2.54.0 From 8b7f480e466daca997551c866a52b38dd34a395c Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 18 Aug 2026 10:53:42 +0200 Subject: [PATCH 20/81] feat: add cover property on ProjectReward resource --- src/ApiResource/Project/RewardApiResource.php | 6 ++++++ src/Entity/Project/Reward.php | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/ApiResource/Project/RewardApiResource.php b/src/ApiResource/Project/RewardApiResource.php index 4f1ced8..6b2dfca 100644 --- a/src/ApiResource/Project/RewardApiResource.php +++ b/src/ApiResource/Project/RewardApiResource.php @@ -57,6 +57,12 @@ class RewardApiResource #[MapTo(if: 'source.description != null')] public ?string $description = null; + /** + * URL to an image resource to be displayed as header. + */ + #[Assert\Url()] + public string $cover; + /** * The minimal monetary sum to be able to claim this reward. */ diff --git a/src/Entity/Project/Reward.php b/src/Entity/Project/Reward.php index b01d9e5..4b4c07d 100644 --- a/src/Entity/Project/Reward.php +++ b/src/Entity/Project/Reward.php @@ -47,6 +47,9 @@ class Reward implements LocalizedInterface #[Gedmo\Translatable()] private ?string $description = null; + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $cover = null; + /** * The minimal monetary sum to be able to claim this reward. */ @@ -129,6 +132,18 @@ class Reward implements LocalizedInterface return $this; } + public function getCover(): ?string + { + return $this->cover; + } + + public function setCover(?string $cover): static + { + $this->cover = $cover; + + return $this; + } + public function getMoney(): ?Money { return $this->money; -- 2.54.0 From 5965f65c4ace6d553a520eb7da474a6e7dd7fa94 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 18 Aug 2026 10:58:45 +0200 Subject: [PATCH 21/81] feat: add cover property to Project resources --- .../Project/ProjectApiResource.php | 6 ++ src/Dto/ProjectCreationDto.php | 6 ++ src/Dto/ProjectUpdationDto.php | 6 ++ src/Entity/Project/Project.php | 99 +++++++++++-------- 4 files changed, 75 insertions(+), 42 deletions(-) diff --git a/src/ApiResource/Project/ProjectApiResource.php b/src/ApiResource/Project/ProjectApiResource.php index 3a850f8..6db3e61 100644 --- a/src/ApiResource/Project/ProjectApiResource.php +++ b/src/ApiResource/Project/ProjectApiResource.php @@ -103,6 +103,12 @@ class ProjectApiResource #[Assert\NotBlank()] public string $subtitle; + /** + * URL to an image resource to be displayed as header. + */ + #[Assert\Url()] + public string $cover; + /** * On `minimum`, Project will campaign until the minimum deadline.\ * On `optimum`, Project will campaing until the minimum deadline, diff --git a/src/Dto/ProjectCreationDto.php b/src/Dto/ProjectCreationDto.php index eb0175b..94893a5 100644 --- a/src/Dto/ProjectCreationDto.php +++ b/src/Dto/ProjectCreationDto.php @@ -25,6 +25,12 @@ class ProjectCreationDto #[Assert\NotBlank()] public string $subtitle; + /** + * URL to an image resource to be displayed as header. + */ + #[Assert\Url()] + public string $cover; + /** * List of Categories. * diff --git a/src/Dto/ProjectUpdationDto.php b/src/Dto/ProjectUpdationDto.php index 79adc99..44a64c8 100644 --- a/src/Dto/ProjectUpdationDto.php +++ b/src/Dto/ProjectUpdationDto.php @@ -32,6 +32,12 @@ class ProjectUpdationDto */ public string $subtitle; + /** + * URL to an image resource to be displayed as header. + */ + #[Assert\Url()] + public string $cover; + /** * List of Categories. * diff --git a/src/Entity/Project/Project.php b/src/Entity/Project/Project.php index b6c9b91..4281ef6 100644 --- a/src/Entity/Project/Project.php +++ b/src/Entity/Project/Project.php @@ -42,6 +42,24 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized #[ORM\Column] private ?int $id = null; + #[ORM\Column(length: 56, unique: true)] + #[Gedmo\Slug(fields: ['title'])] + private ?string $slug = null; + + /** + * Since Projects can be recipients of funding, they are assigned an Accounting when created. + * A Project's Accounting represents how much money the Project has raised from the community. + */ + #[ORM\OneToOne(inversedBy: 'project', cascade: ['persist'])] + private ?Accounting $accounting = null; + + /** + * The User who created this Project. + */ + #[ORM\ManyToOne(inversedBy: 'projects', cascade: ['persist'])] + #[ORM\JoinColumn(nullable: false)] + private ?User $owner = null; + /** * The main title for the project. */ @@ -49,10 +67,6 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized #[Gedmo\Translatable()] private ?string $title = null; - #[ORM\Column(length: 56, unique: true)] - #[Gedmo\Slug(fields: ['title'])] - private ?string $slug = null; - /** * Secondary head-line for the project. */ @@ -60,6 +74,9 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized #[Gedmo\Translatable()] private ?string $subtitle = null; + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $cover = null; + #[ORM\Column(enumType: ProjectDeadline::class)] private ?ProjectDeadline $deadline = ProjectDeadline::Minimum; @@ -100,20 +117,6 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized #[ORM\Embedded(class: ProjectVideo::class)] private ?ProjectVideo $video = null; - /** - * Since Projects can be recipients of funding, they are assigned an Accounting when created. - * A Project's Accounting represents how much money the Project has raised from the community. - */ - #[ORM\OneToOne(inversedBy: 'project', cascade: ['persist'])] - private ?Accounting $accounting = null; - - /** - * The User who created this Project. - */ - #[ORM\ManyToOne(inversedBy: 'projects', cascade: ['persist'])] - #[ORM\JoinColumn(nullable: false)] - private ?User $owner = null; - /** * The status of this Project as it goes through it's life-cycle. * Projects have a start and an end, and in the meantime they go through different phases represented under this status. @@ -190,18 +193,6 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this; } - public function getTitle(): ?string - { - return $this->title; - } - - public function setTitle(string $title): static - { - $this->title = $title; - - return $this; - } - public function getSlug(): ?string { return $this->slug; @@ -214,6 +205,30 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this; } + public function getAccounting(): ?Accounting + { + return $this->accounting; + } + + public function setAccounting(?Accounting $accounting): static + { + $this->accounting = $accounting; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + public function getSubtitle(): ?string { return $this->subtitle; @@ -226,6 +241,18 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this; } + public function getCover(): ?string + { + return $this->cover; + } + + public function setCover(?string $cover): static + { + $this->cover = $cover; + + return $this; + } + public function getDeadline(): ?ProjectDeadline { return $this->deadline; @@ -346,18 +373,6 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this; } - public function getAccounting(): ?Accounting - { - return $this->accounting; - } - - public function setAccounting(?Accounting $accounting): static - { - $this->accounting = $accounting; - - return $this; - } - public function getStatus(): ProjectStatus { return $this->status; -- 2.54.0 From ee61072c6a97edb80335ba9b88193b4b34581cc8 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 18 Aug 2026 11:02:36 +0200 Subject: [PATCH 22/81] feat: use video cover for project cover --- src/Benzina/ProjectsPump.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index a53a9af..bd7665e 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -74,7 +74,6 @@ class ProjectsPump implements PumpInterface $project->setSlug($record['id']); $project->setTerritory($this->getProjectTerritory($record)); - $project->setVideo($this->getProjectVideo($record)); $project->setOwner($owner); $project->setStatus($status); $project->setMigrated(true); @@ -84,6 +83,11 @@ class ProjectsPump implements PumpInterface $project->setTranslatableLocale($record['lang']); $project->setUpdates(new ArrayCollection($this->getProjectUpdates($project, $context))); + $video = $this->getProjectVideo($record); + + $project->setVideo($video); + $project->setCover($video->cover); + $conf = $this->getProjectConf($project, $context); $project->setDeadline($this->getProjectDeadline($conf)); -- 2.54.0 From 7a6a2628d7a9c02c064def055edeec6499091826 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 18 Aug 2026 11:07:37 +0200 Subject: [PATCH 23/81] feat: add migration for property cover on project and rewards --- migrations/Version20260818090559.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 migrations/Version20260818090559.php diff --git a/migrations/Version20260818090559.php b/migrations/Version20260818090559.php new file mode 100644 index 0000000..3d6590d --- /dev/null +++ b/migrations/Version20260818090559.php @@ -0,0 +1,28 @@ +addSql('ALTER TABLE project ADD cover LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE project_reward ADD cover LONGTEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_reward DROP cover'); + $this->addSql('ALTER TABLE project DROP cover'); + } +} -- 2.54.0 From 80ac9684aba933791a49bcc6a7e5a9f0eb2e613e Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 18 Aug 2026 11:14:56 +0200 Subject: [PATCH 24/81] fix: small improvements on UserOwned helpers --- src/Entity/UserOwnedInterface.php | 4 ++-- src/Entity/UserOwnedTrait.php | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Entity/UserOwnedInterface.php b/src/Entity/UserOwnedInterface.php index bbcf213..e2e998e 100644 --- a/src/Entity/UserOwnedInterface.php +++ b/src/Entity/UserOwnedInterface.php @@ -12,14 +12,14 @@ interface UserOwnedInterface public function getOwner(): ?User; /** - * @param User|null The User who is the owner of this entity, or null to remove ownership + * @param User|null $owner The User who is the owner of this entity, or null to remove ownership */ public function setOwner(?User $owner): static; /** * Determines if the given User is the owner of this entity. * - * @param User The User to check ownership against + * @param User $user The User to check ownership against */ public function isOwnedBy(User $user): bool; } diff --git a/src/Entity/UserOwnedTrait.php b/src/Entity/UserOwnedTrait.php index b706a54..3e54414 100644 --- a/src/Entity/UserOwnedTrait.php +++ b/src/Entity/UserOwnedTrait.php @@ -4,6 +4,9 @@ namespace App\Entity; use App\Entity\User\User; +/** + * @property ?User $owner + */ trait UserOwnedTrait { public function getOwner(): ?User @@ -20,6 +23,6 @@ trait UserOwnedTrait public function isOwnedBy(User $user): bool { - return $this->getOwner()->getId() === $user->getId(); + return $this->getOwner()?->getId() === $user->getId(); } } -- 2.54.0 From 6d2389125fc82078c473b3b71bad46ce4b3b41af Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 25 Aug 2026 12:22:43 +0200 Subject: [PATCH 25/81] feat: add territory to project creation --- src/Dto/ProjectCreationDto.php | 14 ++++++++------ src/Entity/Territory.php | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Dto/ProjectCreationDto.php b/src/Dto/ProjectCreationDto.php index 94893a5..35facab 100644 --- a/src/Dto/ProjectCreationDto.php +++ b/src/Dto/ProjectCreationDto.php @@ -6,6 +6,7 @@ use ApiPlatform\Metadata as API; use App\ApiResource\CategoryApiResource; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectStatus; +use App\Entity\Territory; use Symfony\Component\Validator\Constraints as Assert; class ProjectCreationDto @@ -17,6 +18,7 @@ class ProjectCreationDto */ #[Assert\NotBlank()] #[Assert\Regex('/[a-zA-Z]{1,}/')] + #[Assert\Length(min: 3)] public string $title; /** @@ -25,12 +27,6 @@ class ProjectCreationDto #[Assert\NotBlank()] public string $subtitle; - /** - * URL to an image resource to be displayed as header. - */ - #[Assert\Url()] - public string $cover; - /** * List of Categories. * @@ -41,6 +37,12 @@ class ProjectCreationDto #[API\ApiProperty(writableLink: false, openapiContext: self::CATEGORIES_OPENAPI_CONTEXT)] public array $categories; + /** + * ISO 3166 data about the Project's territory of interest. + */ + #[Assert\Valid()] + public Territory $territory; + /** * Deadlines and important Project dates. */ diff --git a/src/Entity/Territory.php b/src/Entity/Territory.php index ca444b1..dd82d5d 100644 --- a/src/Entity/Territory.php +++ b/src/Entity/Territory.php @@ -16,6 +16,7 @@ class Territory * ISO 3166-1 alpha-2 two-letter country code.\ * e.g: ES (Spain). */ + #[Assert\NotBlank()] #[Assert\Country(alpha3: false)] #[ORM\Column(type: Types::STRING, nullable: true)] public readonly ?string $country; -- 2.54.0 From 31af0cb72bb0892dd3427848259af0aa1423c531 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 25 Aug 2026 13:25:17 +0200 Subject: [PATCH 26/81] feat: mark creation fields as required --- src/Dto/ProjectCreationDto.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Dto/ProjectCreationDto.php b/src/Dto/ProjectCreationDto.php index 35facab..1965143 100644 --- a/src/Dto/ProjectCreationDto.php +++ b/src/Dto/ProjectCreationDto.php @@ -41,12 +41,14 @@ class ProjectCreationDto * ISO 3166 data about the Project's territory of interest. */ #[Assert\Valid()] + #[Assert\NotBlank()] public Territory $territory; /** * Deadlines and important Project dates. */ #[Assert\Valid()] + #[Assert\NotBlank()] public ProjectCalendar $calendar; #[API\ApiProperty(writable: false)] -- 2.54.0 From 5c54d1bf5b073bcd5081cc6dd66c24b214288bc0 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 26 Aug 2026 11:03:37 +0200 Subject: [PATCH 27/81] feat: add CollaborationCandidacy resource --- migrations/Version20260826085355.php | 30 ++++++ .../CollaborationCandidacyApiResource.php | 63 +++++++++++++ src/Entity/Project/Collaboration.php | 43 +++++++++ src/Entity/Project/CollaborationCandidacy.php | 93 +++++++++++++++++++ .../Project/CollaborationCandidacyStatus.php | 26 ++++++ src/OpenApi/RedocExtensionsTrait.php | 1 + .../CollaborationCandidacyRepository.php | 43 +++++++++ 7 files changed, 299 insertions(+) create mode 100644 migrations/Version20260826085355.php create mode 100644 src/ApiResource/Project/CollaborationCandidacyApiResource.php create mode 100644 src/Entity/Project/CollaborationCandidacy.php create mode 100644 src/Entity/Project/CollaborationCandidacyStatus.php create mode 100644 src/Repository/Project/CollaborationCandidacyRepository.php diff --git a/migrations/Version20260826085355.php b/migrations/Version20260826085355.php new file mode 100644 index 0000000..74c6939 --- /dev/null +++ b/migrations/Version20260826085355.php @@ -0,0 +1,30 @@ +addSql('CREATE TABLE project_collaboration_candidacy (id INT AUTO_INCREMENT NOT NULL, collaboration_id INT NOT NULL, user_id INT NOT NULL, description LONGTEXT NOT NULL, status VARCHAR(255) NOT NULL, date_created DATETIME NOT NULL, date_updated DATETIME NOT NULL, INDEX IDX_378F2D4AEF1544CE (collaboration_id), INDEX IDX_378F2D4AA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE project_collaboration_candidacy ADD CONSTRAINT FK_378F2D4AEF1544CE FOREIGN KEY (collaboration_id) REFERENCES project_collaboration (id)'); + $this->addSql('ALTER TABLE project_collaboration_candidacy ADD CONSTRAINT FK_378F2D4AA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_collaboration_candidacy DROP FOREIGN KEY FK_378F2D4AEF1544CE'); + $this->addSql('ALTER TABLE project_collaboration_candidacy DROP FOREIGN KEY FK_378F2D4AA76ED395'); + $this->addSql('DROP TABLE project_collaboration_candidacy'); + } +} diff --git a/src/ApiResource/Project/CollaborationCandidacyApiResource.php b/src/ApiResource/Project/CollaborationCandidacyApiResource.php new file mode 100644 index 0000000..c1b22db --- /dev/null +++ b/src/ApiResource/Project/CollaborationCandidacyApiResource.php @@ -0,0 +1,63 @@ + + */ + #[ORM\OneToMany(targetEntity: CollaborationCandidacy::class, mappedBy: 'collaboration')] + private Collection $candidacies; + + public function __construct() + { + $this->candidacies = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -94,4 +107,34 @@ class Collaboration implements LocalizedInterface return $this; } + + /** + * @return Collection + */ + public function getCandidacies(): Collection + { + return $this->candidacies; + } + + public function addCandidacy(CollaborationCandidacy $candidacy): static + { + if (!$this->candidacies->contains($candidacy)) { + $this->candidacies->add($candidacy); + $candidacy->setCollaboration($this); + } + + return $this; + } + + public function removeCandidacy(CollaborationCandidacy $candidacy): static + { + if ($this->candidacies->removeElement($candidacy)) { + // set the owning side to null (unless already changed) + if ($candidacy->getCollaboration() === $this) { + $candidacy->setCollaboration(null); + } + } + + return $this; + } } diff --git a/src/Entity/Project/CollaborationCandidacy.php b/src/Entity/Project/CollaborationCandidacy.php new file mode 100644 index 0000000..46a22b2 --- /dev/null +++ b/src/Entity/Project/CollaborationCandidacy.php @@ -0,0 +1,93 @@ +id; + } + + public function getCollaboration(): ?Collaboration + { + return $this->collaboration; + } + + public function setCollaboration(?Collaboration $collaboration): static + { + $this->collaboration = $collaboration; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(string $description): static + { + $this->description = $description; + + return $this; + } + + public function getStatus(): ?CollaborationCandidacyStatus + { + return $this->status; + } + + public function setStatus(CollaborationCandidacyStatus $status): static + { + $this->status = $status; + + return $this; + } +} diff --git a/src/Entity/Project/CollaborationCandidacyStatus.php b/src/Entity/Project/CollaborationCandidacyStatus.php new file mode 100644 index 0000000..2c2f5f0 --- /dev/null +++ b/src/Entity/Project/CollaborationCandidacyStatus.php @@ -0,0 +1,26 @@ + + */ +class CollaborationCandidacyRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CollaborationCandidacy::class); + } + + // /** + // * @return CollaborationCandidacy[] Returns an array of CollaborationCandidacy objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('c') + // ->andWhere('c.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('c.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?CollaborationCandidacy + // { + // return $this->createQueryBuilder('c') + // ->andWhere('c.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} -- 2.54.0 From 4d4de0fc7bf713af62f521d38e53d1e26e365175 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 26 Aug 2026 11:21:46 +0200 Subject: [PATCH 28/81] fix: update old validations access of new types --- src/Validator/CountrySubdivisionValidator.php | 4 ++-- src/Validator/EnoughRewardChargeValidator.php | 3 ++- src/Validator/SameProjectRewardChargeValidator.php | 6 +++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Validator/CountrySubdivisionValidator.php b/src/Validator/CountrySubdivisionValidator.php index 3387367..79ed3a0 100644 --- a/src/Validator/CountrySubdivisionValidator.php +++ b/src/Validator/CountrySubdivisionValidator.php @@ -10,7 +10,7 @@ use Symfony\Component\Validator\ConstraintValidator; final class CountrySubdivisionValidator extends ConstraintValidator { /** - * @param CountrySubdivion $constraint + * @param CountrySubdivision $constraint */ public function validate(mixed $value, Constraint $constraint): void { @@ -31,7 +31,7 @@ final class CountrySubdivisionValidator extends ConstraintValidator /** * Check if a a string is an actual ISO 3166-2 value. * - * @return `true` if the string is a recognized territory + * @return bool `true` if the string is a recognized territory */ public static function validateISO3166_2(string $iso3166_2): bool { diff --git a/src/Validator/EnoughRewardChargeValidator.php b/src/Validator/EnoughRewardChargeValidator.php index 6880951..c992fba 100644 --- a/src/Validator/EnoughRewardChargeValidator.php +++ b/src/Validator/EnoughRewardChargeValidator.php @@ -3,6 +3,7 @@ namespace App\Validator; use App\ApiResource\Project\RewardClaimApiResource; +use App\Money\Money; use App\Money\MoneyService; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -20,7 +21,7 @@ final class EnoughRewardChargeValidator extends ConstraintValidator public function validate(mixed $value, Constraint $constraint): void { $charge = $value->charge->money; - $reward = $value->reward->money; + $reward = new Money($value->reward->money->amount, $value->reward->money->currency); if ($this->money->isMoreOrSame($charge, $reward)) { return; diff --git a/src/Validator/SameProjectRewardChargeValidator.php b/src/Validator/SameProjectRewardChargeValidator.php index 0776d30..6d933df 100644 --- a/src/Validator/SameProjectRewardChargeValidator.php +++ b/src/Validator/SameProjectRewardChargeValidator.php @@ -3,6 +3,7 @@ namespace App\Validator; use App\ApiResource\Project\RewardClaimApiResource; +use App\Entity\Project\Project; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -16,7 +17,10 @@ final class SameProjectRewardChargeValidator extends ConstraintValidator $charge = $value->charge; $reward = $value->reward; - if ($charge->target->project?->id === $reward->project->id) { + if ( + $charge->target->ownerObject instanceof Project + && $charge->target->ownerObject?->getId() === $reward->project->id + ) { return; } -- 2.54.0 From 40ff4b5ca4bd9e2dfb8028afde72f7bc487368ab Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 26 Aug 2026 11:52:03 +0200 Subject: [PATCH 29/81] feat: add validation for charges targetting projects not in campaign --- .../Gateway/CheckoutApiResource.php | 2 + src/Dto/Gateway/CheckoutCreationDto.php | 2 + src/Validator/ChargeToProjectInCampaign.php | 11 +++++ .../ChargeToProjectInCampaignValidator.php | 46 +++++++++++++++++++ 4 files changed, 61 insertions(+) create mode 100644 src/Validator/ChargeToProjectInCampaign.php create mode 100644 src/Validator/ChargeToProjectInCampaignValidator.php diff --git a/src/ApiResource/Gateway/CheckoutApiResource.php b/src/ApiResource/Gateway/CheckoutApiResource.php index 6d48369..31f6f36 100644 --- a/src/ApiResource/Gateway/CheckoutApiResource.php +++ b/src/ApiResource/Gateway/CheckoutApiResource.php @@ -17,6 +17,7 @@ use App\Gateway\Tracking; use App\Mapping\Transformer\GatewayIdMapTransformer; use App\State\ApiResourceStateProvider; use App\State\Gateway\CheckoutStateProcessor; +use App\Validator\ChargeToProjectInCampaign; use AutoMapper\Attribute\MapFrom; use AutoMapper\Attribute\MapTo; use Symfony\Component\Validator\Constraints as Assert; @@ -70,6 +71,7 @@ class CheckoutApiResource #[API\ApiProperty(readableLink: true, writableLink: true)] #[Assert\NotBlank()] #[Assert\Count(min: 1)] + #[Assert\All([new ChargeToProjectInCampaign()])] public array $charges = []; /** diff --git a/src/Dto/Gateway/CheckoutCreationDto.php b/src/Dto/Gateway/CheckoutCreationDto.php index b13a963..7ab8980 100644 --- a/src/Dto/Gateway/CheckoutCreationDto.php +++ b/src/Dto/Gateway/CheckoutCreationDto.php @@ -8,6 +8,7 @@ use App\ApiResource\Gateway\GatewayApiResource; use App\Entity\Gateway\Checkout; use App\Gateway\RefundStrategy; use App\Mapping\Transformer\GatewayIdMapTransformer; +use App\Validator\ChargeToProjectInCampaign; use AutoMapper\Attribute\MapFrom; use AutoMapper\Attribute\MapTo; use Symfony\Component\Validator\Constraints as Assert; @@ -37,6 +38,7 @@ class CheckoutCreationDto #[API\ApiProperty(readableLink: true, writableLink: true)] #[Assert\NotBlank()] #[Assert\Count(min: 1)] + #[Assert\All([new ChargeToProjectInCampaign()])] public array $charges = []; /** diff --git a/src/Validator/ChargeToProjectInCampaign.php b/src/Validator/ChargeToProjectInCampaign.php new file mode 100644 index 0000000..b47b38f --- /dev/null +++ b/src/Validator/ChargeToProjectInCampaign.php @@ -0,0 +1,11 @@ +mapper->map($value->target, Accounting::class); + + if ($target->getProject() === null) { + return; + } + + if ($target->getProject()->getStatus() === ProjectStatus::InCampaign) { + return; + } + + $this->context->buildViolation($constraint->message) + ->setParameter('{{ target }}', $value->target->id) + ->setParameter('{{ project }}', $target->getProject()->getId()) + ->addViolation() + ; + } +} -- 2.54.0 From 28c27ad4ed61d7f9c9878a67a818920b24ebff0b Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 26 Aug 2026 12:53:39 +0200 Subject: [PATCH 30/81] feat: validate new collaboration candicacies are to open collaborations --- .../CollaborationCandidacyApiResource.php | 5 ++- src/Validator/CollaborationIsOpen.php | 11 +++++ .../CollaborationIsOpenValidator.php | 43 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/Validator/CollaborationIsOpen.php create mode 100644 src/Validator/CollaborationIsOpenValidator.php diff --git a/src/ApiResource/Project/CollaborationCandidacyApiResource.php b/src/ApiResource/Project/CollaborationCandidacyApiResource.php index c1b22db..d3c99c5 100644 --- a/src/ApiResource/Project/CollaborationCandidacyApiResource.php +++ b/src/ApiResource/Project/CollaborationCandidacyApiResource.php @@ -12,6 +12,7 @@ use App\Entity\Project\CollaborationCandidacy; use App\Entity\Project\CollaborationCandidacyStatus; use App\State\ApiResourceStateProcessor; use App\State\ApiResourceStateProvider; +use App\Validator as AssertCustom; use Symfony\Component\Validator\Constraints as Assert; /** @@ -36,8 +37,9 @@ class CollaborationCandidacyApiResource * The ProjectCollaboration to which this candidacy is applying to. */ #[Assert\NotBlank()] + #[AssertCustom\CollaborationIsOpen()] #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] - public CollaborationApiResource $project; + public CollaborationApiResource $collaboration; /** * The User applying to fill for the ProjectCollaboration. @@ -59,5 +61,6 @@ class CollaborationCandidacyApiResource */ #[Assert\NotNull()] #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] + #[API\ApiProperty(securityPostDenormalize: 'is_granted("PROJECT_EDIT", object.collaboration.project)')] public CollaborationCandidacyStatus $status = CollaborationCandidacyStatus::ToReview; } diff --git a/src/Validator/CollaborationIsOpen.php b/src/Validator/CollaborationIsOpen.php new file mode 100644 index 0000000..aa260e1 --- /dev/null +++ b/src/Validator/CollaborationIsOpen.php @@ -0,0 +1,11 @@ +mapper->map($value, Collaboration::class); + + if ( + $collaboration->isFulfilled() === false + && $collaboration->getProject()->getStatus() === ProjectStatus::InCampaign + ) { + return; + } + + $this->context->buildViolation($constraint->message) + ->setParameter('{{ collaboration }}', $collaboration->getId()) + ->addViolation() + ; + } +} -- 2.54.0 From 0a887e8884dabe12fdb4ff0bf460af5d095e08fc Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 26 Aug 2026 12:59:12 +0200 Subject: [PATCH 31/81] feat: prevent future object access issues in SameProjectRewardChargeValidator --- src/Validator/SameProjectRewardChargeValidator.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Validator/SameProjectRewardChargeValidator.php b/src/Validator/SameProjectRewardChargeValidator.php index 6d933df..62f722a 100644 --- a/src/Validator/SameProjectRewardChargeValidator.php +++ b/src/Validator/SameProjectRewardChargeValidator.php @@ -3,23 +3,31 @@ namespace App\Validator; use App\ApiResource\Project\RewardClaimApiResource; +use App\Entity\Accounting\Accounting; use App\Entity\Project\Project; +use App\Mapping\AutoMapper; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; final class SameProjectRewardChargeValidator extends ConstraintValidator { + public function __construct( + private AutoMapper $mapper, + ) {} + /** * @param RewardClaimApiResource $value */ public function validate(mixed $value, Constraint $constraint): void { - $charge = $value->charge; $reward = $value->reward; + /** @var Accounting */ + $target = $this->mapper->map($value->charge->target, Accounting::class); + if ( - $charge->target->ownerObject instanceof Project - && $charge->target->ownerObject?->getId() === $reward->project->id + $target->getProject() instanceof Project + && $target->getProject()->getId() === $reward->project->id ) { return; } -- 2.54.0 From a48fd6d4b78e23f848426632e6ed453e132311c0 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 3 Sep 2026 15:28:40 +0200 Subject: [PATCH 32/81] fix: return void on succesfull delete operations --- src/State/ApiResourceStateProcessor.php | 5 +++++ src/State/Matchfunding/MatchCallSubmissionStateProcessor.php | 5 +++++ src/State/Matchfunding/MatchStrategyStateProcessor.php | 4 ++++ src/State/Project/ProjectStateProcessor.php | 5 +++++ src/State/Project/RewardClaimStateProcessor.php | 5 +++++ src/State/Project/RewardStateProcessor.php | 5 +++++ src/State/Project/SupportStateProcessor.php | 2 -- src/State/Project/UpdateStateProcessor.php | 5 +++++ src/State/User/UserStateProcessor.php | 5 +++++ 9 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/State/ApiResourceStateProcessor.php b/src/State/ApiResourceStateProcessor.php index 9ec90fc..a363d6c 100644 --- a/src/State/ApiResourceStateProcessor.php +++ b/src/State/ApiResourceStateProcessor.php @@ -3,6 +3,7 @@ namespace App\State; use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Mapping\AutoMapper; @@ -22,6 +23,10 @@ class ApiResourceStateProcessor implements ProcessorInterface $entity = $this->asEntity($data, $operation->getStateOptions()); $entity = $this->entityStateProcessor->process($entity, $operation, $uriVariables, $context); + if ($operation instanceof DeleteOperationInterface) { + return; + } + if ($entity === null) { return null; } diff --git a/src/State/Matchfunding/MatchCallSubmissionStateProcessor.php b/src/State/Matchfunding/MatchCallSubmissionStateProcessor.php index b7a96a6..0bae187 100644 --- a/src/State/Matchfunding/MatchCallSubmissionStateProcessor.php +++ b/src/State/Matchfunding/MatchCallSubmissionStateProcessor.php @@ -2,6 +2,7 @@ namespace App\State\Matchfunding; +use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\ApiResource\Matchfunding\MatchCallSubmissionApiResource; @@ -28,6 +29,10 @@ class MatchCallSubmissionStateProcessor implements ProcessorInterface $submission = $this->autoMapper->map($data, MatchCallSubmission::class); $submission = $this->entityProcessor->process($submission, $operation, $uriVariables, $context); + if ($operation instanceof DeleteOperationInterface) { + return; + } + return $this->autoMapper->map($submission, MatchCallSubmissionApiResource::class); } } diff --git a/src/State/Matchfunding/MatchStrategyStateProcessor.php b/src/State/Matchfunding/MatchStrategyStateProcessor.php index da7a94d..9c69dec 100644 --- a/src/State/Matchfunding/MatchStrategyStateProcessor.php +++ b/src/State/Matchfunding/MatchStrategyStateProcessor.php @@ -42,6 +42,10 @@ class MatchStrategyStateProcessor implements ProcessorInterface $strategy = $this->entityProcessor->process($strategy, $operation, $uriVariables, $context); + if ($operation instanceof DeleteOperationInterface) { + return; + } + if ($strategy === null) { return null; } diff --git a/src/State/Project/ProjectStateProcessor.php b/src/State/Project/ProjectStateProcessor.php index 2e13de3..e9fcaac 100644 --- a/src/State/Project/ProjectStateProcessor.php +++ b/src/State/Project/ProjectStateProcessor.php @@ -2,6 +2,7 @@ namespace App\State\Project; +use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Exception\AccessDeniedException; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; @@ -42,6 +43,10 @@ class ProjectStateProcessor implements ProcessorInterface $project = $this->entityStateProcessor->process($project, $operation, $uriVariables, $context); + if ($operation instanceof DeleteOperationInterface) { + return; + } + if ($project === null) { return null; } diff --git a/src/State/Project/RewardClaimStateProcessor.php b/src/State/Project/RewardClaimStateProcessor.php index e73fe24..a66ea95 100644 --- a/src/State/Project/RewardClaimStateProcessor.php +++ b/src/State/Project/RewardClaimStateProcessor.php @@ -2,6 +2,7 @@ namespace App\State\Project; +use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\ApiResource\Project\RewardClaimApiResource; @@ -47,6 +48,10 @@ class RewardClaimStateProcessor implements ProcessorInterface $claim = $this->entityStateProcessor->process($claim, $operation, $uriVariables, $context); + if ($operation instanceof DeleteOperationInterface) { + return; + } + return $this->autoMapper->map($claim, $data); } } diff --git a/src/State/Project/RewardStateProcessor.php b/src/State/Project/RewardStateProcessor.php index 77d978f..3a175ff 100644 --- a/src/State/Project/RewardStateProcessor.php +++ b/src/State/Project/RewardStateProcessor.php @@ -2,6 +2,7 @@ namespace App\State\Project; +use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\ApiResource\Project\RewardApiResource; @@ -32,6 +33,10 @@ class RewardStateProcessor implements ProcessorInterface $reward = $this->entityStateProcessor->process($reward, $operation, $uriVariables, $context); + if ($operation instanceof DeleteOperationInterface) { + return; + } + return $this->autoMapper->map($reward, $data); } } diff --git a/src/State/Project/SupportStateProcessor.php b/src/State/Project/SupportStateProcessor.php index c241149..ef6c7a9 100644 --- a/src/State/Project/SupportStateProcessor.php +++ b/src/State/Project/SupportStateProcessor.php @@ -8,7 +8,6 @@ use App\ApiResource\Project\SupportApiResource; use App\Entity\Project\Support; use App\Mapping\AutoMapper; use App\State\EntityStateProcessor; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\Exception\AuthenticationException; @@ -18,7 +17,6 @@ class SupportStateProcessor implements ProcessorInterface private EntityStateProcessor $entityStateProcessor, private Security $security, private AutoMapper $autoMapper, - private EntityManagerInterface $entityManager, ) {} /** diff --git a/src/State/Project/UpdateStateProcessor.php b/src/State/Project/UpdateStateProcessor.php index 6611cb5..74405e1 100644 --- a/src/State/Project/UpdateStateProcessor.php +++ b/src/State/Project/UpdateStateProcessor.php @@ -2,6 +2,7 @@ namespace App\State\Project; +use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\ApiResource\Project\UpdateApiResource; @@ -41,6 +42,10 @@ class UpdateStateProcessor implements ProcessorInterface $update = $this->entityStateProcessor->process($update, $operation, $uriVariables, $context); + if ($operation instanceof DeleteOperationInterface) { + return; + } + return $this->autoMapper->map($update, $data); } } diff --git a/src/State/User/UserStateProcessor.php b/src/State/User/UserStateProcessor.php index f0619f1..a420b35 100644 --- a/src/State/User/UserStateProcessor.php +++ b/src/State/User/UserStateProcessor.php @@ -4,6 +4,7 @@ namespace App\State\User; use ApiPlatform\Doctrine\Common\State\PersistProcessor; use ApiPlatform\Metadata as API; +use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\State\ProcessorInterface; use App\Entity\User\Organization; use App\Entity\User\User; @@ -35,6 +36,10 @@ class UserStateProcessor implements ProcessorInterface $user = $this->persistProcessor->process($user, $operation, $uriVariables, $context); + if ($operation instanceof DeleteOperationInterface) { + return; + } + return $this->autoMapper->map($user, $data); } } -- 2.54.0 From 4d4e7a7db6ffff0194f680304b3a6453c3e27b28 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 3 Sep 2026 16:14:46 +0200 Subject: [PATCH 33/81] fix: force set of isFinite property --- src/State/Project/RewardStateProcessor.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/State/Project/RewardStateProcessor.php b/src/State/Project/RewardStateProcessor.php index 3a175ff..7628be4 100644 --- a/src/State/Project/RewardStateProcessor.php +++ b/src/State/Project/RewardStateProcessor.php @@ -26,6 +26,7 @@ class RewardStateProcessor implements ProcessorInterface { /** @var Reward */ $reward = $this->autoMapper->map($data, Reward::class); + $reward->setIsFinite($data->isFinite); if (!$reward->getId()) { $reward->setUnitsAvailable($reward->getUnitsTotal()); -- 2.54.0 From 1bc97973d5dd6fe4c38020d3b3f36134d0d82dfb Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 8 Sep 2026 16:54:57 +0200 Subject: [PATCH 34/81] feat: allow to filter ProjectBudgetItem by type and deadline --- src/ApiResource/Project/BudgetItemApiResource.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ApiResource/Project/BudgetItemApiResource.php b/src/ApiResource/Project/BudgetItemApiResource.php index 6e4b549..49a64f9 100644 --- a/src/ApiResource/Project/BudgetItemApiResource.php +++ b/src/ApiResource/Project/BudgetItemApiResource.php @@ -41,6 +41,7 @@ class BudgetItemApiResource * The type of need this item solves. */ #[Assert\NotBlank()] + #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] public BudgetItemType $type; /** @@ -68,5 +69,6 @@ class BudgetItemApiResource * This field specifies whether the budget item belongs to the minimum or optimum budget: */ #[Assert\NotBlank()] + #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] public ProjectDeadline $deadline; } -- 2.54.0 From 5e96bc94e6d7eee2486094998d9a2405c7e6f3ca Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 10 Sep 2026 12:07:22 +0200 Subject: [PATCH 35/81] feat: remove example values from .env --- .env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 3507315..c1ef931 100644 --- a/.env +++ b/.env @@ -33,8 +33,8 @@ DATABASE_URL="mysql://goteo:goteo@mariadb:3306/goteo?serverVersion=10.11.2-Maria ###> kyzegs/doctrine-encryption-bundle ### # IMPORTANT: Run bin/console encrypt:genkey or use any other service to generate new base64-encoded 256-bit keys -DOCTRINE_ENCRYPTION_ENCRYPT_KEY="2B7o7mA3COFgMa/x+mKrrNPubx3nQ9nwQA7SSrcb4ao=" -DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY="HBFPjEU1cna4/9oTQvj1mwotMe6C5jvv/ltpZrqZ0AA=" +DOCTRINE_ENCRYPTION_ENCRYPT_KEY="" +DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY="" ###< kyzegs/doctrine-encryption-bundle ### ###> nelmio/cors-bundle ### -- 2.54.0 From 565d01254e810d052c1ad21cce6d7da18f57b383 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 11 Sep 2026 19:25:51 +0200 Subject: [PATCH 36/81] feat: add testing encryption keys --- .env.test | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.test b/.env.test index 92426e3..3d4c19f 100644 --- a/.env.test +++ b/.env.test @@ -4,4 +4,5 @@ APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 PANTHER_APP_ENV=panther PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots -CRYPTO_SECRET="CryptoSecret" +DOCTRINE_ENCRYPTION_ENCRYPT_KEY="0Y3McEg51558fcreunKodhuP+Ua/Yf+tp43GxmBVY4Q=" +DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY="cRt6Hf/3nEVG8XTBnZrOFCnOHJQ7PIE910EYIT+79EQ=" -- 2.54.0 From 8ad3e79c19ed242d2bff5cb5ee7424ff36ea8728 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 11 Sep 2026 19:30:06 +0200 Subject: [PATCH 37/81] feat: make project in BudgetItem body required --- src/ApiResource/Project/BudgetItemApiResource.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ApiResource/Project/BudgetItemApiResource.php b/src/ApiResource/Project/BudgetItemApiResource.php index 49a64f9..8dca61e 100644 --- a/src/ApiResource/Project/BudgetItemApiResource.php +++ b/src/ApiResource/Project/BudgetItemApiResource.php @@ -34,6 +34,7 @@ class BudgetItemApiResource #[API\ApiProperty(identifier: true, writable: false)] public int $id; + #[Assert\NotBlank()] #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] public ProjectApiResource $project; -- 2.54.0 From 6247fd2c54e80f9cf0efaba6603f78b8e9339573 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 11 Sep 2026 19:35:19 +0200 Subject: [PATCH 38/81] feat: add testing encryption keys to actions --- .github/workflows/php-cs-fixer.yaml | 2 ++ .github/workflows/phpunit.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/php-cs-fixer.yaml b/.github/workflows/php-cs-fixer.yaml index b153c55..ccdb18b 100644 --- a/.github/workflows/php-cs-fixer.yaml +++ b/.github/workflows/php-cs-fixer.yaml @@ -12,6 +12,8 @@ jobs: env: STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }} + DOCTRINE_ENCRYPTION_ENCRYPT_KEY: "0Y3McEg51558fcreunKodhuP+Ua/Yf+tp43GxmBVY4Q=" + DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY: "cRt6Hf/3nEVG8XTBnZrOFCnOHJQ7PIE910EYIT+79EQ=" steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/phpunit.yaml b/.github/workflows/phpunit.yaml index d4ca3cd..6468b4b 100644 --- a/.github/workflows/phpunit.yaml +++ b/.github/workflows/phpunit.yaml @@ -12,6 +12,8 @@ jobs: env: STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }} + DOCTRINE_ENCRYPTION_ENCRYPT_KEY: "0Y3McEg51558fcreunKodhuP+Ua/Yf+tp43GxmBVY4Q=" + DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY: "cRt6Hf/3nEVG8XTBnZrOFCnOHJQ7PIE910EYIT+79EQ=" services: mariadb: -- 2.54.0 From 85aac10b6a4f6d480bbdbaaf6ea173b795d06a32 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 17 Sep 2026 14:44:04 +0200 Subject: [PATCH 39/81] feat: add collaborations property to ProjectApiResource --- src/ApiResource/Project/ProjectApiResource.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ApiResource/Project/ProjectApiResource.php b/src/ApiResource/Project/ProjectApiResource.php index 6db3e61..c228fa0 100644 --- a/src/ApiResource/Project/ProjectApiResource.php +++ b/src/ApiResource/Project/ProjectApiResource.php @@ -204,6 +204,14 @@ class ProjectApiResource #[API\ApiProperty(writable: false)] public array $budgetItems; + /** + * A list of the ProjectCollaborations this Project has. + * + * @var array + */ + #[API\ApiProperty(writable: false)] + public array $collaborations; + /** * A list of the ProjectUpdates this Project has. * -- 2.54.0 From 93203d8d5622c53ef6140337491cfc24dbcf47f3 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 22 Sep 2026 12:46:50 +0200 Subject: [PATCH 40/81] feat: mark API instance as DTF's --- config/packages/api_platform.yaml | 8 ++++---- config/packages/framework.yaml | 2 +- package-lock.json | 2 +- src/OpenApi/OpenApiDescription.md | 3 +++ 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index cef49d4..14607f1 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -1,14 +1,14 @@ api_platform: - title: The v4 API + title: The Goteo v4 API version: 4.0.0 show_webby: false collection: order: "DESC" openapi: contact: - name: Fundación Platoniq - url: http://platoniq.net/contact/ - email: contacto@platoniq.net + name: Democratic Tech Fund + url: https://democratictech.fund/contact/ + email: democratictech@freeknowledge.eu termsOfService: license: name: AGPL diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 387064a..32e051e 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -26,7 +26,7 @@ framework: http_client: default_options: headers: - 'User-Agent': 'Platoniq Goteo-v4-API' + 'User-Agent': 'DTF Goteo v4 API' when@test: framework: diff --git a/package-lock.json b/package-lock.json index 6fb5280..d4d6155 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "org.goteo.api", + "name": "dtf-api", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/src/OpenApi/OpenApiDescription.md b/src/OpenApi/OpenApiDescription.md index cbbcad7..323b5b9 100644 --- a/src/OpenApi/OpenApiDescription.md +++ b/src/OpenApi/OpenApiDescription.md @@ -1,4 +1,7 @@ # Introduction +DTF's Goteo v4 API instance by [Platoniq](https://platoniq.net/). + +[Co-op Cloud Git](https://git.coopcloud.tech/DemocraticTech.Fund/crowd-fund) The v4 API is a multi-capable web API based on the [API Platform](https://api-platform.com/) framework, built to support the future generation of the [Goteo](https://goteo.org) crowdfunding platform, aiming to be it's new underlying engine. -- 2.54.0 From a32cedc1771bf7d0fecc0217772a3bc49bf18120 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 22 Sep 2026 12:47:06 +0200 Subject: [PATCH 41/81] fix: default localization is english --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index c1ef931..0483eb2 100644 --- a/.env +++ b/.env @@ -17,7 +17,7 @@ ###> symfony/framework-bundle ### APP_ENV=dev APP_SECRET=b8ec44a08e5f4ba917879713a4a8e63c -APP_LOCALE='es' +APP_LOCALE='en' ###< symfony/framework-bundle ### ###> doctrine/doctrine-bundle ### -- 2.54.0 From 9b5b5a93503680fdcdc624adb2a4c12fe65859c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Subiabre=20Garc=C3=ADa?= Date: Wed, 23 Sep 2026 18:36:55 +0200 Subject: [PATCH 42/81] feat: Update Users PATCH body (#246) * feat: move UserSignupDto to Dto\User namespace * fix: bad parsing of INSTR by ApiPlatform pagination layer * fix: make User resources be processed by dedicated update processor * fix: specify authentication usage of UserSignupDto::password property * feat: improve non-crawlable URI detection for ScoutService * feat: add "org" to Links with known organizations behind links * feat: map raw uri strings to full Link objects * fix: bad loading of existing user data in UserStateProcessor * feat: add NotExisting validator * feat: use NotExisting constraint on Users handle, email, Projects titles and Tipjar names * fix: check for existing node item in URI rel detection --- src/ApiResource/TipjarApiResource.php | 2 + src/ApiResource/User/UserApiResource.php | 10 ++- src/Dto/ProjectCreationDto.php | 3 + src/Dto/{ => User}/UserSignupDto.php | 7 +- src/Dto/User/UserUpdationDto.php | 78 +++++++++++++++++++ src/Filter/OrderedLikeFilter.php | 11 ++- src/Library/Link.php | 21 ++++- src/Library/LinkOrg.php | 39 ++++++++++ .../Transformer/RawLinksMapTransformer.php | 47 +++++++++++ ...ption.php => NonCrawlableUriException.php} | 7 +- src/Service/Scout/ScoutService.php | 28 ++++++- src/State/User/UserStateProcessor.php | 32 +++++--- src/Validator/NotExisting.php | 26 +++++++ src/Validator/NotExistingValidator.php | 45 +++++++++++ tests/Service/Scout/ScoutServiceTest.php | 12 +-- 15 files changed, 335 insertions(+), 33 deletions(-) rename src/Dto/{ => User}/UserSignupDto.php (74%) create mode 100644 src/Dto/User/UserUpdationDto.php create mode 100644 src/Library/LinkOrg.php create mode 100644 src/Mapping/Transformer/RawLinksMapTransformer.php rename src/Service/Scout/{FileUriException.php => NonCrawlableUriException.php} (74%) create mode 100644 src/Validator/NotExisting.php create mode 100644 src/Validator/NotExistingValidator.php diff --git a/src/ApiResource/TipjarApiResource.php b/src/ApiResource/TipjarApiResource.php index 2f769a9..6b254fd 100644 --- a/src/ApiResource/TipjarApiResource.php +++ b/src/ApiResource/TipjarApiResource.php @@ -9,6 +9,7 @@ use App\ApiResource\Accounting\AccountingApiResource; use App\Entity\Tipjar; use App\State\ApiResourceStateProcessor; use App\State\ApiResourceStateProvider; +use App\Validator\NotExisting; use Symfony\Component\Validator\Constraints as Assert; /** @@ -40,6 +41,7 @@ class TipjarApiResource * Human readable, non white space, unique string. */ #[Assert\NotBlank()] + #[NotExisting(Tipjar::class, 'name')] #[API\ApiFilter(SearchFilter::class, strategy: 'partial')] public string $name; } diff --git a/src/ApiResource/User/UserApiResource.php b/src/ApiResource/User/UserApiResource.php index 656e3b5..fa2cbcd 100644 --- a/src/ApiResource/User/UserApiResource.php +++ b/src/ApiResource/User/UserApiResource.php @@ -10,7 +10,8 @@ use ApiPlatform\Metadata as API; use App\ApiResource\Accounting\AccountingApiResource; use App\ApiResource\TimestampedCreationApiResource; use App\ApiResource\TimestampedUpdationApiResource; -use App\Dto\UserSignupDto; +use App\Dto\User\UserSignupDto; +use App\Dto\User\UserUpdationDto; use App\Entity\Territory; use App\Entity\User\User; use App\Entity\User\UserType; @@ -48,8 +49,11 @@ use Symfony\Component\Validator\Constraints as Assert; ), ] )] -#[API\Patch(securityPostDenormalize: 'is_granted("USER_EDIT", previous_object)')] -#[API\Delete(securityPostDenormalize: 'is_granted("USER_EDIT", previous_object)')] +#[API\Patch( + input: UserUpdationDto::class, + securityPostDenormalize: 'is_granted("USER_EDIT", previous_object)' +)] +#[API\Delete(securityPostDenormalize: 'is_granted("ROLE_ADMIN", previous_object)')] #[API\ApiFilter(QFilter::class, properties: [ 'q' => [ 'email', diff --git a/src/Dto/ProjectCreationDto.php b/src/Dto/ProjectCreationDto.php index 1965143..50cf7c5 100644 --- a/src/Dto/ProjectCreationDto.php +++ b/src/Dto/ProjectCreationDto.php @@ -4,9 +4,11 @@ namespace App\Dto; use ApiPlatform\Metadata as API; use App\ApiResource\CategoryApiResource; +use App\Entity\Project\Project; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectStatus; use App\Entity\Territory; +use App\Validator\NotExisting; use Symfony\Component\Validator\Constraints as Assert; class ProjectCreationDto @@ -19,6 +21,7 @@ class ProjectCreationDto #[Assert\NotBlank()] #[Assert\Regex('/[a-zA-Z]{1,}/')] #[Assert\Length(min: 3)] + #[NotExisting(Project::class, 'title')] public string $title; /** diff --git a/src/Dto/UserSignupDto.php b/src/Dto/User/UserSignupDto.php similarity index 74% rename from src/Dto/UserSignupDto.php rename to src/Dto/User/UserSignupDto.php index 7a5b56b..3d347fa 100644 --- a/src/Dto/UserSignupDto.php +++ b/src/Dto/User/UserSignupDto.php @@ -1,8 +1,10 @@ + */ + #[API\ApiProperty(securityPostDenormalize: 'is_granted("ROLE_ADMIN")')] + public array $roles; + + /** + * A list of absolute URLs.\ + * e.g: social profiles, personal website. + * + * @var array + */ + #[MapTo(User::class, transformer: RawLinksMapTransformer::class)] + #[MapTo(UserApiResource::class, transformer: RawLinksMapTransformer::class)] + public array $links = []; + + /** + * ISO 3166 data about the Users's location territory. + */ + #[Assert\Valid()] + public Territory $territory; + + /** + * Free-form rich text description for the User. + */ + public string $description; +} diff --git a/src/Filter/OrderedLikeFilter.php b/src/Filter/OrderedLikeFilter.php index 50b81d6..a8f5887 100644 --- a/src/Filter/OrderedLikeFilter.php +++ b/src/Filter/OrderedLikeFilter.php @@ -33,6 +33,7 @@ final class OrderedLikeFilter extends AbstractFilter ): void { if ( !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass) ) { return; } @@ -40,10 +41,18 @@ final class OrderedLikeFilter extends AbstractFilter $rootAlias = $queryBuilder->getRootAliases()[0]; $likeParameter = $queryNameGenerator->generateParameterName($property); $instrParameter = $queryNameGenerator->generateParameterName(sprintf('%s%s', $property, 'instr')); + $positionAlias = $queryNameGenerator->generateJoinAlias('position'); $queryBuilder ->andWhere(sprintf('%s.%s LIKE :%s', $rootAlias, $property, $likeParameter)) - ->addOrderBy(sprintf('INSTR(%s.%s, :%s)', $rootAlias, $property, $instrParameter), 'ASC') + ->addSelect(sprintf( + 'INSTR(%s.%s, :%s) AS HIDDEN %s', + $rootAlias, + $property, + $instrParameter, + $positionAlias + )) + ->addOrderBy($positionAlias, 'ASC') ->addOrderBy(sprintf('%s.%s', $rootAlias, $property), 'ASC') ->setParameter($likeParameter, sprintf('%%%s%%', $value)) ->setParameter($instrParameter, $value) diff --git a/src/Library/Link.php b/src/Library/Link.php index ed56969..a1801fa 100644 --- a/src/Library/Link.php +++ b/src/Library/Link.php @@ -16,15 +16,30 @@ class Link */ public ?string $rel; - public static function tryFrom($value): self + /** + * The known organization behind this link. + */ + public function getOrg(): LinkOrg + { + return LinkOrg::fromUrl($this->url); + } + + public static function tryFrom(mixed $value): self { if ($value instanceof self) { return $value; } + if (\is_array($value)) { + $link = new self(); + $link->url = $value['url']; + $link->rel = $value['rel']; + + return $link; + } + $link = new self(); - $link->url = $value['url']; - $link->rel = $value['rel']; + $link->url = $value; return $link; } diff --git a/src/Library/LinkOrg.php b/src/Library/LinkOrg.php new file mode 100644 index 0000000..72d243b --- /dev/null +++ b/src/Library/LinkOrg.php @@ -0,0 +1,39 @@ + self::Instagram, + 'facebook.com' => self::Facebook, + 'fb.com' => self::Facebook, + 'github.com' => self::GitHub, + 'linkedin.com' => self::LinkedIn, + 'identi.ca' => self::Identica, + ]; + } + + public static function fromUrl(string $url): self + { + $host = \parse_url($url, PHP_URL_HOST) ?? ''; + $host = \join('.', \array_slice(\explode('.', $host), -2)); + + foreach (self::domains() as $domain => $network) { + if ($host === $domain || \str_ends_with($host, '.'.$domain)) { + return $network; + } + } + + return self::Unknown; + } +} diff --git a/src/Mapping/Transformer/RawLinksMapTransformer.php b/src/Mapping/Transformer/RawLinksMapTransformer.php new file mode 100644 index 0000000..8d8ae20 --- /dev/null +++ b/src/Mapping/Transformer/RawLinksMapTransformer.php @@ -0,0 +1,47 @@ +scoutService->get($rawLink); + + $link = new Link(); + $link->url = $info->getUri(); + $link->rel = $this->getRel($info); + + $links[] = $link; + } + + return $links; + } + + private function getRel(ScoutResult $info): ?string + { + $nodes = (new \DOMXPath($info->getDocument()->getDocument()))->query('//a[@rel]'); + + if ($nodes->length === 0) { + return null; + } + + $anchor = $nodes->item(0); + if (!$anchor instanceof \DOMElement) { + return null; + } + + return $anchor->getAttribute('rel'); + } +} diff --git a/src/Service/Scout/FileUriException.php b/src/Service/Scout/NonCrawlableUriException.php similarity index 74% rename from src/Service/Scout/FileUriException.php rename to src/Service/Scout/NonCrawlableUriException.php index 6e701a0..11ab582 100644 --- a/src/Service/Scout/FileUriException.php +++ b/src/Service/Scout/NonCrawlableUriException.php @@ -4,13 +4,13 @@ namespace App\Service\Scout; use Psr\Http\Message\UriInterface; -class FileUriException extends \Exception +class NonCrawlableUriException extends \Exception { private UriInterface $uri; public function __construct( UriInterface $uri, - string $message = 'Cannot scout URLs to files (%s)', + string $message = 'Cannot scout %s: it is not a Web page', int $code = 0, ?\Throwable $previous = null, ) { @@ -19,9 +19,6 @@ class FileUriException extends \Exception $this->uri = $uri; } - /** - * The URI to the file. - */ public function getUri(): UriInterface { return $this->uri; diff --git a/src/Service/Scout/ScoutService.php b/src/Service/Scout/ScoutService.php index 7173d8d..3c7110f 100644 --- a/src/Service/Scout/ScoutService.php +++ b/src/Service/Scout/ScoutService.php @@ -5,6 +5,7 @@ namespace App\Service\Scout; use Embed\Embed; use Embed\Http\Crawler; use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\ResponseInterface; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\Url; use Symfony\Component\Validator\Validation; @@ -39,8 +40,8 @@ class ScoutService /** * @param string $url A URL to an external resource * - * @throws InvalidUriException When the given $url string could not be validated as an actual URL - * @throws FileUriException If the given $url string points to a file + * @throws InvalidUriException When the given $url string could not be validated as an actual URL + * @throws NonCrawlableUriException If the given $url string points to a file */ public function get(string $url): ScoutResult { @@ -54,9 +55,12 @@ class ScoutService } $uri = $this->embed->getCrawler()->createUri($url); + $response = $this->httpClient->sendRequest( + new \GuzzleHttp\Psr7\Request('HEAD', $url) + ); - if (\pathinfo($uri->getPath(), \PATHINFO_EXTENSION)) { - throw new FileUriException($uri); + if (!$this->isCrawlable($response)) { + throw new NonCrawlableUriException($uri); } $info = $this->embed->get($url); @@ -82,4 +86,20 @@ class ScoutService return $result; } + + private function isCrawlable(ResponseInterface $response): bool + { + $contentType = $response->getHeaderLine('Content-Type'); + + if ($contentType === '') { + return true; + } + + $contentType = \strtolower(\trim(\explode(';', $contentType)[0])); + + return \in_array($contentType, [ + 'text/html', + 'application/xhtml+xml', + ], true); + } } diff --git a/src/State/User/UserStateProcessor.php b/src/State/User/UserStateProcessor.php index a420b35..9c9deca 100644 --- a/src/State/User/UserStateProcessor.php +++ b/src/State/User/UserStateProcessor.php @@ -2,44 +2,58 @@ namespace App\State\User; -use ApiPlatform\Doctrine\Common\State\PersistProcessor; use ApiPlatform\Metadata as API; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\State\ProcessorInterface; +use App\ApiResource\User\UserApiResource; +use App\Dto\User\UserUpdationDto; use App\Entity\User\Organization; use App\Entity\User\User; use App\Entity\User\UserType; use App\Mapping\AutoMapper; -use Symfony\Component\DependencyInjection\Attribute\Autowire; +use App\Repository\User\UserRepository; +use App\State\EntityStateProcessor; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; class UserStateProcessor implements ProcessorInterface { public function __construct( private AutoMapper $autoMapper, - #[Autowire(service: PersistProcessor::class)] - private ProcessorInterface $persistProcessor, + private UserRepository $userRepository, + private UserPasswordHasherInterface $userPasswordHasher, + private EntityStateProcessor $entityProcessor, ) {} /** - * @param UserApiResource $data + * @param UserUpdationDto $data * - * @return UserApiResource + * @return UserUpdationDto */ public function process(mixed $data, API\Operation $operation, array $uriVariables = [], array $context = []) { + $user = $this->userRepository->find($context['previous_data']->id); + if (!$user) { + throw new NotFoundHttpException(); + } + /** @var User */ - $user = $this->autoMapper->map($data, User::class); + $user = $this->autoMapper->map($data, $user); + + if (isset($data->password)) { + $user->setPassword($this->userPasswordHasher->hashPassword($user, $data->password)); + } if ($user->isType(UserType::Organization) && $user->getOrganization() === null) { $user->setOrganization(Organization::for($user)); } - $user = $this->persistProcessor->process($user, $operation, $uriVariables, $context); + $user = $this->entityProcessor->process($user, $operation, $uriVariables, $context); if ($operation instanceof DeleteOperationInterface) { return; } - return $this->autoMapper->map($user, $data); + return $this->autoMapper->map($user, UserApiResource::class); } } diff --git a/src/Validator/NotExisting.php b/src/Validator/NotExisting.php new file mode 100644 index 0000000..1b53b9c --- /dev/null +++ b/src/Validator/NotExisting.php @@ -0,0 +1,26 @@ +entity; + $repository = $this->entityManager->getRepository($entityClass); + + if ( + $value === null + || $value === '' + || $repository->findOneBy([$constraint->property => $value]) === null + ) { + return; + } + + $this->context->buildViolation($constraint->message) + ->setParameter('{{ property }}', $constraint->property) + ->setParameter('{{ value }}', $value) + ->setParameter('{{ entity }}', $this->getClassShortName($entityClass)) + ->addViolation() + ; + } + + private function getClassShortName(string $class): string + { + $reflect = new \ReflectionClass($class); + + return $reflect->getShortName(); + } +} diff --git a/tests/Service/Scout/ScoutServiceTest.php b/tests/Service/Scout/ScoutServiceTest.php index 256f0db..496ea3f 100644 --- a/tests/Service/Scout/ScoutServiceTest.php +++ b/tests/Service/Scout/ScoutServiceTest.php @@ -2,8 +2,8 @@ namespace App\Tests\Service\Scout; -use App\Service\Scout\FileUriException; use App\Service\Scout\InvalidUriException; +use App\Service\Scout\NonCrawlableUriException; use App\Service\Scout\ScoutService; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -38,17 +38,17 @@ class ScoutServiceTest extends KernelTestCase } /** - * @dataProvider providePathsToBinaryFiles + * @dataProvider provideNonCrawlablePaths */ - public function testThrowsExceptionOnPathsToBinaryFiles(string $url) + public function testThrowsExceptionOnNonCrawlablePaths(string $url) { - $this->expectException(FileUriException::class); - $this->expectExceptionMessageMatches(\sprintf('/\(%s\)$/', \preg_quote($url, '/'))); + $this->expectException(NonCrawlableUriException::class); + $this->expectExceptionMessageMatches(\sprintf('/%s/', \preg_quote($url, '/'))); $this->scout->get($url); } - public function providePathsToBinaryFiles(): array + public function provideNonCrawlablePaths(): array { return [ ['https://sialmaraz.es/wp-content/uploads/2025/01/MANIFESTACION-ALMARAZ-18_1.mp4'], -- 2.54.0 From 6973e9a0988a0fdf1942b0b997422b8cb6fdc42c Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 25 Sep 2026 17:05:28 +0200 Subject: [PATCH 43/81] fix: make app:oauth2-server:consent-client true by default --- src/Command/OAuth2ConsentClientCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/OAuth2ConsentClientCommand.php b/src/Command/OAuth2ConsentClientCommand.php index 6434e73..a58ad8c 100644 --- a/src/Command/OAuth2ConsentClientCommand.php +++ b/src/Command/OAuth2ConsentClientCommand.php @@ -31,7 +31,7 @@ class OAuth2ConsentClientCommand extends Command $this ->setDescription('Consents an OAuth2 client, allowing it to skip the consent screen') - ->addOption('consent', null, InputOption::VALUE_NEGATABLE, 'Consent the client') + ->addOption('consent', null, InputOption::VALUE_NEGATABLE, 'Consent the client', true) ->addArgument('identifier', InputArgument::REQUIRED, 'The client identifier') ; -- 2.54.0 From 6b9014d73f82c02d877b563ba6bfce72bf5a75ee Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 25 Sep 2026 17:47:45 +0200 Subject: [PATCH 44/81] feat: bump league/oauth2-server-bundle to 1.2.2 --- composer.lock | 307 +++++++++++++++++++++++++------------------------- 1 file changed, 153 insertions(+), 154 deletions(-) diff --git a/composer.lock b/composer.lock index d7af667..be5a325 100644 --- a/composer.lock +++ b/composer.lock @@ -3916,16 +3916,16 @@ }, { "name": "league/oauth2-server-bundle", - "version": "v1.1.1", + "version": "v1.2.2", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-server-bundle.git", - "reference": "b0094bef19cb0dfa526ea9eec6d8a863b91a3f7d" + "reference": "db8d6a267b3bb76451816751539d50aa56588247" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-server-bundle/zipball/b0094bef19cb0dfa526ea9eec6d8a863b91a3f7d", - "reference": "b0094bef19cb0dfa526ea9eec6d8a863b91a3f7d", + "url": "https://api.github.com/repos/thephpleague/oauth2-server-bundle/zipball/db8d6a267b3bb76451816751539d50aa56588247", + "reference": "db8d6a267b3bb76451816751539d50aa56588247", "shasum": "" }, "require": { @@ -3934,16 +3934,18 @@ "nyholm/psr7": "^1.4", "php": "^8.1", "psr/http-factory": "^1.0", - "symfony/deprecation-contracts": "^3", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/psr-http-message-bridge": "^6.4|^7.0|^8.0", - "symfony/security-bundle": "^6.4|^7.0|^8.0" + "symfony/deprecation-contracts": "^3.0", + "symfony/event-dispatcher": "^6.4|^7.4|^8.0", + "symfony/filesystem": "^6.4|^7.4|^8.0", + "symfony/framework-bundle": "^6.4|^7.4|^8.0", + "symfony/password-hasher": "^6.4|^7.4|^8.0", + "symfony/psr-http-message-bridge": "^6.4|^7.4|^8.0", + "symfony/security-bundle": "^6.4|^7.4|^8.0" }, "conflict": { "doctrine/doctrine-bundle": "<2.8.0", - "doctrine/orm": "<2.14" + "doctrine/orm": "<2.14", + "symfony/console": "<6.1" }, "require-dev": { "doctrine/doctrine-bundle": "^2.8|^3.0", @@ -3953,19 +3955,15 @@ "php-cs-fixer/shim": "^3.38", "phpstan/phpstan": "^2.1", "phpstan/phpstan-symfony": "^2.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/phpunit-bridge": "^7.3" + "rector/rector": "^2.5", + "symfony/browser-kit": "^6.4|^7.4|^8.0", + "symfony/phpunit-bridge": "^8.1" }, "suggest": { "doctrine/doctrine-bundle": "Allow usage of doctrine as persistence layer", "doctrine/orm": "Allow usage of doctrine as persistence layer" }, "type": "symfony-bundle", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, "autoload": { "psr-4": { "League\\Bundle\\OAuth2ServerBundle\\": "src/" @@ -3996,9 +3994,9 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth2-server-bundle/issues", - "source": "https://github.com/thephpleague/oauth2-server-bundle/tree/v1.1.1" + "source": "https://github.com/thephpleague/oauth2-server-bundle/tree/v1.2.2" }, - "time": "2026-02-06T11:56:53+00:00" + "time": "2026-07-26T12:46:32+00:00" }, { "name": "league/uri", @@ -5934,16 +5932,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "8f0b5de5d9f5131aa99fdab78d7802efef9322dc" + "reference": "b001ef092224de1358462ceabfbdf4d4c2d4c9d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/8f0b5de5d9f5131aa99fdab78d7802efef9322dc", - "reference": "8f0b5de5d9f5131aa99fdab78d7802efef9322dc", + "url": "https://api.github.com/repos/symfony/cache/zipball/b001ef092224de1358462ceabfbdf4d4c2d4c9d2", + "reference": "b001ef092224de1358462ceabfbdf4d4c2d4c9d2", "shasum": "" }, "require": { @@ -5966,7 +5964,7 @@ "symfony/cache-implementation": "1.1|2.0|3.0" }, "require-dev": { - "cache/integration-tests": "dev-master", + "cache/integration-tests": "^0.18", "doctrine/dbal": "^2.13.1|^3|^4", "predis/predis": "^1.1|^2.0", "psr/simple-cache": "^1.0|^2.0|^3.0", @@ -6010,7 +6008,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.43" + "source": "https://github.com/symfony/cache/tree/v6.4.46" }, "funding": [ { @@ -6030,7 +6028,7 @@ "type": "tidelift" } ], - "time": "2026-07-28T21:33:22+00:00" + "time": "2026-09-08T06:25:25+00:00" }, { "name": "symfony/cache-contracts", @@ -6192,16 +6190,16 @@ }, { "name": "symfony/config", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "e39cdd91b2adf67a117bea29660b73d896937d86" + "reference": "6a5abf67fd5b138df96984fe6f8fd736fb7d6b29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/e39cdd91b2adf67a117bea29660b73d896937d86", - "reference": "e39cdd91b2adf67a117bea29660b73d896937d86", + "url": "https://api.github.com/repos/symfony/config/zipball/6a5abf67fd5b138df96984fe6f8fd736fb7d6b29", + "reference": "6a5abf67fd5b138df96984fe6f8fd736fb7d6b29", "shasum": "" }, "require": { @@ -6247,7 +6245,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.43" + "source": "https://github.com/symfony/config/tree/v6.4.44" }, "funding": [ { @@ -6267,7 +6265,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T09:46:53+00:00" + "time": "2026-08-20T09:38:57+00:00" }, { "name": "symfony/console", @@ -6369,16 +6367,16 @@ }, { "name": "symfony/dependency-injection", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905" + "reference": "675b2dfaf70bdca37d13816582023e3bac5fc2d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905", - "reference": "f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/675b2dfaf70bdca37d13816582023e3bac5fc2d1", + "reference": "675b2dfaf70bdca37d13816582023e3bac5fc2d1", "shasum": "" }, "require": { @@ -6430,7 +6428,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.43" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.44" }, "funding": [ { @@ -6450,7 +6448,7 @@ "type": "tidelift" } ], - "time": "2026-07-20T15:13:12+00:00" + "time": "2026-08-21T15:20:39+00:00" }, { "name": "symfony/deprecation-contracts", @@ -6715,16 +6713,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "ecec4c500623ee0ac0c925c3426bcb3e7f429b14" + "reference": "8fa59eb915a14b2881993565a122c9dd1b0f8a19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/ecec4c500623ee0ac0c925c3426bcb3e7f429b14", - "reference": "ecec4c500623ee0ac0c925c3426bcb3e7f429b14", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8fa59eb915a14b2881993565a122c9dd1b0f8a19", + "reference": "8fa59eb915a14b2881993565a122c9dd1b0f8a19", "shasum": "" }, "require": { @@ -6770,7 +6768,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.43" + "source": "https://github.com/symfony/error-handler/tree/v6.4.44" }, "funding": [ { @@ -6790,20 +6788,20 @@ "type": "tidelift" } ], - "time": "2026-07-21T10:11:50+00:00" + "time": "2026-08-20T17:31:15+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf" + "reference": "d7110a878e7e7cbd8aaebe9f55da8af885b8c0af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ac405d324c10ebbbde6a6e58379bf81db10f1dbf", - "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d7110a878e7e7cbd8aaebe9f55da8af885b8c0af", + "reference": "d7110a878e7e7cbd8aaebe9f55da8af885b8c0af", "shasum": "" }, "require": { @@ -6854,7 +6852,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.43" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.44" }, "funding": [ { @@ -6874,7 +6872,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T14:00:19+00:00" + "time": "2026-08-21T14:01:40+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7026,16 +7024,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16" + "reference": "6dabf41c21957a2060f101bd7bf7e322989866bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16", - "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/6dabf41c21957a2060f101bd7bf7e322989866bd", + "reference": "6dabf41c21957a2060f101bd7bf7e322989866bd", "shasum": "" }, "require": { @@ -7072,7 +7070,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.43" + "source": "https://github.com/symfony/filesystem/tree/v6.4.45" }, "funding": [ { @@ -7092,20 +7090,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T10:13:35+00:00" + "time": "2026-08-23T07:59:31+00:00" }, { "name": "symfony/finder", - "version": "v6.4.42", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "0b73dac42493acbadbba644207a715b254e9b029" + "reference": "04db08efd85558162ef43fba10c299871b60d60b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029", - "reference": "0b73dac42493acbadbba644207a715b254e9b029", + "url": "https://api.github.com/repos/symfony/finder/zipball/04db08efd85558162ef43fba10c299871b60d60b", + "reference": "04db08efd85558162ef43fba10c299871b60d60b", "shasum": "" }, "require": { @@ -7140,7 +7138,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.42" + "source": "https://github.com/symfony/finder/tree/v6.4.46" }, "funding": [ { @@ -7160,7 +7158,7 @@ "type": "tidelift" } ], - "time": "2026-06-26T15:18:24+00:00" + "time": "2026-09-10T14:17:24+00:00" }, { "name": "symfony/flex", @@ -7237,16 +7235,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "dcec940410874561f58edb75e2f3574c8a1340d3" + "reference": "407e77216969a366fd2bbf6d96a84c6b4a6500a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/dcec940410874561f58edb75e2f3574c8a1340d3", - "reference": "dcec940410874561f58edb75e2f3574c8a1340d3", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/407e77216969a366fd2bbf6d96a84c6b4a6500a2", + "reference": "407e77216969a366fd2bbf6d96a84c6b4a6500a2", "shasum": "" }, "require": { @@ -7280,7 +7278,7 @@ "symfony/form": "<5.4", "symfony/http-client": "<6.3", "symfony/lock": "<5.4", - "symfony/mailer": "<5.4", + "symfony/mailer": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5", "symfony/messenger": "<6.3", "symfony/mime": "<6.4.37|>=7.0,<7.4.9", "symfony/property-access": "<5.4", @@ -7366,7 +7364,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.4.43" + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.46" }, "funding": [ { @@ -7386,7 +7384,7 @@ "type": "tidelift" } ], - "time": "2026-07-26T09:00:38+00:00" + "time": "2026-09-13T09:15:59+00:00" }, { "name": "symfony/http-client", @@ -7570,16 +7568,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e" + "reference": "08bbdd5cf09248785442c41929aabe42f8ef5cfd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ea0c801ec34e9017a8c9363e55c8ef5f1717216e", - "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/08bbdd5cf09248785442c41929aabe42f8ef5cfd", + "reference": "08bbdd5cf09248785442c41929aabe42f8ef5cfd", "shasum": "" }, "require": { @@ -7627,7 +7625,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.43" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.46" }, "funding": [ { @@ -7647,20 +7645,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T06:55:26+00:00" + "time": "2026-09-14T17:40:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "cc35556a9bc9e9a6a35fb1114609d1e3c9a63738" + "reference": "c662aa16f2a6b078800ef5302444043c38932213" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cc35556a9bc9e9a6a35fb1114609d1e3c9a63738", - "reference": "cc35556a9bc9e9a6a35fb1114609d1e3c9a63738", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c662aa16f2a6b078800ef5302444043c38932213", + "reference": "c662aa16f2a6b078800ef5302444043c38932213", "shasum": "" }, "require": { @@ -7745,7 +7743,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.43" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.46" }, "funding": [ { @@ -7765,7 +7763,7 @@ "type": "tidelift" } ], - "time": "2026-07-29T11:30:30+00:00" + "time": "2026-09-15T07:06:31+00:00" }, { "name": "symfony/intl", @@ -8359,16 +8357,16 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -8420,7 +8418,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -8440,7 +8438,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -8772,16 +8770,16 @@ }, { "name": "symfony/process", - "version": "v6.4.41", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7" + "reference": "b9f3d9ddf67543b92dbfa6d210c407d7a7982781" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", - "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", + "url": "https://api.github.com/repos/symfony/process/zipball/b9f3d9ddf67543b92dbfa6d210c407d7a7982781", + "reference": "b9f3d9ddf67543b92dbfa6d210c407d7a7982781", "shasum": "" }, "require": { @@ -8813,7 +8811,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.41" + "source": "https://github.com/symfony/process/tree/v6.4.46" }, "funding": [ { @@ -8833,20 +8831,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T13:47:21+00:00" + "time": "2026-09-02T12:35:55+00:00" }, { "name": "symfony/property-access", - "version": "v6.4.32", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "6dfa655ac9e9860c05cabb287f34da86b18c237e" + "reference": "57793e8d1e3ede2d4a65604e8f213a3cfa8d1721" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/6dfa655ac9e9860c05cabb287f34da86b18c237e", - "reference": "6dfa655ac9e9860c05cabb287f34da86b18c237e", + "url": "https://api.github.com/repos/symfony/property-access/zipball/57793e8d1e3ede2d4a65604e8f213a3cfa8d1721", + "reference": "57793e8d1e3ede2d4a65604e8f213a3cfa8d1721", "shasum": "" }, "require": { @@ -8894,7 +8892,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v6.4.32" + "source": "https://github.com/symfony/property-access/tree/v6.4.44" }, "funding": [ { @@ -8914,20 +8912,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T08:25:17+00:00" + "time": "2026-07-30T11:04:25+00:00" }, { "name": "symfony/property-info", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "ff32a0d7885f6f8997ee0aca499fd008852ad5bd" + "reference": "4789c9a2650f9285da6db0363ef40e5a21b42ce9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/ff32a0d7885f6f8997ee0aca499fd008852ad5bd", - "reference": "ff32a0d7885f6f8997ee0aca499fd008852ad5bd", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4789c9a2650f9285da6db0363ef40e5a21b42ce9", + "reference": "4789c9a2650f9285da6db0363ef40e5a21b42ce9", "shasum": "" }, "require": { @@ -8984,7 +8982,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v6.4.43" + "source": "https://github.com/symfony/property-info/tree/v6.4.46" }, "funding": [ { @@ -9004,7 +9002,7 @@ "type": "tidelift" } ], - "time": "2026-07-22T12:35:29+00:00" + "time": "2026-09-03T19:31:46+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -9095,16 +9093,16 @@ }, { "name": "symfony/routing", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "f8dedd42e7a511906aa54be6cebffbbe826ff20e" + "reference": "6fe2ed5b356beec41d5720dd73db72887c3f9d6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/f8dedd42e7a511906aa54be6cebffbbe826ff20e", - "reference": "f8dedd42e7a511906aa54be6cebffbbe826ff20e", + "url": "https://api.github.com/repos/symfony/routing/zipball/6fe2ed5b356beec41d5720dd73db72887c3f9d6d", + "reference": "6fe2ed5b356beec41d5720dd73db72887c3f9d6d", "shasum": "" }, "require": { @@ -9158,7 +9156,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.43" + "source": "https://github.com/symfony/routing/tree/v6.4.45" }, "funding": [ { @@ -9178,7 +9176,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T13:51:56+00:00" + "time": "2026-08-16T17:07:07+00:00" }, { "name": "symfony/runtime", @@ -9265,16 +9263,16 @@ }, { "name": "symfony/security-bundle", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "c77d9d6076e6bb8ba706b8616519e3f52f6b2660" + "reference": "de06cd0f756ca8fe4894c7e95bac71d90bb94e64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c77d9d6076e6bb8ba706b8616519e3f52f6b2660", - "reference": "c77d9d6076e6bb8ba706b8616519e3f52f6b2660", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/de06cd0f756ca8fe4894c7e95bac71d90bb94e64", + "reference": "de06cd0f756ca8fe4894c7e95bac71d90bb94e64", "shasum": "" }, "require": { @@ -9357,7 +9355,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v6.4.43" + "source": "https://github.com/symfony/security-bundle/tree/v6.4.45" }, "funding": [ { @@ -9377,20 +9375,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T06:43:01+00:00" + "time": "2026-08-30T06:17:28+00:00" }, { "name": "symfony/security-core", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "9d2741948b0eb1fc08fd5431909dac8316ba49c2" + "reference": "8f74c37460002b97c1318007629c8be37681498a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/9d2741948b0eb1fc08fd5431909dac8316ba49c2", - "reference": "9d2741948b0eb1fc08fd5431909dac8316ba49c2", + "url": "https://api.github.com/repos/symfony/security-core/zipball/8f74c37460002b97c1318007629c8be37681498a", + "reference": "8f74c37460002b97c1318007629c8be37681498a", "shasum": "" }, "require": { @@ -9417,6 +9415,7 @@ "symfony/expression-language": "^5.4|^6.0|^7.0", "symfony/http-foundation": "^5.4|^6.0|^7.0", "symfony/ldap": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", "symfony/string": "^5.4|^6.0|^7.0", "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", "symfony/validator": "^6.4|^7.0" @@ -9447,7 +9446,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v6.4.43" + "source": "https://github.com/symfony/security-core/tree/v6.4.45" }, "funding": [ { @@ -9467,7 +9466,7 @@ "type": "tidelift" } ], - "time": "2026-07-13T05:00:30+00:00" + "time": "2026-08-30T00:30:12+00:00" }, { "name": "symfony/security-csrf", @@ -9543,16 +9542,16 @@ }, { "name": "symfony/security-http", - "version": "v6.4.42", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "c6a3836e6c331503a841f439912939c4e1be480f" + "reference": "a8ad51e48f20b515e589b5927ee7cde7e1e37453" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/c6a3836e6c331503a841f439912939c4e1be480f", - "reference": "c6a3836e6c331503a841f439912939c4e1be480f", + "url": "https://api.github.com/repos/symfony/security-http/zipball/a8ad51e48f20b515e589b5927ee7cde7e1e37453", + "reference": "a8ad51e48f20b515e589b5927ee7cde7e1e37453", "shasum": "" }, "require": { @@ -9611,7 +9610,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v6.4.42" + "source": "https://github.com/symfony/security-http/tree/v6.4.46" }, "funding": [ { @@ -9631,7 +9630,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T12:40:39+00:00" + "time": "2026-09-15T05:55:29+00:00" }, { "name": "symfony/serializer", @@ -9737,16 +9736,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -9800,7 +9799,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -9820,7 +9819,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/stimulus-bundle", @@ -9963,16 +9962,16 @@ }, { "name": "symfony/string", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49" + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", - "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", + "url": "https://api.github.com/repos/symfony/string/zipball/4513ab2b0ad2d7845a2f920e2f30b61633c9e606", + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606", "shasum": "" }, "require": { @@ -10028,7 +10027,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.43" + "source": "https://github.com/symfony/string/tree/v6.4.46" }, "funding": [ { @@ -10048,7 +10047,7 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:28:15+00:00" + "time": "2026-09-11T09:03:57+00:00" }, { "name": "symfony/translation", @@ -10696,16 +10695,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "6638cfe00907b2b980b749fcf2cd4cd6b2b1396e" + "reference": "86a9b2f1bd81c9780a809632238ab6ba10292166" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6638cfe00907b2b980b749fcf2cd4cd6b2b1396e", - "reference": "6638cfe00907b2b980b749fcf2cd4cd6b2b1396e", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/86a9b2f1bd81c9780a809632238ab6ba10292166", + "reference": "86a9b2f1bd81c9780a809632238ab6ba10292166", "shasum": "" }, "require": { @@ -10760,7 +10759,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.43" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.45" }, "funding": [ { @@ -10780,20 +10779,20 @@ "type": "tidelift" } ], - "time": "2026-07-06T08:03:42+00:00" + "time": "2026-08-30T20:10:39+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.4.42", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "15e670e0218bfe4efbc0556b8a12ad1304f4c7f4" + "reference": "a4e458999ee554a667cd19227f0b97a751bd61e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/15e670e0218bfe4efbc0556b8a12ad1304f4c7f4", - "reference": "15e670e0218bfe4efbc0556b8a12ad1304f4c7f4", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/a4e458999ee554a667cd19227f0b97a751bd61e6", + "reference": "a4e458999ee554a667cd19227f0b97a751bd61e6", "shasum": "" }, "require": { @@ -10841,7 +10840,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.42" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.45" }, "funding": [ { @@ -10861,7 +10860,7 @@ "type": "tidelift" } ], - "time": "2026-06-27T08:12:01+00:00" + "time": "2026-08-23T09:39:49+00:00" }, { "name": "symfony/web-link", -- 2.54.0 From 03ff6f71e8832f2928a43603b67a9fc2fc4dc14d Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 25 Sep 2026 17:56:24 +0200 Subject: [PATCH 45/81] feat: add UserConverter decorator to have league/oauth2-server JWT's sub have User id instead of handle --- src/OAuth/Server/UserConverter.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/OAuth/Server/UserConverter.php diff --git a/src/OAuth/Server/UserConverter.php b/src/OAuth/Server/UserConverter.php new file mode 100644 index 0000000..f925ac4 --- /dev/null +++ b/src/OAuth/Server/UserConverter.php @@ -0,0 +1,30 @@ +setIdentifier((string) $user->getId()); + + return $userEntity; + } +} -- 2.54.0 From b5e03e285af9582df58ec01fdd0a50e851c1e779 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 25 Sep 2026 18:04:20 +0200 Subject: [PATCH 46/81] fix: specify OAuth2 and separate server from client utilities --- public/{oauth => oauth2}/providers/google.png | Bin src/{OAuth => OAuth2/Client}/DecidimProvider.php | 12 ++++++------ src/{OAuth => OAuth2/Client}/TokenOwner.php | 2 +- .../Client}/TokenOwnerInterface.php | 2 +- src/{OAuth => OAuth2}/Server/UserConverter.php | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) rename public/{oauth => oauth2}/providers/google.png (100%) rename src/{OAuth => OAuth2/Client}/DecidimProvider.php (88%) rename src/{OAuth => OAuth2/Client}/TokenOwner.php (96%) rename src/{OAuth => OAuth2/Client}/TokenOwnerInterface.php (85%) rename src/{OAuth => OAuth2}/Server/UserConverter.php (96%) diff --git a/public/oauth/providers/google.png b/public/oauth2/providers/google.png similarity index 100% rename from public/oauth/providers/google.png rename to public/oauth2/providers/google.png diff --git a/src/OAuth/DecidimProvider.php b/src/OAuth2/Client/DecidimProvider.php similarity index 88% rename from src/OAuth/DecidimProvider.php rename to src/OAuth2/Client/DecidimProvider.php index f3f701b..a36c5c8 100644 --- a/src/OAuth/DecidimProvider.php +++ b/src/OAuth2/Client/DecidimProvider.php @@ -1,6 +1,6 @@ : * type: generic - * provider_class: App\OAuth\DecidimProvider + * provider_class: App\OAuth2\Client\DecidimProvider * provider_options: * url: - * client_id: "%env()%" - * client_secret: "%env()%" + * client_id: "%env()%" + * client_secret: "%env()%" * redirect_route: oauth_providers_callback * redirect_params: * provider: decidim_ diff --git a/src/OAuth/TokenOwner.php b/src/OAuth2/Client/TokenOwner.php similarity index 96% rename from src/OAuth/TokenOwner.php rename to src/OAuth2/Client/TokenOwner.php index 98a0d2e..1242c76 100644 --- a/src/OAuth/TokenOwner.php +++ b/src/OAuth2/Client/TokenOwner.php @@ -1,6 +1,6 @@ Date: Fri, 25 Sep 2026 18:25:29 +0200 Subject: [PATCH 47/81] fix: make app_user_provider use ID instead of handle --- config/packages/security.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index f82acb4..41e3f9c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -13,7 +13,7 @@ security: app_user_provider: entity: class: App\Entity\User\User - property: handle + property: id login_user_provider: id: App\Security\LoginUserProvider firewalls: -- 2.54.0 From b0bc61f2f44e76cafbc02670f103ccf75eac2ed2 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 29 Sep 2026 14:26:59 +0200 Subject: [PATCH 48/81] feat: add explaining comment to doctrine_encryption.key_id --- config/packages/doctrine_encryption.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/packages/doctrine_encryption.yaml b/config/packages/doctrine_encryption.yaml index 32b1129..f02918c 100644 --- a/config/packages/doctrine_encryption.yaml +++ b/config/packages/doctrine_encryption.yaml @@ -2,4 +2,6 @@ doctrine_encryption: encrypt_key: "%env(DOCTRINE_ENCRYPTION_ENCRYPT_KEY)%" blind_index_key: "%env(DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY)%" + # Necessary for key rotation + # https://github.com/Kyzegs/doctrine-encryption-bundle#key-rotation key_id: "2026" -- 2.54.0 From 8743c79603321c43d7349f4e7f30e0d18768af54 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 29 Sep 2026 17:01:59 +0200 Subject: [PATCH 49/81] feat: add status to reward claims --- .../Project/RewardClaimApiResource.php | 7 +++++++ src/Entity/Project/RewardClaim.php | 15 +++++++++++++++ src/Entity/Project/RewardClaimStatus.php | 16 ++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 src/Entity/Project/RewardClaimStatus.php diff --git a/src/ApiResource/Project/RewardClaimApiResource.php b/src/ApiResource/Project/RewardClaimApiResource.php index 1f644a0..411315f 100644 --- a/src/ApiResource/Project/RewardClaimApiResource.php +++ b/src/ApiResource/Project/RewardClaimApiResource.php @@ -9,6 +9,7 @@ use App\ApiResource\Gateway\ChargeApiResource; use App\ApiResource\User\UserApiResource; use App\Dto\RewardClaimCreationDto; use App\Entity\Project\RewardClaim; +use App\Entity\Project\RewardClaimStatus; use App\State\ApiResourceStateProvider; use App\State\Project\RewardClaimStateProcessor; use App\Validator\AvailableRewardUnits; @@ -53,4 +54,10 @@ class RewardClaimApiResource #[AvailableRewardUnits()] #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] public RewardApiResource $reward; + + /** + * The point at which the claim is in its life-cylce. + */ + #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] + public RewardClaimStatus $status; } diff --git a/src/Entity/Project/RewardClaim.php b/src/Entity/Project/RewardClaim.php index 1e973d5..0573ca4 100644 --- a/src/Entity/Project/RewardClaim.php +++ b/src/Entity/Project/RewardClaim.php @@ -35,6 +35,9 @@ class RewardClaim implements UserOwnedInterface #[ORM\JoinColumn(nullable: false)] private ?Charge $charge = null; + #[ORM\Column(enumType: RewardClaimStatus::class)] + private ?RewardClaimStatus $status = null; + public function getId(): ?int { return $this->id; @@ -63,4 +66,16 @@ class RewardClaim implements UserOwnedInterface return $this; } + + public function getStatus(): ?RewardClaimStatus + { + return $this->status; + } + + public function setStatus(RewardClaimStatus $status): static + { + $this->status = $status; + + return $this; + } } diff --git a/src/Entity/Project/RewardClaimStatus.php b/src/Entity/Project/RewardClaimStatus.php new file mode 100644 index 0000000..4340485 --- /dev/null +++ b/src/Entity/Project/RewardClaimStatus.php @@ -0,0 +1,16 @@ + Date: Tue, 29 Sep 2026 17:17:09 +0200 Subject: [PATCH 50/81] feat: make default RewardClaim entity status in_pending --- src/Entity/Project/RewardClaim.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Entity/Project/RewardClaim.php b/src/Entity/Project/RewardClaim.php index 0573ca4..f9d51f2 100644 --- a/src/Entity/Project/RewardClaim.php +++ b/src/Entity/Project/RewardClaim.php @@ -36,7 +36,7 @@ class RewardClaim implements UserOwnedInterface private ?Charge $charge = null; #[ORM\Column(enumType: RewardClaimStatus::class)] - private ?RewardClaimStatus $status = null; + private ?RewardClaimStatus $status = RewardClaimStatus::InPending; public function getId(): ?int { -- 2.54.0 From 4fedf2657b1c92537d54faaa96fee08c637e608e Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 29 Sep 2026 17:41:31 +0200 Subject: [PATCH 51/81] feat: add shorter check for ownership of projects --- src/Security/Voter/UserOwnedVoterTrait.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Security/Voter/UserOwnedVoterTrait.php b/src/Security/Voter/UserOwnedVoterTrait.php index 1bdd014..3e664a2 100644 --- a/src/Security/Voter/UserOwnedVoterTrait.php +++ b/src/Security/Voter/UserOwnedVoterTrait.php @@ -3,6 +3,7 @@ namespace App\Security\Voter; use App\ApiResource\Accounting\AccountingApiResource; +use App\ApiResource\Project\ProjectApiResource; use App\ApiResource\User\UserApiResource; use App\Entity\User\User; use App\Entity\UserOwnedInterface; @@ -31,6 +32,10 @@ trait UserOwnedVoterTrait return $subject->id === $user->getAccounting()->getId(); } + if ($subject instanceof ProjectApiResource) { + return $subject->owner->id === $user->getId(); + } + if ($subject instanceof UserOwnedInterface) { return $subject->isOwnedBy($user); } -- 2.54.0 From bb297c5faca36b23fdc40e54a320aa849353d266 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 29 Sep 2026 18:13:43 +0200 Subject: [PATCH 52/81] feat: allow to modify status of reward claims --- .../Project/RewardClaimApiResource.php | 11 +++- src/Dto/RewardClaimUpdationDto.php | 18 ++++++ src/Security/Voter/RewardClaimVoter.php | 64 +++++++++++++++++++ .../Project/RewardClaimStateProcessor.php | 61 ++++++++++++------ 4 files changed, 132 insertions(+), 22 deletions(-) create mode 100644 src/Dto/RewardClaimUpdationDto.php create mode 100644 src/Security/Voter/RewardClaimVoter.php diff --git a/src/ApiResource/Project/RewardClaimApiResource.php b/src/ApiResource/Project/RewardClaimApiResource.php index 411315f..7776c3f 100644 --- a/src/ApiResource/Project/RewardClaimApiResource.php +++ b/src/ApiResource/Project/RewardClaimApiResource.php @@ -8,6 +8,7 @@ use ApiPlatform\Metadata as API; use App\ApiResource\Gateway\ChargeApiResource; use App\ApiResource\User\UserApiResource; use App\Dto\RewardClaimCreationDto; +use App\Dto\RewardClaimUpdationDto; use App\Entity\Project\RewardClaim; use App\Entity\Project\RewardClaimStatus; use App\State\ApiResourceStateProvider; @@ -16,7 +17,10 @@ use App\Validator\AvailableRewardUnits; use Symfony\Component\Validator\Constraints as Assert; /** - * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward. + * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ + * \ + * Once created ProjectRewardClaims can only be deleted by the User who owns it, + * while their status can only be updated by the User who owns the Project of the claimed ProjectReward. */ #[API\ApiResource( shortName: 'ProjectRewardClaim', @@ -27,14 +31,15 @@ use Symfony\Component\Validator\Constraints as Assert; #[API\GetCollection()] #[API\Post(input: RewardClaimCreationDto::class)] #[API\Get()] -#[API\Delete()] +#[API\Patch(input: RewardClaimUpdationDto::class)] +#[API\Delete(security: 'is_granted("CLAIM_OWNS", object)')] class RewardClaimApiResource { #[API\ApiProperty(identifier: true, writable: false)] public int $id; /** - * The User claiming the ProjectReward. + * The User claiming the ProjectReward. Derived from the GatewayCharge. */ #[API\ApiProperty(writable: false)] #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] diff --git a/src/Dto/RewardClaimUpdationDto.php b/src/Dto/RewardClaimUpdationDto.php new file mode 100644 index 0000000..7a18244 --- /dev/null +++ b/src/Dto/RewardClaimUpdationDto.php @@ -0,0 +1,18 @@ +voteEdit($subject, $token->getUser()); + case self::OWNS: + return $this->voteOwns($subject, $token->getUser()); + } + + return false; + } + + private function voteEdit(RewardClaimApiResource $claim, ?UserInterface $user): bool + { + if (!$user instanceof User) { + return false; + } + + if ($user->hasRoles(['ROLE_ADMIN'])) { + return true; + } + + return $this->isOwnerOf($claim->reward->project, $user); + } + + private function voteOwns(RewardClaimApiResource $claim, ?UserInterface $user): bool + { + if (!$user instanceof User) { + return false; + } + + if ($user->hasRoles(['ROLE_ADMIN'])) { + return true; + } + + return $this->isOwnerOf($claim->reward, $user); + } +} diff --git a/src/State/Project/RewardClaimStateProcessor.php b/src/State/Project/RewardClaimStateProcessor.php index a66ea95..a906cbd 100644 --- a/src/State/Project/RewardClaimStateProcessor.php +++ b/src/State/Project/RewardClaimStateProcessor.php @@ -7,44 +7,36 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\ApiResource\Project\RewardClaimApiResource; use App\Dto\RewardClaimCreationDto; +use App\Dto\RewardClaimUpdationDto; use App\Entity\Project\RewardClaim; use App\Mapping\AutoMapper; +use App\Repository\Project\RewardClaimRepository; +use App\Security\Voter\AccountingVoter; use App\State\EntityStateProcessor; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Security\Core\Exception\AuthenticationException; class RewardClaimStateProcessor implements ProcessorInterface { public function __construct( - private EntityStateProcessor $entityStateProcessor, private AutoMapper $autoMapper, + private RewardClaimRepository $rewardClaimRepository, private Security $security, + private EntityStateProcessor $entityStateProcessor, ) {} /** - * @param RewardClaimApiResource|RewardClaimCreationDto $data + * @param RewardClaimCreationDto|RewardClaimUpdationDto $data * * @return RewardClaimApiResource|null */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { - /** @var RewardClaim */ - $claim = $this->autoMapper->map($data, RewardClaim::class); - - if ($data instanceof RewardClaimCreationDto) { - $owner = $this->security->getUser(); - - if (!$owner) { - throw new AuthenticationException(); - } - - $claim->setOwner($owner); - - $reward = $claim->getReward(); - $reward->addClaim($claim); - - $claim->setReward($reward); - } + $claim = match ($data::class) { + RewardClaimUpdationDto::class => $this->getClaimFromUpdate($context), + RewardClaimCreationDto::class => $this->getClaimFromCreate($data), + }; $claim = $this->entityStateProcessor->process($claim, $operation, $uriVariables, $context); @@ -52,6 +44,37 @@ class RewardClaimStateProcessor implements ProcessorInterface return; } + if ($claim === null) { + return null; + } + return $this->autoMapper->map($claim, $data); } + + private function getClaimFromUpdate(array $context): RewardClaim + { + $claim = $this->rewardClaimRepository->find($context['previous_data']->id); + if (!$claim) { + throw new NotFoundHttpException(); + } + + return $claim; + } + + private function getClaimFromCreate(RewardClaimCreationDto $data): RewardClaim + { + /** @var RewardClaim */ + $claim = $this->autoMapper->map($data, RewardClaim::class); + + $origin = $claim->getCharge()->getCheckout()->getOrigin(); + if (!$this->security->isGranted(AccountingVoter::EDIT, $origin)) { + throw new AuthenticationException(); + } + + $owner = $origin->getUser(); + + $claim->setOwner($owner); + + return $claim; + } } -- 2.54.0 From 81a264a241f14cfa98cbe846de77644433f151f9 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 29 Sep 2026 18:54:25 +0200 Subject: [PATCH 53/81] feat: add shipping address to reward claims --- .../Project/RewardClaimApiResource.php | 6 ++ src/Dto/RewardClaimCreationDto.php | 7 ++ src/Dto/RewardClaimUpdationDto.php | 8 ++ src/Entity/Project/RewardClaim.php | 16 ++++ src/Entity/ShippingAddress.php | 81 +++++++++++++++++++ 5 files changed, 118 insertions(+) create mode 100644 src/Entity/ShippingAddress.php diff --git a/src/ApiResource/Project/RewardClaimApiResource.php b/src/ApiResource/Project/RewardClaimApiResource.php index 7776c3f..05bc3f7 100644 --- a/src/ApiResource/Project/RewardClaimApiResource.php +++ b/src/ApiResource/Project/RewardClaimApiResource.php @@ -11,6 +11,7 @@ use App\Dto\RewardClaimCreationDto; use App\Dto\RewardClaimUpdationDto; use App\Entity\Project\RewardClaim; use App\Entity\Project\RewardClaimStatus; +use App\Entity\ShippingAddress; use App\State\ApiResourceStateProvider; use App\State\Project\RewardClaimStateProcessor; use App\Validator\AvailableRewardUnits; @@ -65,4 +66,9 @@ class RewardClaimApiResource */ #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] public RewardClaimStatus $status; + + /** + * Only used when the reward is a physical object that needs to be shipped. + */ + public ?ShippingAddress $shippingAddress; } diff --git a/src/Dto/RewardClaimCreationDto.php b/src/Dto/RewardClaimCreationDto.php index b6f5251..eff8adc 100644 --- a/src/Dto/RewardClaimCreationDto.php +++ b/src/Dto/RewardClaimCreationDto.php @@ -4,6 +4,7 @@ namespace App\Dto; use App\ApiResource\Gateway\ChargeApiResource; use App\ApiResource\Project\RewardApiResource; +use App\Entity\ShippingAddress; use App\Validator\AvailableRewardUnits; use App\Validator\EnoughRewardCharge; use App\Validator\SameProjectRewardCharge; @@ -27,4 +28,10 @@ class RewardClaimCreationDto #[Assert\NotBlank()] #[AvailableRewardUnits()] public RewardApiResource $reward; + + /** + * If the reward is a physical object that needs to be delivered to an specific place. + */ + #[Assert\Valid()] + public ?ShippingAddress $shippingAddress; } diff --git a/src/Dto/RewardClaimUpdationDto.php b/src/Dto/RewardClaimUpdationDto.php index 7a18244..307a51a 100644 --- a/src/Dto/RewardClaimUpdationDto.php +++ b/src/Dto/RewardClaimUpdationDto.php @@ -4,6 +4,7 @@ namespace App\Dto; use ApiPlatform\Metadata as API; use App\Entity\Project\RewardClaimStatus; +use App\Entity\ShippingAddress; use Symfony\Component\Validator\Constraints as Assert; class RewardClaimUpdationDto @@ -15,4 +16,11 @@ class RewardClaimUpdationDto #[Assert\NotBlank()] #[API\ApiProperty(security: 'is_granted("CLAIM_EDIT", object)')] public RewardClaimStatus $status; + + /** + * If the reward is a physical object that needs to be delivered to an specific place. + */ + #[Assert\Valid()] + #[API\ApiProperty(security: 'is_granted("CLAIM_OWNS", object)')] + public ?ShippingAddress $shippingAddress; } diff --git a/src/Entity/Project/RewardClaim.php b/src/Entity/Project/RewardClaim.php index f9d51f2..fd0b6c9 100644 --- a/src/Entity/Project/RewardClaim.php +++ b/src/Entity/Project/RewardClaim.php @@ -6,6 +6,7 @@ use App\Entity\Gateway\Charge; use App\Entity\User\User; use App\Entity\UserOwnedInterface; use App\Entity\UserOwnedTrait; +use App\Entity\ShippingAddress; use App\Mapping\Provider\EntityMapProvider; use App\Repository\Project\RewardClaimRepository; use AutoMapper\Attribute\MapProvider; @@ -38,6 +39,9 @@ class RewardClaim implements UserOwnedInterface #[ORM\Column(enumType: RewardClaimStatus::class)] private ?RewardClaimStatus $status = RewardClaimStatus::InPending; + #[ORM\Embedded(class: ShippingAddress::class)] + private ?ShippingAddress $shippingAddress = null; + public function getId(): ?int { return $this->id; @@ -78,4 +82,16 @@ class RewardClaim implements UserOwnedInterface return $this; } + + public function getShippingAddress(): ?ShippingAddress + { + return $this->shippingAddress; + } + + public function setShippingAddress(?ShippingAddress $shippingAddress): static + { + $this->shippingAddress = $shippingAddress; + + return $this; + } } diff --git a/src/Entity/ShippingAddress.php b/src/Entity/ShippingAddress.php new file mode 100644 index 0000000..8b0d065 --- /dev/null +++ b/src/Entity/ShippingAddress.php @@ -0,0 +1,81 @@ + Date: Tue, 29 Sep 2026 18:55:07 +0200 Subject: [PATCH 54/81] fix: remove requirement of status in claim update op --- src/Dto/RewardClaimUpdationDto.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Dto/RewardClaimUpdationDto.php b/src/Dto/RewardClaimUpdationDto.php index 307a51a..166e000 100644 --- a/src/Dto/RewardClaimUpdationDto.php +++ b/src/Dto/RewardClaimUpdationDto.php @@ -13,7 +13,6 @@ class RewardClaimUpdationDto * The point at which the claim over the reward is.\ * May only be updated by admins or the User who owns the Project of the ProjectReward. */ - #[Assert\NotBlank()] #[API\ApiProperty(security: 'is_granted("CLAIM_EDIT", object)')] public RewardClaimStatus $status; -- 2.54.0 From 242755e01e876c0c8e22411ce128864a77f785dc Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 29 Sep 2026 19:01:18 +0200 Subject: [PATCH 55/81] fix: update filters and remove obsolete attributes from ProjectRewardClaim resource --- src/ApiResource/Project/RewardClaimApiResource.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/ApiResource/Project/RewardClaimApiResource.php b/src/ApiResource/Project/RewardClaimApiResource.php index 05bc3f7..0c78849 100644 --- a/src/ApiResource/Project/RewardClaimApiResource.php +++ b/src/ApiResource/Project/RewardClaimApiResource.php @@ -14,8 +14,6 @@ use App\Entity\Project\RewardClaimStatus; use App\Entity\ShippingAddress; use App\State\ApiResourceStateProvider; use App\State\Project\RewardClaimStateProcessor; -use App\Validator\AvailableRewardUnits; -use Symfony\Component\Validator\Constraints as Assert; /** * A ProjectRewardClaim represents the will of an User who wishes to obtain one ProjectReward.\ @@ -42,22 +40,18 @@ class RewardClaimApiResource /** * The User claiming the ProjectReward. Derived from the GatewayCharge. */ - #[API\ApiProperty(writable: false)] #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] public UserApiResource $owner; /** * The GatewayCharge granting access to the ProjectReward. */ - #[Assert\NotBlank()] #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] public ChargeApiResource $charge; /** * The ProjectReward being claimed. */ - #[Assert\NotBlank()] - #[AvailableRewardUnits()] #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] public RewardApiResource $reward; @@ -70,5 +64,8 @@ class RewardClaimApiResource /** * Only used when the reward is a physical object that needs to be shipped. */ + #[API\ApiFilter(SearchFilter::class, properties: ['shippingAddress.city' => 'partial'])] + #[API\ApiFilter(SearchFilter::class, properties: ['shippingAddress.postCode' => 'partial'])] + #[API\ApiFilter(SearchFilter::class, properties: ['shippingAddress.country' => 'partial'])] public ?ShippingAddress $shippingAddress; } -- 2.54.0 From 6f88f746f32e74b9152d3f3ba80bd2ca30e57419 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 29 Sep 2026 19:07:06 +0200 Subject: [PATCH 56/81] feat: update DB layer for project reward claims --- migrations/Version20260929170504.php | 26 ++++++++++++++++++++++++++ src/Entity/ShippingAddress.php | 7 +++++++ 2 files changed, 33 insertions(+) create mode 100644 migrations/Version20260929170504.php diff --git a/migrations/Version20260929170504.php b/migrations/Version20260929170504.php new file mode 100644 index 0000000..c013652 --- /dev/null +++ b/migrations/Version20260929170504.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE project_reward_claim ADD status VARCHAR(255) NOT NULL, ADD shipping_address_first_name VARCHAR(255) NOT NULL, ADD shipping_address_last_name VARCHAR(255) NOT NULL, ADD shipping_address_address_line1 VARCHAR(255) NOT NULL, ADD shipping_address_address_line2 VARCHAR(255) DEFAULT NULL, ADD shipping_address_city VARCHAR(255) NOT NULL, ADD shipping_address_post_code VARCHAR(255) NOT NULL, ADD shipping_address_country VARCHAR(2) NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_reward_claim DROP status, DROP shipping_address_first_name, DROP shipping_address_last_name, DROP shipping_address_address_line1, DROP shipping_address_address_line2, DROP shipping_address_city, DROP shipping_address_post_code, DROP shipping_address_country'); + } +} diff --git a/src/Entity/ShippingAddress.php b/src/Entity/ShippingAddress.php index 8b0d065..0ad62a5 100644 --- a/src/Entity/ShippingAddress.php +++ b/src/Entity/ShippingAddress.php @@ -13,35 +13,41 @@ final class ShippingAddress * First name(s) of the person receiving the shipment. */ #[Assert\NotBlank()] + #[ORM\Column(length: 255)] public readonly string $firstName, /** * Last name(s) of the person receiving the shipment. */ #[Assert\NotBlank()] + #[ORM\Column(length: 255)] public readonly string $lastName, /** * Line 1: usually street name and number. */ #[Assert\NotBlank()] + #[ORM\Column(length: 255)] public readonly string $addressLine1, /** * Line 2: additional data like apartment number, door, etc. */ + #[ORM\Column(length: 255, nullable: true)] public readonly ?string $addressLine2, /** * Name of the city, or the lowest-available type of settlement to which the address lines belong. */ #[Assert\NotBlank()] + #[ORM\Column(length: 255)] public readonly string $city, /** * Postal/PIN/ZIP code to which the address lines belong. */ #[Assert\NotBlank()] + #[ORM\Column(length: 255)] public readonly string $postCode, /** @@ -50,6 +56,7 @@ final class ShippingAddress */ #[Assert\NotBlank()] #[Assert\Country(alpha3: false)] + #[ORM\Column(length: 2)] public readonly string $country, ) {} -- 2.54.0 From 995fd339b78fa488bbbda5724c35b562df87622b Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 29 Sep 2026 19:07:38 +0200 Subject: [PATCH 57/81] fix: code style issues by PHP-CS-Fixer --- src/Entity/Project/RewardClaim.php | 2 +- src/Entity/ShippingAddress.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Entity/Project/RewardClaim.php b/src/Entity/Project/RewardClaim.php index fd0b6c9..181e0d0 100644 --- a/src/Entity/Project/RewardClaim.php +++ b/src/Entity/Project/RewardClaim.php @@ -3,10 +3,10 @@ namespace App\Entity\Project; use App\Entity\Gateway\Charge; +use App\Entity\ShippingAddress; use App\Entity\User\User; use App\Entity\UserOwnedInterface; use App\Entity\UserOwnedTrait; -use App\Entity\ShippingAddress; use App\Mapping\Provider\EntityMapProvider; use App\Repository\Project\RewardClaimRepository; use AutoMapper\Attribute\MapProvider; diff --git a/src/Entity/ShippingAddress.php b/src/Entity/ShippingAddress.php index 0ad62a5..4d32b04 100644 --- a/src/Entity/ShippingAddress.php +++ b/src/Entity/ShippingAddress.php @@ -78,7 +78,7 @@ final class ShippingAddress ); } - throw new \Exception("Could not get a ShippingAddress object from value"); + throw new \Exception('Could not get a ShippingAddress object from value'); } public function toArray(): array -- 2.54.0 From a328073b0f6fc687de8b80bac0ba5d27ce038ff9 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 09:55:04 +0200 Subject: [PATCH 58/81] feat: add DoctrineLoggablePumpTrait to remove Gedmo Loggable event listener --- src/Benzina/DoctrineLoggablePumpTrait.php | 34 +++++++++++++++++++++++ src/Benzina/InvestRewardsPump.php | 3 +- src/Benzina/InvestsPump.php | 3 +- src/Benzina/ProjectsBudgetPump.php | 3 +- src/Benzina/ProjectsPump.php | 3 +- src/Benzina/ProjectsRewardsPump.php | 3 +- src/Benzina/UsersPump.php | 3 +- 7 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 src/Benzina/DoctrineLoggablePumpTrait.php diff --git a/src/Benzina/DoctrineLoggablePumpTrait.php b/src/Benzina/DoctrineLoggablePumpTrait.php new file mode 100644 index 0000000..b351a38 --- /dev/null +++ b/src/Benzina/DoctrineLoggablePumpTrait.php @@ -0,0 +1,34 @@ +getConnection()->getConfiguration()->getMiddlewares(); + $middlewares = \array_filter($middlewares, fn($m) => !$m instanceof LoggingMiddleware); + + $entityManager->getConnection()->getConfiguration()->setMiddlewares($middlewares); + + $evm = $entityManager->getEventManager(); + foreach ($evm->getAllListeners() as $event => $listeners) { + foreach ($listeners as $listener) { + if ($listener instanceof LoggableListener) { + $evm->removeEventListener([$event], $listener); + } + } + } + + $this->setEntityManager($entityManager); + } +} diff --git a/src/Benzina/InvestRewardsPump.php b/src/Benzina/InvestRewardsPump.php index 83acb14..3d7b22b 100644 --- a/src/Benzina/InvestRewardsPump.php +++ b/src/Benzina/InvestRewardsPump.php @@ -13,14 +13,13 @@ use App\Repository\Project\RewardRepository; use App\Repository\User\UserRepository; use Doctrine\Common\Collections\Criteria; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; class InvestRewardsPump implements PumpInterface { use ArrayPumpTrait; use DatabasePumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use InvestsPumpTrait; /** @var array */ diff --git a/src/Benzina/InvestsPump.php b/src/Benzina/InvestsPump.php index f3e01fd..4bee943 100644 --- a/src/Benzina/InvestsPump.php +++ b/src/Benzina/InvestsPump.php @@ -29,14 +29,13 @@ use App\Repository\Project\SupportRepository; use App\Repository\TipjarRepository; use App\Service\Gateway\CheckoutService; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; class InvestsPump implements PumpInterface { use ArrayPumpTrait; use DatabasePumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use InvestsPumpTrait; public const TRACKING_TITLE_V3 = 'v3 Invest ID'; diff --git a/src/Benzina/ProjectsBudgetPump.php b/src/Benzina/ProjectsBudgetPump.php index d461430..b0657cb 100644 --- a/src/Benzina/ProjectsBudgetPump.php +++ b/src/Benzina/ProjectsBudgetPump.php @@ -9,14 +9,13 @@ use App\Entity\Project\Project; use App\Entity\Project\ProjectDeadline; use App\Repository\Project\ProjectRepository; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; class ProjectsBudgetPump implements PumpInterface { use ArrayPumpTrait; use DatabasePumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use LocalizedPumpTrait; private const MAX_INT = 2147483647; diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index bd7665e..3cfd786 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -18,13 +18,12 @@ use App\Service\Scout\ScoutService; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Criteria; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; class ProjectsPump implements PumpInterface { use ArrayPumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use DatabasePumpTrait; use ProjectsPumpTrait; use LocalizedPumpTrait; diff --git a/src/Benzina/ProjectsRewardsPump.php b/src/Benzina/ProjectsRewardsPump.php index 81b0a70..1d68a4c 100644 --- a/src/Benzina/ProjectsRewardsPump.php +++ b/src/Benzina/ProjectsRewardsPump.php @@ -8,14 +8,13 @@ use App\Entity\Project\Project; use App\Entity\Project\Reward; use App\Repository\Project\ProjectRepository; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; class ProjectsRewardsPump implements PumpInterface { use ArrayPumpTrait; use DatabasePumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use LocalizedPumpTrait; private const REWARD_KEYS = [ diff --git a/src/Benzina/UsersPump.php b/src/Benzina/UsersPump.php index 0aa03c3..69d65fa 100644 --- a/src/Benzina/UsersPump.php +++ b/src/Benzina/UsersPump.php @@ -12,7 +12,6 @@ use App\Service\Project\TerritoryService; use App\Service\UserService; use Doctrine\Persistence\ManagerRegistry; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; use Symfony\Component\Validator\Constraints\Url; use Symfony\Component\Validator\Validation; @@ -20,7 +19,7 @@ use Symfony\Component\Validator\Validation; class UsersPump implements PumpInterface { use ArrayPumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use UsersPumpTrait; use TerritoryPumpTrait; -- 2.54.0 From 80178bb7f046ba7bbf42aaa461a5310d596d0782 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 09:56:26 +0200 Subject: [PATCH 59/81] fix: update legacy ref to FileUriException --- src/Benzina/ProjectsPump.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 3cfd786..49b7afc 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -13,7 +13,7 @@ use App\Entity\User\User; use App\Repository\Project\ProjectRepository; use App\Repository\User\UserRepository; use App\Service\Project\TerritoryService; -use App\Service\Scout\FileUriException; +use App\Service\Scout\NonCrawlableUriException; use App\Service\Scout\ScoutService; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Criteria; @@ -239,7 +239,7 @@ class ProjectsPump implements PumpInterface } return new ProjectVideo($info->url, $info->cover ?? $info->image, $info->image); - } catch (FileUriException $e) { + } catch (NonCrawlableUriException $e) { return new ProjectVideo($e->getUri()); } catch (\Exception $e) { return null; -- 2.54.0 From 1f6eb4fb4d573f6e0bc199990295cde2593969ec Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 10:16:52 +0200 Subject: [PATCH 60/81] feat: pump reward claim status and shipping address from v3 records --- src/Benzina/InvestRewardsPump.php | 40 +++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/Benzina/InvestRewardsPump.php b/src/Benzina/InvestRewardsPump.php index 3d7b22b..b6b1242 100644 --- a/src/Benzina/InvestRewardsPump.php +++ b/src/Benzina/InvestRewardsPump.php @@ -5,12 +5,14 @@ namespace App\Benzina; use App\Entity\Gateway\Charge; use App\Entity\Project\Reward; use App\Entity\Project\RewardClaim; +use App\Entity\Project\RewardClaimStatus; +use App\Entity\ShippingAddress; use App\Entity\User\User; use App\Gateway\ChargeStatus; -use App\Money\MoneyService; use App\Repository\Gateway\ChargeRepository; use App\Repository\Project\RewardRepository; use App\Repository\User\UserRepository; +use App\Service\UserService; use Doctrine\Common\Collections\Criteria; use Goteo\Benzina\Pump\ArrayPumpTrait; use Goteo\Benzina\Pump\PumpInterface; @@ -35,7 +37,6 @@ class InvestRewardsPump implements PumpInterface private RewardRepository $rewardRepository, private UserRepository $userRepository, private ChargeRepository $chargeRepository, - private MoneyService $moneyService, ) {} public function supports(mixed $sample): bool @@ -80,6 +81,14 @@ class InvestRewardsPump implements PumpInterface $reward->addClaim($claim); $claim->setReward($reward); + $status = match ($record['fulfilled']) { + 0 => RewardClaimStatus::InPending, + 1 => RewardClaimStatus::Fulfilled, + }; + + $claim->setStatus($status); + $claim->setShippingAddress($this->getAddress($record, $context)); + $this->persist($claim, $context); } @@ -156,4 +165,31 @@ class InvestRewardsPump implements PumpInterface return $user; } + + private function getAddress(array $record, array $context): ?ShippingAddress + { + $query = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `invest_address` a WHERE a.invest = :invest' + ); + + $query->execute(['invest' => $record['invest']]); + + $result = $query->fetch(\PDO::FETCH_ASSOC); + + if (!$result) { + return null; + } + + [$firstName, $lastName] = UserService::guessNames($result['name']); + + return new ShippingAddress( + $firstName, + $lastName, + $result['address'], + null, + $result['location'], + $result['zipcode'], + $result['country'] + ); + } } -- 2.54.0 From 9b8911eefb1c1b8b6af1f90dd5c9d1eeb6c7c73f Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 10:24:27 +0200 Subject: [PATCH 61/81] fix: update pumps batch sizes and injected dependencies --- src/Benzina/InvestRewardsPump.php | 26 +++++--------------------- src/Benzina/InvestsPump.php | 6 +++--- src/Benzina/ProjectsPump.php | 12 ++---------- src/Benzina/UsersPump.php | 4 +++- 4 files changed, 13 insertions(+), 35 deletions(-) diff --git a/src/Benzina/InvestRewardsPump.php b/src/Benzina/InvestRewardsPump.php index b6b1242..391f7d4 100644 --- a/src/Benzina/InvestRewardsPump.php +++ b/src/Benzina/InvestRewardsPump.php @@ -11,9 +11,7 @@ use App\Entity\User\User; use App\Gateway\ChargeStatus; use App\Repository\Gateway\ChargeRepository; use App\Repository\Project\RewardRepository; -use App\Repository\User\UserRepository; use App\Service\UserService; -use Doctrine\Common\Collections\Criteria; use Goteo\Benzina\Pump\ArrayPumpTrait; use Goteo\Benzina\Pump\PumpInterface; @@ -35,9 +33,11 @@ class InvestRewardsPump implements PumpInterface public function __construct( private RewardRepository $rewardRepository, - private UserRepository $userRepository, + private PumpedUserRepository $userRepository, private ChargeRepository $chargeRepository, - ) {} + ) { + $this->setFlushBatchSize(8); + } public function supports(mixed $sample): bool { @@ -147,23 +147,7 @@ class InvestRewardsPump implements PumpInterface private function getUser(array $record): ?User { - $id = $record['user']; - - if (isset($this->userCache[$id])) { - return $this->userRepository->find($this->userCache[$id]); - } - - $criteria = new Criteria(); - $criteria - ->orWhere($criteria->expr()->eq('migratedId', $id)) - ->orWhere($criteria->expr()->contains('dedupedIds', $id)) - ->setMaxResults(1); - - $user = $this->userRepository->matching($criteria)->first(); - - $this->userCache[$id] = $user->getId(); - - return $user; + return $this->userRepository->findPumped($record['user']); } private function getAddress(array $record, array $context): ?ShippingAddress diff --git a/src/Benzina/InvestsPump.php b/src/Benzina/InvestsPump.php index 4bee943..77094a4 100644 --- a/src/Benzina/InvestsPump.php +++ b/src/Benzina/InvestsPump.php @@ -27,7 +27,6 @@ use App\Repository\Project\ProjectRepository; use App\Repository\Project\RewardRepository; use App\Repository\Project\SupportRepository; use App\Repository\TipjarRepository; -use App\Service\Gateway\CheckoutService; use Goteo\Benzina\Pump\ArrayPumpTrait; use Goteo\Benzina\Pump\PumpInterface; @@ -70,9 +69,10 @@ class InvestsPump implements PumpInterface private SupportRepository $supportRepository, private TipjarRepository $tipjarRepository, private RewardRepository $rewardRepository, - private CheckoutService $checkoutService, private MoneyService $moneyService, - ) {} + ) { + $this->setFlushBatchSize(8); + } public function supports(mixed $sample): bool { diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 49b7afc..c4885f6 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -11,12 +11,10 @@ use App\Entity\Project\Update; use App\Entity\Territory; use App\Entity\User\User; use App\Repository\Project\ProjectRepository; -use App\Repository\User\UserRepository; use App\Service\Project\TerritoryService; use App\Service\Scout\NonCrawlableUriException; use App\Service\Scout\ScoutService; use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Criteria; use Goteo\Benzina\Pump\ArrayPumpTrait; use Goteo\Benzina\Pump\PumpInterface; @@ -31,7 +29,7 @@ class ProjectsPump implements PumpInterface public function __construct( private ProjectRepository $projectRepository, - private UserRepository $userRepository, + private PumpedUserRepository $userRepository, private TerritoryService $territoryService, private ScoutService $scoutService, ) {} @@ -123,13 +121,7 @@ class ProjectsPump implements PumpInterface private function getProjectOwner(array $record): ?User { - $criteria = new Criteria(); - $criteria - ->orWhere($criteria->expr()->eq('migratedId', $record['owner'])) - ->orWhere($criteria->expr()->contains('dedupedIds', $record['owner'])) - ->setMaxResults(1); - - return $this->userRepository->matching($criteria)->first() ?? null; + return $this->userRepository->findPumped($record['owner']); } private function getProjectLocalizations(Project $project, array $context): array diff --git a/src/Benzina/UsersPump.php b/src/Benzina/UsersPump.php index 69d65fa..41af75d 100644 --- a/src/Benzina/UsersPump.php +++ b/src/Benzina/UsersPump.php @@ -26,7 +26,9 @@ class UsersPump implements PumpInterface public function __construct( private ManagerRegistry $managerRegistry, private TerritoryService $territoryService, - ) {} + ) { + $this->setFlushBatchSize(8); + } public function supports(mixed $sample): bool { -- 2.54.0 From 74db6791462a4d0cace830063a65a0e3144fdd6b Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 13:06:51 +0200 Subject: [PATCH 62/81] feat: pump user roles --- src/Benzina/UsersPump.php | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/Benzina/UsersPump.php b/src/Benzina/UsersPump.php index 41af75d..37491d9 100644 --- a/src/Benzina/UsersPump.php +++ b/src/Benzina/UsersPump.php @@ -22,6 +22,7 @@ class UsersPump implements PumpInterface use DoctrineLoggablePumpTrait; use UsersPumpTrait; use TerritoryPumpTrait; + use DatabasePumpTrait; public function __construct( private ManagerRegistry $managerRegistry, @@ -42,7 +43,7 @@ class UsersPump implements PumpInterface public function pump(mixed $record, array $context): void { $user = new User(); - $user = $this->processUser($user, $record); + $user = $this->processUser($user, $record, $context); try { $this->persist($user, $context); @@ -63,7 +64,7 @@ class UsersPump implements PumpInterface } $user = new User(); - $user = $this->processUser($user, $record); + $user = $this->processUser($user, $record, $context); $user->setHandle(UserService::asHandle($record['id'], 16, 255)); $this->persist($user, $context); @@ -72,7 +73,7 @@ class UsersPump implements PumpInterface } } - private function processUser(User $user, array $record): User + private function processUser(User $user, array $record, array $context): User { $user->setHandle($this->buildHandle($record)); $user->setPassword($record['password'] ?? ''); @@ -87,6 +88,7 @@ class UsersPump implements PumpInterface $user->setLinks($this->getLinks($record)); $user->setTerritory($this->getTerritory($record)); $user->setDescription($record['about']); + $user->setRoles($this->getRoles($record, $context)); match ($user->getType()) { UserType::Individual => $user = $this->setUserPerson($record, $user), @@ -205,4 +207,28 @@ class UsersPump implements PumpInterface return $this->territoryService->search($cleanAddress); } + + private function getRoles(array $record, array $context): array + { + $query = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `user_role` r WHERE r.user_id = :user' + ); + + $query->execute(['user' => $record['id']]); + + $results = $query->fetchAll(\PDO::FETCH_ASSOC); + + if (!$results || empty($results)) { + return []; + } + + $roles = []; + foreach ($results as $result) { + if (in_array($result['role_id'], ['superadmin', 'manager'])) { + $roles[] = 'ROLE_ADMIN'; + } + } + + return $roles; + } } -- 2.54.0 From 87ccc073df0dc349106f524253418213116dd103 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 14:15:09 +0200 Subject: [PATCH 63/81] feat: add true image cover retrieval and leave video as fallback for ProjectsPump --- src/Benzina/ProjectsPump.php | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index c4885f6..70199a2 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -81,9 +81,15 @@ class ProjectsPump implements PumpInterface $project->setUpdates(new ArrayCollection($this->getProjectUpdates($project, $context))); $video = $this->getProjectVideo($record); + if ($video !== null) { + $project->setVideo($video); + $project->setCover($video->cover); + } - $project->setVideo($video); - $project->setCover($video->cover); + $cover = $this->getProjectCover($record, $context); + if ($cover !== null) { + $project->setCover($cover); + } $conf = $this->getProjectConf($project, $context); @@ -337,4 +343,38 @@ class ProjectsPump implements PumpInterface return ProjectDeadline::Minimum; } + + private function getImageSource(array $record, array $context): ?string + { + $query = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `project_image` WHERE `project` = :project ORDER BY `order` ASC' + ); + + $query->execute(['project' => $record['id']]); + + $images = $query->fetchAll(); + + foreach ($images as $image) { + if ($image['section'] === 'play-video') { + return $image['image']; + } + } + + return $record['image']; + } + + private function getProjectCover(array $record, array $context): ?string + { + $image = $this->getImageSource($record, $context); + + if ($image === null || $image === '') { + return null; + } + + if (!\str_contains($image, '.')) { + return null; + } + + return \sprintf('https://s3.eu-west-1.amazonaws.com/goteoassets.org/images/%s', $image); + } } -- 2.54.0 From 10c7806ef6855e76663cfb890bd8333be4e94abb Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 14:32:50 +0200 Subject: [PATCH 64/81] feat: pump project collaborations --- src/Benzina/ProjectsPump.php | 23 +++++++++++++++++++++++ src/Entity/Project/Project.php | 10 ++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 70199a2..43f1e9e 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -2,6 +2,7 @@ namespace App\Benzina; +use App\Entity\Project\Collaboration; use App\Entity\Project\Project; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectDeadline; @@ -79,6 +80,7 @@ class ProjectsPump implements PumpInterface $project->setDateUpdated(new \DateTime()); $project->setTranslatableLocale($record['lang']); $project->setUpdates(new ArrayCollection($this->getProjectUpdates($project, $context))); + $project->setCollaborations(new ArrayCollection($this->getProjectCollaborations($project, $context))); $video = $this->getProjectVideo($record); if ($video !== null) { @@ -377,4 +379,25 @@ class ProjectsPump implements PumpInterface return \sprintf('https://s3.eu-west-1.amazonaws.com/goteoassets.org/images/%s', $image); } + + private function getProjectCollaborations(Project $project, array $context): array + { + $collaborations = []; + + $query = $this->getDbConnection($context)->prepare( + "SELECT * FROM `support` s WHERE s.project = :project" + ); + + $query->execute(['project' => $project->getMigratedId()]); + $supports = $query->fetchAll(); + + foreach ($supports as $support) { + $collaboration = new Collaboration(); + $collaboration->setTitle($support['support']); + $collaboration->setDescription($support['description']); + $collaborations[] = $collaboration; + } + + return $collaborations; + } } diff --git a/src/Entity/Project/Project.php b/src/Entity/Project/Project.php index 4281ef6..0534196 100644 --- a/src/Entity/Project/Project.php +++ b/src/Entity/Project/Project.php @@ -561,6 +561,16 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this->collaborations; } + /** + * @param Collection $collaborations + */ + public function setCollaborations(Collection $collaborations): static + { + $this->collaborations = $collaborations; + + return $this; + } + public function addCollaboration(Collaboration $collaboration): static { if (!$this->collaborations->contains($collaboration)) { -- 2.54.0 From 574b6d3ccff8bcf70be0d1ef2ccd7ae75052b29a Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 14:55:13 +0200 Subject: [PATCH 65/81] feat: allow cascading of collaborations --- src/Benzina/ProjectsPump.php | 1 + src/Entity/Project/Project.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 43f1e9e..3613a0e 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -395,6 +395,7 @@ class ProjectsPump implements PumpInterface $collaboration = new Collaboration(); $collaboration->setTitle($support['support']); $collaboration->setDescription($support['description']); + $collaborations[] = $collaboration; } diff --git a/src/Entity/Project/Project.php b/src/Entity/Project/Project.php index 0534196..9b0e923 100644 --- a/src/Entity/Project/Project.php +++ b/src/Entity/Project/Project.php @@ -157,7 +157,7 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized /** * @var Collection */ - #[ORM\OneToMany(targetEntity: Collaboration::class, mappedBy: 'project')] + #[ORM\OneToMany(targetEntity: Collaboration::class, mappedBy: 'project', cascade: ['persist'])] private Collection $collaborations; /** -- 2.54.0 From 1bcb1f3daac75156e0df9ba5003f14d1f21b9ddf Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 15:03:18 +0200 Subject: [PATCH 66/81] fix: set isFulfilled of collaboration --- src/Benzina/ProjectsPump.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 3613a0e..927b1a3 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -392,9 +392,14 @@ class ProjectsPump implements PumpInterface $supports = $query->fetchAll(); foreach ($supports as $support) { + $fulfilled = in_array($project->getStatus(), [ + ProjectStatus::FundingPaid + ]); + $collaboration = new Collaboration(); $collaboration->setTitle($support['support']); $collaboration->setDescription($support['description']); + $collaboration->setFulfilled($fulfilled); $collaborations[] = $collaboration; } -- 2.54.0 From e1e1604f318a71bf2fe332bed1029805b43e7828 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 15:32:45 +0200 Subject: [PATCH 67/81] fix: link collaboration to in-pumping project --- src/Benzina/ProjectsPump.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 927b1a3..8726783 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -402,6 +402,7 @@ class ProjectsPump implements PumpInterface $collaboration->setFulfilled($fulfilled); $collaborations[] = $collaboration; + $project->addCollaboration($collaboration); } return $collaborations; -- 2.54.0 From 727ddaee15c98e4e0ecc9d33b2d7d309323fb395 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 15:33:30 +0200 Subject: [PATCH 68/81] fix: add trailing comma to list of fulfilling statuses --- src/Benzina/ProjectsPump.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index 8726783..eddaf30 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -385,7 +385,7 @@ class ProjectsPump implements PumpInterface $collaborations = []; $query = $this->getDbConnection($context)->prepare( - "SELECT * FROM `support` s WHERE s.project = :project" + 'SELECT * FROM `support` s WHERE s.project = :project' ); $query->execute(['project' => $project->getMigratedId()]); @@ -393,7 +393,7 @@ class ProjectsPump implements PumpInterface foreach ($supports as $support) { $fulfilled = in_array($project->getStatus(), [ - ProjectStatus::FundingPaid + ProjectStatus::FundingPaid, ]); $collaboration = new Collaboration(); -- 2.54.0 From 3d0925ca0fe193ca1278f434e2ceaca41e79d813 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 16:33:26 +0200 Subject: [PATCH 69/81] test: update PHPUnit CI to use bin/phpunit instead of action --- .github/workflows/phpunit.yaml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/phpunit.yaml b/.github/workflows/phpunit.yaml index 6468b4b..d01df04 100644 --- a/.github/workflows/phpunit.yaml +++ b/.github/workflows/phpunit.yaml @@ -34,11 +34,14 @@ jobs: php_version: ${{ env.PHP_VERSION }} php_extensions: ${{ env.PHP_EXTENSIONS }} + - name: PHP diagnostics + run: | + php -v + php --ini + php -r 'echo "memory_limit=", ini_get("memory_limit"), PHP_EOL;' + php -r 'echo "PHP_VERSION=", PHP_VERSION, PHP_EOL;' + - name: PHPUnit - uses: php-actions/phpunit@v4 + run: php bin/phpunit -c phpunit.xml.dist env: DATABASE_URL: mysql://root:goteo@127.0.0.1:${{ job.services.mariadb.ports['3306'] }}/goteo - with: - php_version: ${{ env.PHP_VERSION }} - php_extensions: ${{ env.PHP_EXTENSIONS }} - configuration: phpunit.xml.dist -- 2.54.0 From deef8ad1712cdc47e361843acafb472344d3b3f8 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 16:42:48 +0200 Subject: [PATCH 70/81] feat: set memory_limit of PHPUnit run on CI to 128M --- .github/workflows/phpunit.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/phpunit.yaml b/.github/workflows/phpunit.yaml index d01df04..b1f1fcf 100644 --- a/.github/workflows/phpunit.yaml +++ b/.github/workflows/phpunit.yaml @@ -38,10 +38,9 @@ jobs: run: | php -v php --ini - php -r 'echo "memory_limit=", ini_get("memory_limit"), PHP_EOL;' php -r 'echo "PHP_VERSION=", PHP_VERSION, PHP_EOL;' - name: PHPUnit - run: php bin/phpunit -c phpunit.xml.dist + run: php -d memory_limit=128M bin/phpunit -c phpunit.xml.dist env: DATABASE_URL: mysql://root:goteo@127.0.0.1:${{ job.services.mariadb.ports['3306'] }}/goteo -- 2.54.0 From c3b42990977152022885b32ee0f2fee6d140ae0f Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 17:36:27 +0200 Subject: [PATCH 71/81] fix: add user avatar to user pump processing --- src/Benzina/UsersPump.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Benzina/UsersPump.php b/src/Benzina/UsersPump.php index 37491d9..0775a3a 100644 --- a/src/Benzina/UsersPump.php +++ b/src/Benzina/UsersPump.php @@ -89,6 +89,7 @@ class UsersPump implements PumpInterface $user->setTerritory($this->getTerritory($record)); $user->setDescription($record['about']); $user->setRoles($this->getRoles($record, $context)); + $user->setAvatar($this->getAvatar($record)); match ($user->getType()) { UserType::Individual => $user = $this->setUserPerson($record, $user), @@ -231,4 +232,19 @@ class UsersPump implements PumpInterface return $roles; } + + private function getAvatar(array $record): ?string + { + $image = $record['avatar']; + + if ($image === null || $image === '') { + return null; + } + + if (!\str_contains($image, '.')) { + return null; + } + + return \sprintf('https://s3.eu-west-1.amazonaws.com/goteoassets.org/images/%s', $image); + } } -- 2.54.0 From 7732352bb7d8e190431ae668c9fc3349bf917023 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 30 Sep 2026 17:36:42 +0200 Subject: [PATCH 72/81] fix: set invests batch size to 1 to avoid support duplicates on insert --- src/Benzina/InvestsPump.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Benzina/InvestsPump.php b/src/Benzina/InvestsPump.php index 77094a4..f192e67 100644 --- a/src/Benzina/InvestsPump.php +++ b/src/Benzina/InvestsPump.php @@ -71,7 +71,7 @@ class InvestsPump implements PumpInterface private RewardRepository $rewardRepository, private MoneyService $moneyService, ) { - $this->setFlushBatchSize(8); + $this->setFlushBatchSize(1); } public function supports(mixed $sample): bool -- 2.54.0 From 5d878f2c4cb551f3a6062e79b6917748fd830936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Subiabre=20Garc=C3=ADa?= Date: Thu, 1 Oct 2026 16:22:42 +0200 Subject: [PATCH 73/81] Merge pull request #253 from goteo/fix/pumping-bugs fix: Pumping bugs --- migrations/Version20261001120621.php | 34 +++++ src/ApiResource/AddressApiResource.php | 73 +++++++++ .../Project/RewardClaimApiResource.php | 11 +- src/Benzina/InvestRewardsPump.php | 31 ++-- src/Dto/RewardClaimCreationDto.php | 5 +- src/Dto/RewardClaimUpdationDto.php | 6 +- src/Entity/Address.php | 142 ++++++++++++++++++ src/Entity/Project/RewardClaim.php | 14 +- src/Entity/ShippingAddress.php | 88 ----------- src/Entity/User/User.php | 38 +++++ src/Repository/AddressRepository.php | 43 ++++++ 11 files changed, 364 insertions(+), 121 deletions(-) create mode 100644 migrations/Version20261001120621.php create mode 100644 src/ApiResource/AddressApiResource.php create mode 100644 src/Entity/Address.php delete mode 100644 src/Entity/ShippingAddress.php create mode 100644 src/Repository/AddressRepository.php diff --git a/migrations/Version20261001120621.php b/migrations/Version20261001120621.php new file mode 100644 index 0000000..ed0e951 --- /dev/null +++ b/migrations/Version20261001120621.php @@ -0,0 +1,34 @@ +addSql('CREATE TABLE address (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, line1 VARCHAR(255) NOT NULL, line2 VARCHAR(255) DEFAULT NULL, city VARCHAR(255) NOT NULL, post_code VARCHAR(255) NOT NULL, country VARCHAR(2) NOT NULL, INDEX IDX_D4E6F81A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE address ADD CONSTRAINT FK_D4E6F81A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)'); + $this->addSql('ALTER TABLE project_reward_claim ADD address_id INT DEFAULT NULL, DROP shipping_address_first_name, DROP shipping_address_last_name, DROP shipping_address_address_line1, DROP shipping_address_address_line2, DROP shipping_address_city, DROP shipping_address_post_code, DROP shipping_address_country'); + $this->addSql('ALTER TABLE project_reward_claim ADD CONSTRAINT FK_EA126118F5B7AF75 FOREIGN KEY (address_id) REFERENCES address (id)'); + $this->addSql('CREATE INDEX IDX_EA126118F5B7AF75 ON project_reward_claim (address_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_reward_claim DROP FOREIGN KEY FK_EA126118F5B7AF75'); + $this->addSql('ALTER TABLE address DROP FOREIGN KEY FK_D4E6F81A76ED395'); + $this->addSql('DROP TABLE address'); + $this->addSql('DROP INDEX IDX_EA126118F5B7AF75 ON project_reward_claim'); + $this->addSql('ALTER TABLE project_reward_claim ADD shipping_address_first_name VARCHAR(255) DEFAULT NULL, ADD shipping_address_last_name VARCHAR(255) DEFAULT NULL, ADD shipping_address_address_line1 VARCHAR(255) DEFAULT NULL, ADD shipping_address_address_line2 VARCHAR(255) DEFAULT NULL, ADD shipping_address_city VARCHAR(255) DEFAULT NULL, ADD shipping_address_post_code VARCHAR(255) DEFAULT NULL, ADD shipping_address_country VARCHAR(2) DEFAULT NULL, DROP address_id'); + } +} diff --git a/src/ApiResource/AddressApiResource.php b/src/ApiResource/AddressApiResource.php new file mode 100644 index 0000000..1e6619e --- /dev/null +++ b/src/ApiResource/AddressApiResource.php @@ -0,0 +1,73 @@ + 'partial'])] - #[API\ApiFilter(SearchFilter::class, properties: ['shippingAddress.postCode' => 'partial'])] - #[API\ApiFilter(SearchFilter::class, properties: ['shippingAddress.country' => 'partial'])] - public ?ShippingAddress $shippingAddress; + #[API\ApiProperty(readableLink: true)] + #[API\ApiFilter(SearchFilter::class, properties: ['address.city' => 'partial'])] + #[API\ApiFilter(SearchFilter::class, properties: ['address.postCode' => 'partial'])] + #[API\ApiFilter(SearchFilter::class, properties: ['address.country' => 'partial'])] + public ?AddressApiResource $address; } diff --git a/src/Benzina/InvestRewardsPump.php b/src/Benzina/InvestRewardsPump.php index 391f7d4..837f0f6 100644 --- a/src/Benzina/InvestRewardsPump.php +++ b/src/Benzina/InvestRewardsPump.php @@ -2,11 +2,11 @@ namespace App\Benzina; +use App\Entity\Address; use App\Entity\Gateway\Charge; use App\Entity\Project\Reward; use App\Entity\Project\RewardClaim; use App\Entity\Project\RewardClaimStatus; -use App\Entity\ShippingAddress; use App\Entity\User\User; use App\Gateway\ChargeStatus; use App\Repository\Gateway\ChargeRepository; @@ -87,7 +87,9 @@ class InvestRewardsPump implements PumpInterface }; $claim->setStatus($status); - $claim->setShippingAddress($this->getAddress($record, $context)); + + $address = $this->getAddress($claim, $context); + $claim->setAddress($address); $this->persist($claim, $context); } @@ -150,30 +152,31 @@ class InvestRewardsPump implements PumpInterface return $this->userRepository->findPumped($record['user']); } - private function getAddress(array $record, array $context): ?ShippingAddress + private function getAddress(RewardClaim $claim, array $context): ?Address { $query = $this->getDbConnection($context)->prepare( 'SELECT * FROM `invest_address` a WHERE a.invest = :invest' ); - $query->execute(['invest' => $record['invest']]); + $query->execute(['invest' => $claim->getCharge()->getMigratedId()]); $result = $query->fetch(\PDO::FETCH_ASSOC); - if (!$result) { + if (!$result || $result['name'] === null) { return null; } [$firstName, $lastName] = UserService::guessNames($result['name']); - return new ShippingAddress( - $firstName, - $lastName, - $result['address'], - null, - $result['location'], - $result['zipcode'], - $result['country'] - ); + $address = new Address(); + $address->setUser($claim->getOwner()); + $address->setFirstName($firstName); + $address->setLastName($lastName); + $address->setLine1($result['address']); + $address->setCity($result['location']); + $address->setPostCode($result['zipcode']); + $address->setCountry($result['country']); + + return $address; } } diff --git a/src/Dto/RewardClaimCreationDto.php b/src/Dto/RewardClaimCreationDto.php index eff8adc..009e551 100644 --- a/src/Dto/RewardClaimCreationDto.php +++ b/src/Dto/RewardClaimCreationDto.php @@ -2,9 +2,9 @@ namespace App\Dto; +use App\ApiResource\AddressApiResource; use App\ApiResource\Gateway\ChargeApiResource; use App\ApiResource\Project\RewardApiResource; -use App\Entity\ShippingAddress; use App\Validator\AvailableRewardUnits; use App\Validator\EnoughRewardCharge; use App\Validator\SameProjectRewardCharge; @@ -32,6 +32,5 @@ class RewardClaimCreationDto /** * If the reward is a physical object that needs to be delivered to an specific place. */ - #[Assert\Valid()] - public ?ShippingAddress $shippingAddress; + public ?AddressApiResource $address; } diff --git a/src/Dto/RewardClaimUpdationDto.php b/src/Dto/RewardClaimUpdationDto.php index 166e000..4c49a2c 100644 --- a/src/Dto/RewardClaimUpdationDto.php +++ b/src/Dto/RewardClaimUpdationDto.php @@ -3,9 +3,8 @@ namespace App\Dto; use ApiPlatform\Metadata as API; +use App\ApiResource\AddressApiResource; use App\Entity\Project\RewardClaimStatus; -use App\Entity\ShippingAddress; -use Symfony\Component\Validator\Constraints as Assert; class RewardClaimUpdationDto { @@ -19,7 +18,6 @@ class RewardClaimUpdationDto /** * If the reward is a physical object that needs to be delivered to an specific place. */ - #[Assert\Valid()] #[API\ApiProperty(security: 'is_granted("CLAIM_OWNS", object)')] - public ?ShippingAddress $shippingAddress; + public ?AddressApiResource $address; } diff --git a/src/Entity/Address.php b/src/Entity/Address.php new file mode 100644 index 0000000..1a7c800 --- /dev/null +++ b/src/Entity/Address.php @@ -0,0 +1,142 @@ +id; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getLine1(): ?string + { + return $this->line1; + } + + public function setLine1(string $line1): static + { + $this->line1 = $line1; + + return $this; + } + + public function getLine2(): ?string + { + return $this->line2; + } + + public function setLine2(?string $line2): static + { + $this->line2 = $line2; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(string $city): static + { + $this->city = $city; + + return $this; + } + + public function getPostCode(): ?string + { + return $this->postCode; + } + + public function setPostCode(string $postCode): static + { + $this->postCode = $postCode; + + return $this; + } + + public function getCountry(): ?string + { + return $this->country; + } + + public function setCountry(string $country): static + { + $this->country = $country; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } +} diff --git a/src/Entity/Project/RewardClaim.php b/src/Entity/Project/RewardClaim.php index 181e0d0..c98b077 100644 --- a/src/Entity/Project/RewardClaim.php +++ b/src/Entity/Project/RewardClaim.php @@ -2,8 +2,8 @@ namespace App\Entity\Project; +use App\Entity\Address; use App\Entity\Gateway\Charge; -use App\Entity\ShippingAddress; use App\Entity\User\User; use App\Entity\UserOwnedInterface; use App\Entity\UserOwnedTrait; @@ -39,8 +39,8 @@ class RewardClaim implements UserOwnedInterface #[ORM\Column(enumType: RewardClaimStatus::class)] private ?RewardClaimStatus $status = RewardClaimStatus::InPending; - #[ORM\Embedded(class: ShippingAddress::class)] - private ?ShippingAddress $shippingAddress = null; + #[ORM\ManyToOne] + private ?Address $address = null; public function getId(): ?int { @@ -83,14 +83,14 @@ class RewardClaim implements UserOwnedInterface return $this; } - public function getShippingAddress(): ?ShippingAddress + public function getAddress(): ?Address { - return $this->shippingAddress; + return $this->address; } - public function setShippingAddress(?ShippingAddress $shippingAddress): static + public function setAddress(?Address $address): static { - $this->shippingAddress = $shippingAddress; + $this->address = $address; return $this; } diff --git a/src/Entity/ShippingAddress.php b/src/Entity/ShippingAddress.php deleted file mode 100644 index 4d32b04..0000000 --- a/src/Entity/ShippingAddress.php +++ /dev/null @@ -1,88 +0,0 @@ - + */ + #[ORM\OneToMany(targetEntity: Address::class, mappedBy: 'user')] + private Collection $addresses; + public function __construct() { $this->accounting = Accounting::of($this); @@ -133,6 +140,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account $this->emailConfirmed = false; $this->active = false; + $this->addresses = new ArrayCollection(); } public function getId(): ?int @@ -393,4 +401,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account return $this; } + + /** + * @return Collection + */ + public function getAddresses(): Collection + { + return $this->addresses; + } + + public function addAddress(Address $address): static + { + if (!$this->addresses->contains($address)) { + $this->addresses->add($address); + $address->setUser($this); + } + + return $this; + } + + public function removeAddress(Address $address): static + { + if ($this->addresses->removeElement($address)) { + // set the owning side to null (unless already changed) + if ($address->getUser() === $this) { + $address->setUser(null); + } + } + + return $this; + } } diff --git a/src/Repository/AddressRepository.php b/src/Repository/AddressRepository.php new file mode 100644 index 0000000..46ee39e --- /dev/null +++ b/src/Repository/AddressRepository.php @@ -0,0 +1,43 @@ + + */ +class AddressRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Address::class); + } + + // /** + // * @return Address[] Returns an array of Address objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('a.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Address + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} -- 2.54.0 From 0007c6b9b145b407651c451c61f6ee6ec8512909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Subiabre=20Garc=C3=ADa?= Date: Thu, 1 Oct 2026 16:26:01 +0200 Subject: [PATCH 74/81] chore: sync main to core develop (#1) * fix: upgrade goteo/benzina to 0.6 * feat: increase Nominatim cache TTL * fix: allow long addresses for territory data * fix: build project addresses from goteo 3 territory fields * fix: handle empty strings for video sources * fix: update project drafts deadline to 2026 * feat: split project description into 4 fields * feat: bump doctrine related dependencies * feat: bump symfony related dependencies * feat: make categories work on idOrSlug * fix: allow filtering on project descBrief property * fix: update constraints on project update to allow optional updates of properties * fix: update Projects test suite to new project shape * fix: order of DROP operations in migration Version20260813171902 * fix: move accounting balance and checkout to supports listeners to onFlush events * fix: bad order of filter safeguards * chore: code style by php-cs-fixer * fix: prevent uninitialized fields on api resource from breaking AccountingVoter * fix: small bug preventing code improvements on Accounting api resource * feat: add cover property on ProjectReward resource * feat: add cover property to Project resources * feat: use video cover for project cover * feat: add migration for property cover on project and rewards * fix: small improvements on UserOwned helpers * feat: add territory to project creation * feat: mark creation fields as required * feat: add CollaborationCandidacy resource * fix: update old validations access of new types * feat: add validation for charges targetting projects not in campaign * feat: validate new collaboration candicacies are to open collaborations * feat: prevent future object access issues in SameProjectRewardChargeValidator * fix: return void on succesfull delete operations * fix: force set of isFinite property * feat: allow to filter ProjectBudgetItem by type and deadline * feat: remove example values from .env * feat: add testing encryption keys * feat: make project in BudgetItem body required * feat: add testing encryption keys to actions * feat: add collaborations property to ProjectApiResource * feat: Update Users PATCH body (#246) * feat: move UserSignupDto to Dto\User namespace * fix: bad parsing of INSTR by ApiPlatform pagination layer * fix: make User resources be processed by dedicated update processor * fix: specify authentication usage of UserSignupDto::password property * feat: improve non-crawlable URI detection for ScoutService * feat: add "org" to Links with known organizations behind links * feat: map raw uri strings to full Link objects * fix: bad loading of existing user data in UserStateProcessor * feat: add NotExisting validator * feat: use NotExisting constraint on Users handle, email, Projects titles and Tipjar names * fix: check for existing node item in URI rel detection * fix: make app:oauth2-server:consent-client true by default * feat: bump league/oauth2-server-bundle to 1.2.2 * feat: add UserConverter decorator to have league/oauth2-server JWT's sub have User id instead of handle * fix: specify OAuth2 and separate server from client utilities * fix: make app_user_provider use ID instead of handle * feat: add explaining comment to doctrine_encryption.key_id * feat: add status to reward claims * feat: make default RewardClaim entity status in_pending * feat: add shorter check for ownership of projects * feat: allow to modify status of reward claims * feat: add shipping address to reward claims * fix: remove requirement of status in claim update op * fix: update filters and remove obsolete attributes from ProjectRewardClaim resource * feat: update DB layer for project reward claims * fix: code style issues by PHP-CS-Fixer * feat: add DoctrineLoggablePumpTrait to remove Gedmo Loggable event listener * fix: update legacy ref to FileUriException * feat: pump reward claim status and shipping address from v3 records * fix: update pumps batch sizes and injected dependencies * feat: pump user roles * feat: add true image cover retrieval and leave video as fallback for ProjectsPump * feat: pump project collaborations * feat: allow cascading of collaborations * fix: set isFulfilled of collaboration * fix: link collaboration to in-pumping project * fix: add trailing comma to list of fulfilling statuses * test: update PHPUnit CI to use bin/phpunit instead of action * feat: set memory_limit of PHPUnit run on CI to 128M * fix: add user avatar to user pump processing * fix: set invests batch size to 1 to avoid support duplicates on insert * Merge pull request #253 from goteo/fix/pumping-bugs fix: Pumping bugs --- .github/workflows/phpunit.yaml | 12 +- composer.lock | 307 +++++++++--------- config/packages/doctrine_encryption.yaml | 2 + config/packages/security.yaml | 2 +- migrations/Version20260929170504.php | 26 ++ migrations/Version20261001120621.php | 34 ++ public/{oauth => oauth2}/providers/google.png | Bin src/ApiResource/AddressApiResource.php | 73 +++++ .../Project/RewardClaimApiResource.php | 34 +- src/ApiResource/TipjarApiResource.php | 2 + src/ApiResource/User/UserApiResource.php | 10 +- src/Benzina/DoctrineLoggablePumpTrait.php | 34 ++ src/Benzina/InvestRewardsPump.php | 62 ++-- src/Benzina/InvestsPump.php | 9 +- src/Benzina/ProjectsBudgetPump.php | 3 +- src/Benzina/ProjectsPump.php | 93 +++++- src/Benzina/ProjectsRewardsPump.php | 3 +- src/Benzina/UsersPump.php | 55 +++- src/Command/OAuth2ConsentClientCommand.php | 2 +- src/Dto/ProjectCreationDto.php | 3 + src/Dto/RewardClaimCreationDto.php | 6 + src/Dto/RewardClaimUpdationDto.php | 23 ++ src/Dto/{ => User}/UserSignupDto.php | 7 +- src/Dto/User/UserUpdationDto.php | 78 +++++ src/Entity/Address.php | 142 ++++++++ src/Entity/Project/Project.php | 12 +- src/Entity/Project/RewardClaim.php | 31 ++ src/Entity/Project/RewardClaimStatus.php | 16 + src/Entity/User/User.php | 38 +++ src/Filter/OrderedLikeFilter.php | 11 +- src/Library/Link.php | 21 +- src/Library/LinkOrg.php | 39 +++ .../Transformer/RawLinksMapTransformer.php | 47 +++ .../Client}/DecidimProvider.php | 12 +- src/{OAuth => OAuth2/Client}/TokenOwner.php | 2 +- .../Client}/TokenOwnerInterface.php | 2 +- src/OAuth2/Server/UserConverter.php | 30 ++ src/Repository/AddressRepository.php | 43 +++ src/Security/Voter/RewardClaimVoter.php | 64 ++++ src/Security/Voter/UserOwnedVoterTrait.php | 5 + ...ption.php => NonCrawlableUriException.php} | 7 +- src/Service/Scout/ScoutService.php | 28 +- .../Project/RewardClaimStateProcessor.php | 61 ++-- src/State/User/UserStateProcessor.php | 32 +- src/Validator/NotExisting.php | 26 ++ src/Validator/NotExistingValidator.php | 45 +++ tests/Service/Scout/ScoutServiceTest.php | 12 +- 47 files changed, 1324 insertions(+), 282 deletions(-) create mode 100644 migrations/Version20260929170504.php create mode 100644 migrations/Version20261001120621.php rename public/{oauth => oauth2}/providers/google.png (100%) create mode 100644 src/ApiResource/AddressApiResource.php create mode 100644 src/Benzina/DoctrineLoggablePumpTrait.php create mode 100644 src/Dto/RewardClaimUpdationDto.php rename src/Dto/{ => User}/UserSignupDto.php (74%) create mode 100644 src/Dto/User/UserUpdationDto.php create mode 100644 src/Entity/Address.php create mode 100644 src/Entity/Project/RewardClaimStatus.php create mode 100644 src/Library/LinkOrg.php create mode 100644 src/Mapping/Transformer/RawLinksMapTransformer.php rename src/{OAuth => OAuth2/Client}/DecidimProvider.php (88%) rename src/{OAuth => OAuth2/Client}/TokenOwner.php (96%) rename src/{OAuth => OAuth2/Client}/TokenOwnerInterface.php (85%) create mode 100644 src/OAuth2/Server/UserConverter.php create mode 100644 src/Repository/AddressRepository.php create mode 100644 src/Security/Voter/RewardClaimVoter.php rename src/Service/Scout/{FileUriException.php => NonCrawlableUriException.php} (74%) create mode 100644 src/Validator/NotExisting.php create mode 100644 src/Validator/NotExistingValidator.php diff --git a/.github/workflows/phpunit.yaml b/.github/workflows/phpunit.yaml index 6468b4b..b1f1fcf 100644 --- a/.github/workflows/phpunit.yaml +++ b/.github/workflows/phpunit.yaml @@ -34,11 +34,13 @@ jobs: php_version: ${{ env.PHP_VERSION }} php_extensions: ${{ env.PHP_EXTENSIONS }} + - name: PHP diagnostics + run: | + php -v + php --ini + php -r 'echo "PHP_VERSION=", PHP_VERSION, PHP_EOL;' + - name: PHPUnit - uses: php-actions/phpunit@v4 + run: php -d memory_limit=128M bin/phpunit -c phpunit.xml.dist env: DATABASE_URL: mysql://root:goteo@127.0.0.1:${{ job.services.mariadb.ports['3306'] }}/goteo - with: - php_version: ${{ env.PHP_VERSION }} - php_extensions: ${{ env.PHP_EXTENSIONS }} - configuration: phpunit.xml.dist diff --git a/composer.lock b/composer.lock index d7af667..be5a325 100644 --- a/composer.lock +++ b/composer.lock @@ -3916,16 +3916,16 @@ }, { "name": "league/oauth2-server-bundle", - "version": "v1.1.1", + "version": "v1.2.2", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-server-bundle.git", - "reference": "b0094bef19cb0dfa526ea9eec6d8a863b91a3f7d" + "reference": "db8d6a267b3bb76451816751539d50aa56588247" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-server-bundle/zipball/b0094bef19cb0dfa526ea9eec6d8a863b91a3f7d", - "reference": "b0094bef19cb0dfa526ea9eec6d8a863b91a3f7d", + "url": "https://api.github.com/repos/thephpleague/oauth2-server-bundle/zipball/db8d6a267b3bb76451816751539d50aa56588247", + "reference": "db8d6a267b3bb76451816751539d50aa56588247", "shasum": "" }, "require": { @@ -3934,16 +3934,18 @@ "nyholm/psr7": "^1.4", "php": "^8.1", "psr/http-factory": "^1.0", - "symfony/deprecation-contracts": "^3", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/psr-http-message-bridge": "^6.4|^7.0|^8.0", - "symfony/security-bundle": "^6.4|^7.0|^8.0" + "symfony/deprecation-contracts": "^3.0", + "symfony/event-dispatcher": "^6.4|^7.4|^8.0", + "symfony/filesystem": "^6.4|^7.4|^8.0", + "symfony/framework-bundle": "^6.4|^7.4|^8.0", + "symfony/password-hasher": "^6.4|^7.4|^8.0", + "symfony/psr-http-message-bridge": "^6.4|^7.4|^8.0", + "symfony/security-bundle": "^6.4|^7.4|^8.0" }, "conflict": { "doctrine/doctrine-bundle": "<2.8.0", - "doctrine/orm": "<2.14" + "doctrine/orm": "<2.14", + "symfony/console": "<6.1" }, "require-dev": { "doctrine/doctrine-bundle": "^2.8|^3.0", @@ -3953,19 +3955,15 @@ "php-cs-fixer/shim": "^3.38", "phpstan/phpstan": "^2.1", "phpstan/phpstan-symfony": "^2.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/phpunit-bridge": "^7.3" + "rector/rector": "^2.5", + "symfony/browser-kit": "^6.4|^7.4|^8.0", + "symfony/phpunit-bridge": "^8.1" }, "suggest": { "doctrine/doctrine-bundle": "Allow usage of doctrine as persistence layer", "doctrine/orm": "Allow usage of doctrine as persistence layer" }, "type": "symfony-bundle", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, "autoload": { "psr-4": { "League\\Bundle\\OAuth2ServerBundle\\": "src/" @@ -3996,9 +3994,9 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth2-server-bundle/issues", - "source": "https://github.com/thephpleague/oauth2-server-bundle/tree/v1.1.1" + "source": "https://github.com/thephpleague/oauth2-server-bundle/tree/v1.2.2" }, - "time": "2026-02-06T11:56:53+00:00" + "time": "2026-07-26T12:46:32+00:00" }, { "name": "league/uri", @@ -5934,16 +5932,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "8f0b5de5d9f5131aa99fdab78d7802efef9322dc" + "reference": "b001ef092224de1358462ceabfbdf4d4c2d4c9d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/8f0b5de5d9f5131aa99fdab78d7802efef9322dc", - "reference": "8f0b5de5d9f5131aa99fdab78d7802efef9322dc", + "url": "https://api.github.com/repos/symfony/cache/zipball/b001ef092224de1358462ceabfbdf4d4c2d4c9d2", + "reference": "b001ef092224de1358462ceabfbdf4d4c2d4c9d2", "shasum": "" }, "require": { @@ -5966,7 +5964,7 @@ "symfony/cache-implementation": "1.1|2.0|3.0" }, "require-dev": { - "cache/integration-tests": "dev-master", + "cache/integration-tests": "^0.18", "doctrine/dbal": "^2.13.1|^3|^4", "predis/predis": "^1.1|^2.0", "psr/simple-cache": "^1.0|^2.0|^3.0", @@ -6010,7 +6008,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.43" + "source": "https://github.com/symfony/cache/tree/v6.4.46" }, "funding": [ { @@ -6030,7 +6028,7 @@ "type": "tidelift" } ], - "time": "2026-07-28T21:33:22+00:00" + "time": "2026-09-08T06:25:25+00:00" }, { "name": "symfony/cache-contracts", @@ -6192,16 +6190,16 @@ }, { "name": "symfony/config", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "e39cdd91b2adf67a117bea29660b73d896937d86" + "reference": "6a5abf67fd5b138df96984fe6f8fd736fb7d6b29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/e39cdd91b2adf67a117bea29660b73d896937d86", - "reference": "e39cdd91b2adf67a117bea29660b73d896937d86", + "url": "https://api.github.com/repos/symfony/config/zipball/6a5abf67fd5b138df96984fe6f8fd736fb7d6b29", + "reference": "6a5abf67fd5b138df96984fe6f8fd736fb7d6b29", "shasum": "" }, "require": { @@ -6247,7 +6245,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.43" + "source": "https://github.com/symfony/config/tree/v6.4.44" }, "funding": [ { @@ -6267,7 +6265,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T09:46:53+00:00" + "time": "2026-08-20T09:38:57+00:00" }, { "name": "symfony/console", @@ -6369,16 +6367,16 @@ }, { "name": "symfony/dependency-injection", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905" + "reference": "675b2dfaf70bdca37d13816582023e3bac5fc2d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905", - "reference": "f11815fd2f5f80ce94bf527b5ecfd4bd0b1cc905", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/675b2dfaf70bdca37d13816582023e3bac5fc2d1", + "reference": "675b2dfaf70bdca37d13816582023e3bac5fc2d1", "shasum": "" }, "require": { @@ -6430,7 +6428,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.43" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.44" }, "funding": [ { @@ -6450,7 +6448,7 @@ "type": "tidelift" } ], - "time": "2026-07-20T15:13:12+00:00" + "time": "2026-08-21T15:20:39+00:00" }, { "name": "symfony/deprecation-contracts", @@ -6715,16 +6713,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "ecec4c500623ee0ac0c925c3426bcb3e7f429b14" + "reference": "8fa59eb915a14b2881993565a122c9dd1b0f8a19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/ecec4c500623ee0ac0c925c3426bcb3e7f429b14", - "reference": "ecec4c500623ee0ac0c925c3426bcb3e7f429b14", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8fa59eb915a14b2881993565a122c9dd1b0f8a19", + "reference": "8fa59eb915a14b2881993565a122c9dd1b0f8a19", "shasum": "" }, "require": { @@ -6770,7 +6768,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.43" + "source": "https://github.com/symfony/error-handler/tree/v6.4.44" }, "funding": [ { @@ -6790,20 +6788,20 @@ "type": "tidelift" } ], - "time": "2026-07-21T10:11:50+00:00" + "time": "2026-08-20T17:31:15+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.43", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf" + "reference": "d7110a878e7e7cbd8aaebe9f55da8af885b8c0af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ac405d324c10ebbbde6a6e58379bf81db10f1dbf", - "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d7110a878e7e7cbd8aaebe9f55da8af885b8c0af", + "reference": "d7110a878e7e7cbd8aaebe9f55da8af885b8c0af", "shasum": "" }, "require": { @@ -6854,7 +6852,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.43" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.44" }, "funding": [ { @@ -6874,7 +6872,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T14:00:19+00:00" + "time": "2026-08-21T14:01:40+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7026,16 +7024,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16" + "reference": "6dabf41c21957a2060f101bd7bf7e322989866bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16", - "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/6dabf41c21957a2060f101bd7bf7e322989866bd", + "reference": "6dabf41c21957a2060f101bd7bf7e322989866bd", "shasum": "" }, "require": { @@ -7072,7 +7070,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.43" + "source": "https://github.com/symfony/filesystem/tree/v6.4.45" }, "funding": [ { @@ -7092,20 +7090,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T10:13:35+00:00" + "time": "2026-08-23T07:59:31+00:00" }, { "name": "symfony/finder", - "version": "v6.4.42", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "0b73dac42493acbadbba644207a715b254e9b029" + "reference": "04db08efd85558162ef43fba10c299871b60d60b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029", - "reference": "0b73dac42493acbadbba644207a715b254e9b029", + "url": "https://api.github.com/repos/symfony/finder/zipball/04db08efd85558162ef43fba10c299871b60d60b", + "reference": "04db08efd85558162ef43fba10c299871b60d60b", "shasum": "" }, "require": { @@ -7140,7 +7138,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.42" + "source": "https://github.com/symfony/finder/tree/v6.4.46" }, "funding": [ { @@ -7160,7 +7158,7 @@ "type": "tidelift" } ], - "time": "2026-06-26T15:18:24+00:00" + "time": "2026-09-10T14:17:24+00:00" }, { "name": "symfony/flex", @@ -7237,16 +7235,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "dcec940410874561f58edb75e2f3574c8a1340d3" + "reference": "407e77216969a366fd2bbf6d96a84c6b4a6500a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/dcec940410874561f58edb75e2f3574c8a1340d3", - "reference": "dcec940410874561f58edb75e2f3574c8a1340d3", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/407e77216969a366fd2bbf6d96a84c6b4a6500a2", + "reference": "407e77216969a366fd2bbf6d96a84c6b4a6500a2", "shasum": "" }, "require": { @@ -7280,7 +7278,7 @@ "symfony/form": "<5.4", "symfony/http-client": "<6.3", "symfony/lock": "<5.4", - "symfony/mailer": "<5.4", + "symfony/mailer": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5", "symfony/messenger": "<6.3", "symfony/mime": "<6.4.37|>=7.0,<7.4.9", "symfony/property-access": "<5.4", @@ -7366,7 +7364,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.4.43" + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.46" }, "funding": [ { @@ -7386,7 +7384,7 @@ "type": "tidelift" } ], - "time": "2026-07-26T09:00:38+00:00" + "time": "2026-09-13T09:15:59+00:00" }, { "name": "symfony/http-client", @@ -7570,16 +7568,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e" + "reference": "08bbdd5cf09248785442c41929aabe42f8ef5cfd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ea0c801ec34e9017a8c9363e55c8ef5f1717216e", - "reference": "ea0c801ec34e9017a8c9363e55c8ef5f1717216e", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/08bbdd5cf09248785442c41929aabe42f8ef5cfd", + "reference": "08bbdd5cf09248785442c41929aabe42f8ef5cfd", "shasum": "" }, "require": { @@ -7627,7 +7625,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.43" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.46" }, "funding": [ { @@ -7647,20 +7645,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T06:55:26+00:00" + "time": "2026-09-14T17:40:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "cc35556a9bc9e9a6a35fb1114609d1e3c9a63738" + "reference": "c662aa16f2a6b078800ef5302444043c38932213" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cc35556a9bc9e9a6a35fb1114609d1e3c9a63738", - "reference": "cc35556a9bc9e9a6a35fb1114609d1e3c9a63738", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c662aa16f2a6b078800ef5302444043c38932213", + "reference": "c662aa16f2a6b078800ef5302444043c38932213", "shasum": "" }, "require": { @@ -7745,7 +7743,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.43" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.46" }, "funding": [ { @@ -7765,7 +7763,7 @@ "type": "tidelift" } ], - "time": "2026-07-29T11:30:30+00:00" + "time": "2026-09-15T07:06:31+00:00" }, { "name": "symfony/intl", @@ -8359,16 +8357,16 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -8420,7 +8418,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -8440,7 +8438,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -8772,16 +8770,16 @@ }, { "name": "symfony/process", - "version": "v6.4.41", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7" + "reference": "b9f3d9ddf67543b92dbfa6d210c407d7a7982781" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", - "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", + "url": "https://api.github.com/repos/symfony/process/zipball/b9f3d9ddf67543b92dbfa6d210c407d7a7982781", + "reference": "b9f3d9ddf67543b92dbfa6d210c407d7a7982781", "shasum": "" }, "require": { @@ -8813,7 +8811,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.41" + "source": "https://github.com/symfony/process/tree/v6.4.46" }, "funding": [ { @@ -8833,20 +8831,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T13:47:21+00:00" + "time": "2026-09-02T12:35:55+00:00" }, { "name": "symfony/property-access", - "version": "v6.4.32", + "version": "v6.4.44", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "6dfa655ac9e9860c05cabb287f34da86b18c237e" + "reference": "57793e8d1e3ede2d4a65604e8f213a3cfa8d1721" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/6dfa655ac9e9860c05cabb287f34da86b18c237e", - "reference": "6dfa655ac9e9860c05cabb287f34da86b18c237e", + "url": "https://api.github.com/repos/symfony/property-access/zipball/57793e8d1e3ede2d4a65604e8f213a3cfa8d1721", + "reference": "57793e8d1e3ede2d4a65604e8f213a3cfa8d1721", "shasum": "" }, "require": { @@ -8894,7 +8892,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v6.4.32" + "source": "https://github.com/symfony/property-access/tree/v6.4.44" }, "funding": [ { @@ -8914,20 +8912,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T08:25:17+00:00" + "time": "2026-07-30T11:04:25+00:00" }, { "name": "symfony/property-info", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "ff32a0d7885f6f8997ee0aca499fd008852ad5bd" + "reference": "4789c9a2650f9285da6db0363ef40e5a21b42ce9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/ff32a0d7885f6f8997ee0aca499fd008852ad5bd", - "reference": "ff32a0d7885f6f8997ee0aca499fd008852ad5bd", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4789c9a2650f9285da6db0363ef40e5a21b42ce9", + "reference": "4789c9a2650f9285da6db0363ef40e5a21b42ce9", "shasum": "" }, "require": { @@ -8984,7 +8982,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v6.4.43" + "source": "https://github.com/symfony/property-info/tree/v6.4.46" }, "funding": [ { @@ -9004,7 +9002,7 @@ "type": "tidelift" } ], - "time": "2026-07-22T12:35:29+00:00" + "time": "2026-09-03T19:31:46+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -9095,16 +9093,16 @@ }, { "name": "symfony/routing", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "f8dedd42e7a511906aa54be6cebffbbe826ff20e" + "reference": "6fe2ed5b356beec41d5720dd73db72887c3f9d6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/f8dedd42e7a511906aa54be6cebffbbe826ff20e", - "reference": "f8dedd42e7a511906aa54be6cebffbbe826ff20e", + "url": "https://api.github.com/repos/symfony/routing/zipball/6fe2ed5b356beec41d5720dd73db72887c3f9d6d", + "reference": "6fe2ed5b356beec41d5720dd73db72887c3f9d6d", "shasum": "" }, "require": { @@ -9158,7 +9156,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.43" + "source": "https://github.com/symfony/routing/tree/v6.4.45" }, "funding": [ { @@ -9178,7 +9176,7 @@ "type": "tidelift" } ], - "time": "2026-07-21T13:51:56+00:00" + "time": "2026-08-16T17:07:07+00:00" }, { "name": "symfony/runtime", @@ -9265,16 +9263,16 @@ }, { "name": "symfony/security-bundle", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "c77d9d6076e6bb8ba706b8616519e3f52f6b2660" + "reference": "de06cd0f756ca8fe4894c7e95bac71d90bb94e64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c77d9d6076e6bb8ba706b8616519e3f52f6b2660", - "reference": "c77d9d6076e6bb8ba706b8616519e3f52f6b2660", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/de06cd0f756ca8fe4894c7e95bac71d90bb94e64", + "reference": "de06cd0f756ca8fe4894c7e95bac71d90bb94e64", "shasum": "" }, "require": { @@ -9357,7 +9355,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v6.4.43" + "source": "https://github.com/symfony/security-bundle/tree/v6.4.45" }, "funding": [ { @@ -9377,20 +9375,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T06:43:01+00:00" + "time": "2026-08-30T06:17:28+00:00" }, { "name": "symfony/security-core", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "9d2741948b0eb1fc08fd5431909dac8316ba49c2" + "reference": "8f74c37460002b97c1318007629c8be37681498a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/9d2741948b0eb1fc08fd5431909dac8316ba49c2", - "reference": "9d2741948b0eb1fc08fd5431909dac8316ba49c2", + "url": "https://api.github.com/repos/symfony/security-core/zipball/8f74c37460002b97c1318007629c8be37681498a", + "reference": "8f74c37460002b97c1318007629c8be37681498a", "shasum": "" }, "require": { @@ -9417,6 +9415,7 @@ "symfony/expression-language": "^5.4|^6.0|^7.0", "symfony/http-foundation": "^5.4|^6.0|^7.0", "symfony/ldap": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4|^6.0|^7.0", "symfony/string": "^5.4|^6.0|^7.0", "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", "symfony/validator": "^6.4|^7.0" @@ -9447,7 +9446,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v6.4.43" + "source": "https://github.com/symfony/security-core/tree/v6.4.45" }, "funding": [ { @@ -9467,7 +9466,7 @@ "type": "tidelift" } ], - "time": "2026-07-13T05:00:30+00:00" + "time": "2026-08-30T00:30:12+00:00" }, { "name": "symfony/security-csrf", @@ -9543,16 +9542,16 @@ }, { "name": "symfony/security-http", - "version": "v6.4.42", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "c6a3836e6c331503a841f439912939c4e1be480f" + "reference": "a8ad51e48f20b515e589b5927ee7cde7e1e37453" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/c6a3836e6c331503a841f439912939c4e1be480f", - "reference": "c6a3836e6c331503a841f439912939c4e1be480f", + "url": "https://api.github.com/repos/symfony/security-http/zipball/a8ad51e48f20b515e589b5927ee7cde7e1e37453", + "reference": "a8ad51e48f20b515e589b5927ee7cde7e1e37453", "shasum": "" }, "require": { @@ -9611,7 +9610,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v6.4.42" + "source": "https://github.com/symfony/security-http/tree/v6.4.46" }, "funding": [ { @@ -9631,7 +9630,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T12:40:39+00:00" + "time": "2026-09-15T05:55:29+00:00" }, { "name": "symfony/serializer", @@ -9737,16 +9736,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -9800,7 +9799,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -9820,7 +9819,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/stimulus-bundle", @@ -9963,16 +9962,16 @@ }, { "name": "symfony/string", - "version": "v6.4.43", + "version": "v6.4.46", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49" + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", - "reference": "2a8d515c3eaa5d33cf76d5fa277cdadd0a4e5b49", + "url": "https://api.github.com/repos/symfony/string/zipball/4513ab2b0ad2d7845a2f920e2f30b61633c9e606", + "reference": "4513ab2b0ad2d7845a2f920e2f30b61633c9e606", "shasum": "" }, "require": { @@ -10028,7 +10027,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.43" + "source": "https://github.com/symfony/string/tree/v6.4.46" }, "funding": [ { @@ -10048,7 +10047,7 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:28:15+00:00" + "time": "2026-09-11T09:03:57+00:00" }, { "name": "symfony/translation", @@ -10696,16 +10695,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.43", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "6638cfe00907b2b980b749fcf2cd4cd6b2b1396e" + "reference": "86a9b2f1bd81c9780a809632238ab6ba10292166" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6638cfe00907b2b980b749fcf2cd4cd6b2b1396e", - "reference": "6638cfe00907b2b980b749fcf2cd4cd6b2b1396e", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/86a9b2f1bd81c9780a809632238ab6ba10292166", + "reference": "86a9b2f1bd81c9780a809632238ab6ba10292166", "shasum": "" }, "require": { @@ -10760,7 +10759,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.43" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.45" }, "funding": [ { @@ -10780,20 +10779,20 @@ "type": "tidelift" } ], - "time": "2026-07-06T08:03:42+00:00" + "time": "2026-08-30T20:10:39+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.4.42", + "version": "v6.4.45", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "15e670e0218bfe4efbc0556b8a12ad1304f4c7f4" + "reference": "a4e458999ee554a667cd19227f0b97a751bd61e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/15e670e0218bfe4efbc0556b8a12ad1304f4c7f4", - "reference": "15e670e0218bfe4efbc0556b8a12ad1304f4c7f4", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/a4e458999ee554a667cd19227f0b97a751bd61e6", + "reference": "a4e458999ee554a667cd19227f0b97a751bd61e6", "shasum": "" }, "require": { @@ -10841,7 +10840,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.42" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.45" }, "funding": [ { @@ -10861,7 +10860,7 @@ "type": "tidelift" } ], - "time": "2026-06-27T08:12:01+00:00" + "time": "2026-08-23T09:39:49+00:00" }, { "name": "symfony/web-link", diff --git a/config/packages/doctrine_encryption.yaml b/config/packages/doctrine_encryption.yaml index 32b1129..f02918c 100644 --- a/config/packages/doctrine_encryption.yaml +++ b/config/packages/doctrine_encryption.yaml @@ -2,4 +2,6 @@ doctrine_encryption: encrypt_key: "%env(DOCTRINE_ENCRYPTION_ENCRYPT_KEY)%" blind_index_key: "%env(DOCTRINE_ENCRYPTION_BLIND_INDEX_KEY)%" + # Necessary for key rotation + # https://github.com/Kyzegs/doctrine-encryption-bundle#key-rotation key_id: "2026" diff --git a/config/packages/security.yaml b/config/packages/security.yaml index f82acb4..41e3f9c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -13,7 +13,7 @@ security: app_user_provider: entity: class: App\Entity\User\User - property: handle + property: id login_user_provider: id: App\Security\LoginUserProvider firewalls: diff --git a/migrations/Version20260929170504.php b/migrations/Version20260929170504.php new file mode 100644 index 0000000..c013652 --- /dev/null +++ b/migrations/Version20260929170504.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE project_reward_claim ADD status VARCHAR(255) NOT NULL, ADD shipping_address_first_name VARCHAR(255) NOT NULL, ADD shipping_address_last_name VARCHAR(255) NOT NULL, ADD shipping_address_address_line1 VARCHAR(255) NOT NULL, ADD shipping_address_address_line2 VARCHAR(255) DEFAULT NULL, ADD shipping_address_city VARCHAR(255) NOT NULL, ADD shipping_address_post_code VARCHAR(255) NOT NULL, ADD shipping_address_country VARCHAR(2) NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_reward_claim DROP status, DROP shipping_address_first_name, DROP shipping_address_last_name, DROP shipping_address_address_line1, DROP shipping_address_address_line2, DROP shipping_address_city, DROP shipping_address_post_code, DROP shipping_address_country'); + } +} diff --git a/migrations/Version20261001120621.php b/migrations/Version20261001120621.php new file mode 100644 index 0000000..ed0e951 --- /dev/null +++ b/migrations/Version20261001120621.php @@ -0,0 +1,34 @@ +addSql('CREATE TABLE address (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, line1 VARCHAR(255) NOT NULL, line2 VARCHAR(255) DEFAULT NULL, city VARCHAR(255) NOT NULL, post_code VARCHAR(255) NOT NULL, country VARCHAR(2) NOT NULL, INDEX IDX_D4E6F81A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE address ADD CONSTRAINT FK_D4E6F81A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)'); + $this->addSql('ALTER TABLE project_reward_claim ADD address_id INT DEFAULT NULL, DROP shipping_address_first_name, DROP shipping_address_last_name, DROP shipping_address_address_line1, DROP shipping_address_address_line2, DROP shipping_address_city, DROP shipping_address_post_code, DROP shipping_address_country'); + $this->addSql('ALTER TABLE project_reward_claim ADD CONSTRAINT FK_EA126118F5B7AF75 FOREIGN KEY (address_id) REFERENCES address (id)'); + $this->addSql('CREATE INDEX IDX_EA126118F5B7AF75 ON project_reward_claim (address_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_reward_claim DROP FOREIGN KEY FK_EA126118F5B7AF75'); + $this->addSql('ALTER TABLE address DROP FOREIGN KEY FK_D4E6F81A76ED395'); + $this->addSql('DROP TABLE address'); + $this->addSql('DROP INDEX IDX_EA126118F5B7AF75 ON project_reward_claim'); + $this->addSql('ALTER TABLE project_reward_claim ADD shipping_address_first_name VARCHAR(255) DEFAULT NULL, ADD shipping_address_last_name VARCHAR(255) DEFAULT NULL, ADD shipping_address_address_line1 VARCHAR(255) DEFAULT NULL, ADD shipping_address_address_line2 VARCHAR(255) DEFAULT NULL, ADD shipping_address_city VARCHAR(255) DEFAULT NULL, ADD shipping_address_post_code VARCHAR(255) DEFAULT NULL, ADD shipping_address_country VARCHAR(2) DEFAULT NULL, DROP address_id'); + } +} diff --git a/public/oauth/providers/google.png b/public/oauth2/providers/google.png similarity index 100% rename from public/oauth/providers/google.png rename to public/oauth2/providers/google.png diff --git a/src/ApiResource/AddressApiResource.php b/src/ApiResource/AddressApiResource.php new file mode 100644 index 0000000..1e6619e --- /dev/null +++ b/src/ApiResource/AddressApiResource.php @@ -0,0 +1,73 @@ + 'partial'])] + #[API\ApiFilter(SearchFilter::class, properties: ['address.postCode' => 'partial'])] + #[API\ApiFilter(SearchFilter::class, properties: ['address.country' => 'partial'])] + public ?AddressApiResource $address; } diff --git a/src/ApiResource/TipjarApiResource.php b/src/ApiResource/TipjarApiResource.php index 2f769a9..6b254fd 100644 --- a/src/ApiResource/TipjarApiResource.php +++ b/src/ApiResource/TipjarApiResource.php @@ -9,6 +9,7 @@ use App\ApiResource\Accounting\AccountingApiResource; use App\Entity\Tipjar; use App\State\ApiResourceStateProcessor; use App\State\ApiResourceStateProvider; +use App\Validator\NotExisting; use Symfony\Component\Validator\Constraints as Assert; /** @@ -40,6 +41,7 @@ class TipjarApiResource * Human readable, non white space, unique string. */ #[Assert\NotBlank()] + #[NotExisting(Tipjar::class, 'name')] #[API\ApiFilter(SearchFilter::class, strategy: 'partial')] public string $name; } diff --git a/src/ApiResource/User/UserApiResource.php b/src/ApiResource/User/UserApiResource.php index 656e3b5..fa2cbcd 100644 --- a/src/ApiResource/User/UserApiResource.php +++ b/src/ApiResource/User/UserApiResource.php @@ -10,7 +10,8 @@ use ApiPlatform\Metadata as API; use App\ApiResource\Accounting\AccountingApiResource; use App\ApiResource\TimestampedCreationApiResource; use App\ApiResource\TimestampedUpdationApiResource; -use App\Dto\UserSignupDto; +use App\Dto\User\UserSignupDto; +use App\Dto\User\UserUpdationDto; use App\Entity\Territory; use App\Entity\User\User; use App\Entity\User\UserType; @@ -48,8 +49,11 @@ use Symfony\Component\Validator\Constraints as Assert; ), ] )] -#[API\Patch(securityPostDenormalize: 'is_granted("USER_EDIT", previous_object)')] -#[API\Delete(securityPostDenormalize: 'is_granted("USER_EDIT", previous_object)')] +#[API\Patch( + input: UserUpdationDto::class, + securityPostDenormalize: 'is_granted("USER_EDIT", previous_object)' +)] +#[API\Delete(securityPostDenormalize: 'is_granted("ROLE_ADMIN", previous_object)')] #[API\ApiFilter(QFilter::class, properties: [ 'q' => [ 'email', diff --git a/src/Benzina/DoctrineLoggablePumpTrait.php b/src/Benzina/DoctrineLoggablePumpTrait.php new file mode 100644 index 0000000..b351a38 --- /dev/null +++ b/src/Benzina/DoctrineLoggablePumpTrait.php @@ -0,0 +1,34 @@ +getConnection()->getConfiguration()->getMiddlewares(); + $middlewares = \array_filter($middlewares, fn($m) => !$m instanceof LoggingMiddleware); + + $entityManager->getConnection()->getConfiguration()->setMiddlewares($middlewares); + + $evm = $entityManager->getEventManager(); + foreach ($evm->getAllListeners() as $event => $listeners) { + foreach ($listeners as $listener) { + if ($listener instanceof LoggableListener) { + $evm->removeEventListener([$event], $listener); + } + } + } + + $this->setEntityManager($entityManager); + } +} diff --git a/src/Benzina/InvestRewardsPump.php b/src/Benzina/InvestRewardsPump.php index 83acb14..837f0f6 100644 --- a/src/Benzina/InvestRewardsPump.php +++ b/src/Benzina/InvestRewardsPump.php @@ -2,25 +2,24 @@ namespace App\Benzina; +use App\Entity\Address; use App\Entity\Gateway\Charge; use App\Entity\Project\Reward; use App\Entity\Project\RewardClaim; +use App\Entity\Project\RewardClaimStatus; use App\Entity\User\User; use App\Gateway\ChargeStatus; -use App\Money\MoneyService; use App\Repository\Gateway\ChargeRepository; use App\Repository\Project\RewardRepository; -use App\Repository\User\UserRepository; -use Doctrine\Common\Collections\Criteria; +use App\Service\UserService; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; class InvestRewardsPump implements PumpInterface { use ArrayPumpTrait; use DatabasePumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use InvestsPumpTrait; /** @var array */ @@ -34,10 +33,11 @@ class InvestRewardsPump implements PumpInterface public function __construct( private RewardRepository $rewardRepository, - private UserRepository $userRepository, + private PumpedUserRepository $userRepository, private ChargeRepository $chargeRepository, - private MoneyService $moneyService, - ) {} + ) { + $this->setFlushBatchSize(8); + } public function supports(mixed $sample): bool { @@ -81,6 +81,16 @@ class InvestRewardsPump implements PumpInterface $reward->addClaim($claim); $claim->setReward($reward); + $status = match ($record['fulfilled']) { + 0 => RewardClaimStatus::InPending, + 1 => RewardClaimStatus::Fulfilled, + }; + + $claim->setStatus($status); + + $address = $this->getAddress($claim, $context); + $claim->setAddress($address); + $this->persist($claim, $context); } @@ -139,22 +149,34 @@ class InvestRewardsPump implements PumpInterface private function getUser(array $record): ?User { - $id = $record['user']; + return $this->userRepository->findPumped($record['user']); + } - if (isset($this->userCache[$id])) { - return $this->userRepository->find($this->userCache[$id]); + private function getAddress(RewardClaim $claim, array $context): ?Address + { + $query = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `invest_address` a WHERE a.invest = :invest' + ); + + $query->execute(['invest' => $claim->getCharge()->getMigratedId()]); + + $result = $query->fetch(\PDO::FETCH_ASSOC); + + if (!$result || $result['name'] === null) { + return null; } - $criteria = new Criteria(); - $criteria - ->orWhere($criteria->expr()->eq('migratedId', $id)) - ->orWhere($criteria->expr()->contains('dedupedIds', $id)) - ->setMaxResults(1); + [$firstName, $lastName] = UserService::guessNames($result['name']); - $user = $this->userRepository->matching($criteria)->first(); + $address = new Address(); + $address->setUser($claim->getOwner()); + $address->setFirstName($firstName); + $address->setLastName($lastName); + $address->setLine1($result['address']); + $address->setCity($result['location']); + $address->setPostCode($result['zipcode']); + $address->setCountry($result['country']); - $this->userCache[$id] = $user->getId(); - - return $user; + return $address; } } diff --git a/src/Benzina/InvestsPump.php b/src/Benzina/InvestsPump.php index f3e01fd..f192e67 100644 --- a/src/Benzina/InvestsPump.php +++ b/src/Benzina/InvestsPump.php @@ -27,16 +27,14 @@ use App\Repository\Project\ProjectRepository; use App\Repository\Project\RewardRepository; use App\Repository\Project\SupportRepository; use App\Repository\TipjarRepository; -use App\Service\Gateway\CheckoutService; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; class InvestsPump implements PumpInterface { use ArrayPumpTrait; use DatabasePumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use InvestsPumpTrait; public const TRACKING_TITLE_V3 = 'v3 Invest ID'; @@ -71,9 +69,10 @@ class InvestsPump implements PumpInterface private SupportRepository $supportRepository, private TipjarRepository $tipjarRepository, private RewardRepository $rewardRepository, - private CheckoutService $checkoutService, private MoneyService $moneyService, - ) {} + ) { + $this->setFlushBatchSize(1); + } public function supports(mixed $sample): bool { diff --git a/src/Benzina/ProjectsBudgetPump.php b/src/Benzina/ProjectsBudgetPump.php index d461430..b0657cb 100644 --- a/src/Benzina/ProjectsBudgetPump.php +++ b/src/Benzina/ProjectsBudgetPump.php @@ -9,14 +9,13 @@ use App\Entity\Project\Project; use App\Entity\Project\ProjectDeadline; use App\Repository\Project\ProjectRepository; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; class ProjectsBudgetPump implements PumpInterface { use ArrayPumpTrait; use DatabasePumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use LocalizedPumpTrait; private const MAX_INT = 2147483647; diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index bd7665e..eddaf30 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -2,6 +2,7 @@ namespace App\Benzina; +use App\Entity\Project\Collaboration; use App\Entity\Project\Project; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectDeadline; @@ -11,20 +12,17 @@ use App\Entity\Project\Update; use App\Entity\Territory; use App\Entity\User\User; use App\Repository\Project\ProjectRepository; -use App\Repository\User\UserRepository; use App\Service\Project\TerritoryService; -use App\Service\Scout\FileUriException; +use App\Service\Scout\NonCrawlableUriException; use App\Service\Scout\ScoutService; use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Criteria; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; class ProjectsPump implements PumpInterface { use ArrayPumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use DatabasePumpTrait; use ProjectsPumpTrait; use LocalizedPumpTrait; @@ -32,7 +30,7 @@ class ProjectsPump implements PumpInterface public function __construct( private ProjectRepository $projectRepository, - private UserRepository $userRepository, + private PumpedUserRepository $userRepository, private TerritoryService $territoryService, private ScoutService $scoutService, ) {} @@ -82,11 +80,18 @@ class ProjectsPump implements PumpInterface $project->setDateUpdated(new \DateTime()); $project->setTranslatableLocale($record['lang']); $project->setUpdates(new ArrayCollection($this->getProjectUpdates($project, $context))); + $project->setCollaborations(new ArrayCollection($this->getProjectCollaborations($project, $context))); $video = $this->getProjectVideo($record); + if ($video !== null) { + $project->setVideo($video); + $project->setCover($video->cover); + } - $project->setVideo($video); - $project->setCover($video->cover); + $cover = $this->getProjectCover($record, $context); + if ($cover !== null) { + $project->setCover($cover); + } $conf = $this->getProjectConf($project, $context); @@ -124,13 +129,7 @@ class ProjectsPump implements PumpInterface private function getProjectOwner(array $record): ?User { - $criteria = new Criteria(); - $criteria - ->orWhere($criteria->expr()->eq('migratedId', $record['owner'])) - ->orWhere($criteria->expr()->contains('dedupedIds', $record['owner'])) - ->setMaxResults(1); - - return $this->userRepository->matching($criteria)->first() ?? null; + return $this->userRepository->findPumped($record['owner']); } private function getProjectLocalizations(Project $project, array $context): array @@ -240,7 +239,7 @@ class ProjectsPump implements PumpInterface } return new ProjectVideo($info->url, $info->cover ?? $info->image, $info->image); - } catch (FileUriException $e) { + } catch (NonCrawlableUriException $e) { return new ProjectVideo($e->getUri()); } catch (\Exception $e) { return null; @@ -346,4 +345,66 @@ class ProjectsPump implements PumpInterface return ProjectDeadline::Minimum; } + + private function getImageSource(array $record, array $context): ?string + { + $query = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `project_image` WHERE `project` = :project ORDER BY `order` ASC' + ); + + $query->execute(['project' => $record['id']]); + + $images = $query->fetchAll(); + + foreach ($images as $image) { + if ($image['section'] === 'play-video') { + return $image['image']; + } + } + + return $record['image']; + } + + private function getProjectCover(array $record, array $context): ?string + { + $image = $this->getImageSource($record, $context); + + if ($image === null || $image === '') { + return null; + } + + if (!\str_contains($image, '.')) { + return null; + } + + return \sprintf('https://s3.eu-west-1.amazonaws.com/goteoassets.org/images/%s', $image); + } + + private function getProjectCollaborations(Project $project, array $context): array + { + $collaborations = []; + + $query = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `support` s WHERE s.project = :project' + ); + + $query->execute(['project' => $project->getMigratedId()]); + $supports = $query->fetchAll(); + + foreach ($supports as $support) { + $fulfilled = in_array($project->getStatus(), [ + ProjectStatus::FundingPaid, + ]); + + $collaboration = new Collaboration(); + $collaboration->setTitle($support['support']); + $collaboration->setDescription($support['description']); + $collaboration->setFulfilled($fulfilled); + + $collaborations[] = $collaboration; + $project->addCollaboration($collaboration); + } + + return $collaborations; + } } diff --git a/src/Benzina/ProjectsRewardsPump.php b/src/Benzina/ProjectsRewardsPump.php index 81b0a70..1d68a4c 100644 --- a/src/Benzina/ProjectsRewardsPump.php +++ b/src/Benzina/ProjectsRewardsPump.php @@ -8,14 +8,13 @@ use App\Entity\Project\Project; use App\Entity\Project\Reward; use App\Repository\Project\ProjectRepository; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; class ProjectsRewardsPump implements PumpInterface { use ArrayPumpTrait; use DatabasePumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use LocalizedPumpTrait; private const REWARD_KEYS = [ diff --git a/src/Benzina/UsersPump.php b/src/Benzina/UsersPump.php index 0aa03c3..0775a3a 100644 --- a/src/Benzina/UsersPump.php +++ b/src/Benzina/UsersPump.php @@ -12,7 +12,6 @@ use App\Service\Project\TerritoryService; use App\Service\UserService; use Doctrine\Persistence\ManagerRegistry; use Goteo\Benzina\Pump\ArrayPumpTrait; -use Goteo\Benzina\Pump\DoctrinePumpTrait; use Goteo\Benzina\Pump\PumpInterface; use Symfony\Component\Validator\Constraints\Url; use Symfony\Component\Validator\Validation; @@ -20,14 +19,17 @@ use Symfony\Component\Validator\Validation; class UsersPump implements PumpInterface { use ArrayPumpTrait; - use DoctrinePumpTrait; + use DoctrineLoggablePumpTrait; use UsersPumpTrait; use TerritoryPumpTrait; + use DatabasePumpTrait; public function __construct( private ManagerRegistry $managerRegistry, private TerritoryService $territoryService, - ) {} + ) { + $this->setFlushBatchSize(8); + } public function supports(mixed $sample): bool { @@ -41,7 +43,7 @@ class UsersPump implements PumpInterface public function pump(mixed $record, array $context): void { $user = new User(); - $user = $this->processUser($user, $record); + $user = $this->processUser($user, $record, $context); try { $this->persist($user, $context); @@ -62,7 +64,7 @@ class UsersPump implements PumpInterface } $user = new User(); - $user = $this->processUser($user, $record); + $user = $this->processUser($user, $record, $context); $user->setHandle(UserService::asHandle($record['id'], 16, 255)); $this->persist($user, $context); @@ -71,7 +73,7 @@ class UsersPump implements PumpInterface } } - private function processUser(User $user, array $record): User + private function processUser(User $user, array $record, array $context): User { $user->setHandle($this->buildHandle($record)); $user->setPassword($record['password'] ?? ''); @@ -86,6 +88,8 @@ class UsersPump implements PumpInterface $user->setLinks($this->getLinks($record)); $user->setTerritory($this->getTerritory($record)); $user->setDescription($record['about']); + $user->setRoles($this->getRoles($record, $context)); + $user->setAvatar($this->getAvatar($record)); match ($user->getType()) { UserType::Individual => $user = $this->setUserPerson($record, $user), @@ -204,4 +208,43 @@ class UsersPump implements PumpInterface return $this->territoryService->search($cleanAddress); } + + private function getRoles(array $record, array $context): array + { + $query = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `user_role` r WHERE r.user_id = :user' + ); + + $query->execute(['user' => $record['id']]); + + $results = $query->fetchAll(\PDO::FETCH_ASSOC); + + if (!$results || empty($results)) { + return []; + } + + $roles = []; + foreach ($results as $result) { + if (in_array($result['role_id'], ['superadmin', 'manager'])) { + $roles[] = 'ROLE_ADMIN'; + } + } + + return $roles; + } + + private function getAvatar(array $record): ?string + { + $image = $record['avatar']; + + if ($image === null || $image === '') { + return null; + } + + if (!\str_contains($image, '.')) { + return null; + } + + return \sprintf('https://s3.eu-west-1.amazonaws.com/goteoassets.org/images/%s', $image); + } } diff --git a/src/Command/OAuth2ConsentClientCommand.php b/src/Command/OAuth2ConsentClientCommand.php index 6434e73..a58ad8c 100644 --- a/src/Command/OAuth2ConsentClientCommand.php +++ b/src/Command/OAuth2ConsentClientCommand.php @@ -31,7 +31,7 @@ class OAuth2ConsentClientCommand extends Command $this ->setDescription('Consents an OAuth2 client, allowing it to skip the consent screen') - ->addOption('consent', null, InputOption::VALUE_NEGATABLE, 'Consent the client') + ->addOption('consent', null, InputOption::VALUE_NEGATABLE, 'Consent the client', true) ->addArgument('identifier', InputArgument::REQUIRED, 'The client identifier') ; diff --git a/src/Dto/ProjectCreationDto.php b/src/Dto/ProjectCreationDto.php index 1965143..50cf7c5 100644 --- a/src/Dto/ProjectCreationDto.php +++ b/src/Dto/ProjectCreationDto.php @@ -4,9 +4,11 @@ namespace App\Dto; use ApiPlatform\Metadata as API; use App\ApiResource\CategoryApiResource; +use App\Entity\Project\Project; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectStatus; use App\Entity\Territory; +use App\Validator\NotExisting; use Symfony\Component\Validator\Constraints as Assert; class ProjectCreationDto @@ -19,6 +21,7 @@ class ProjectCreationDto #[Assert\NotBlank()] #[Assert\Regex('/[a-zA-Z]{1,}/')] #[Assert\Length(min: 3)] + #[NotExisting(Project::class, 'title')] public string $title; /** diff --git a/src/Dto/RewardClaimCreationDto.php b/src/Dto/RewardClaimCreationDto.php index b6f5251..009e551 100644 --- a/src/Dto/RewardClaimCreationDto.php +++ b/src/Dto/RewardClaimCreationDto.php @@ -2,6 +2,7 @@ namespace App\Dto; +use App\ApiResource\AddressApiResource; use App\ApiResource\Gateway\ChargeApiResource; use App\ApiResource\Project\RewardApiResource; use App\Validator\AvailableRewardUnits; @@ -27,4 +28,9 @@ class RewardClaimCreationDto #[Assert\NotBlank()] #[AvailableRewardUnits()] public RewardApiResource $reward; + + /** + * If the reward is a physical object that needs to be delivered to an specific place. + */ + public ?AddressApiResource $address; } diff --git a/src/Dto/RewardClaimUpdationDto.php b/src/Dto/RewardClaimUpdationDto.php new file mode 100644 index 0000000..4c49a2c --- /dev/null +++ b/src/Dto/RewardClaimUpdationDto.php @@ -0,0 +1,23 @@ + + */ + #[API\ApiProperty(securityPostDenormalize: 'is_granted("ROLE_ADMIN")')] + public array $roles; + + /** + * A list of absolute URLs.\ + * e.g: social profiles, personal website. + * + * @var array + */ + #[MapTo(User::class, transformer: RawLinksMapTransformer::class)] + #[MapTo(UserApiResource::class, transformer: RawLinksMapTransformer::class)] + public array $links = []; + + /** + * ISO 3166 data about the Users's location territory. + */ + #[Assert\Valid()] + public Territory $territory; + + /** + * Free-form rich text description for the User. + */ + public string $description; +} diff --git a/src/Entity/Address.php b/src/Entity/Address.php new file mode 100644 index 0000000..1a7c800 --- /dev/null +++ b/src/Entity/Address.php @@ -0,0 +1,142 @@ +id; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getLine1(): ?string + { + return $this->line1; + } + + public function setLine1(string $line1): static + { + $this->line1 = $line1; + + return $this; + } + + public function getLine2(): ?string + { + return $this->line2; + } + + public function setLine2(?string $line2): static + { + $this->line2 = $line2; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(string $city): static + { + $this->city = $city; + + return $this; + } + + public function getPostCode(): ?string + { + return $this->postCode; + } + + public function setPostCode(string $postCode): static + { + $this->postCode = $postCode; + + return $this; + } + + public function getCountry(): ?string + { + return $this->country; + } + + public function setCountry(string $country): static + { + $this->country = $country; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } +} diff --git a/src/Entity/Project/Project.php b/src/Entity/Project/Project.php index 4281ef6..9b0e923 100644 --- a/src/Entity/Project/Project.php +++ b/src/Entity/Project/Project.php @@ -157,7 +157,7 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized /** * @var Collection */ - #[ORM\OneToMany(targetEntity: Collaboration::class, mappedBy: 'project')] + #[ORM\OneToMany(targetEntity: Collaboration::class, mappedBy: 'project', cascade: ['persist'])] private Collection $collaborations; /** @@ -561,6 +561,16 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this->collaborations; } + /** + * @param Collection $collaborations + */ + public function setCollaborations(Collection $collaborations): static + { + $this->collaborations = $collaborations; + + return $this; + } + public function addCollaboration(Collaboration $collaboration): static { if (!$this->collaborations->contains($collaboration)) { diff --git a/src/Entity/Project/RewardClaim.php b/src/Entity/Project/RewardClaim.php index 1e973d5..c98b077 100644 --- a/src/Entity/Project/RewardClaim.php +++ b/src/Entity/Project/RewardClaim.php @@ -2,6 +2,7 @@ namespace App\Entity\Project; +use App\Entity\Address; use App\Entity\Gateway\Charge; use App\Entity\User\User; use App\Entity\UserOwnedInterface; @@ -35,6 +36,12 @@ class RewardClaim implements UserOwnedInterface #[ORM\JoinColumn(nullable: false)] private ?Charge $charge = null; + #[ORM\Column(enumType: RewardClaimStatus::class)] + private ?RewardClaimStatus $status = RewardClaimStatus::InPending; + + #[ORM\ManyToOne] + private ?Address $address = null; + public function getId(): ?int { return $this->id; @@ -63,4 +70,28 @@ class RewardClaim implements UserOwnedInterface return $this; } + + public function getStatus(): ?RewardClaimStatus + { + return $this->status; + } + + public function setStatus(RewardClaimStatus $status): static + { + $this->status = $status; + + return $this; + } + + public function getAddress(): ?Address + { + return $this->address; + } + + public function setAddress(?Address $address): static + { + $this->address = $address; + + return $this; + } } diff --git a/src/Entity/Project/RewardClaimStatus.php b/src/Entity/Project/RewardClaimStatus.php new file mode 100644 index 0000000..4340485 --- /dev/null +++ b/src/Entity/Project/RewardClaimStatus.php @@ -0,0 +1,16 @@ + + */ + #[ORM\OneToMany(targetEntity: Address::class, mappedBy: 'user')] + private Collection $addresses; + public function __construct() { $this->accounting = Accounting::of($this); @@ -133,6 +140,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account $this->emailConfirmed = false; $this->active = false; + $this->addresses = new ArrayCollection(); } public function getId(): ?int @@ -393,4 +401,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account return $this; } + + /** + * @return Collection + */ + public function getAddresses(): Collection + { + return $this->addresses; + } + + public function addAddress(Address $address): static + { + if (!$this->addresses->contains($address)) { + $this->addresses->add($address); + $address->setUser($this); + } + + return $this; + } + + public function removeAddress(Address $address): static + { + if ($this->addresses->removeElement($address)) { + // set the owning side to null (unless already changed) + if ($address->getUser() === $this) { + $address->setUser(null); + } + } + + return $this; + } } diff --git a/src/Filter/OrderedLikeFilter.php b/src/Filter/OrderedLikeFilter.php index 50b81d6..a8f5887 100644 --- a/src/Filter/OrderedLikeFilter.php +++ b/src/Filter/OrderedLikeFilter.php @@ -33,6 +33,7 @@ final class OrderedLikeFilter extends AbstractFilter ): void { if ( !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass) ) { return; } @@ -40,10 +41,18 @@ final class OrderedLikeFilter extends AbstractFilter $rootAlias = $queryBuilder->getRootAliases()[0]; $likeParameter = $queryNameGenerator->generateParameterName($property); $instrParameter = $queryNameGenerator->generateParameterName(sprintf('%s%s', $property, 'instr')); + $positionAlias = $queryNameGenerator->generateJoinAlias('position'); $queryBuilder ->andWhere(sprintf('%s.%s LIKE :%s', $rootAlias, $property, $likeParameter)) - ->addOrderBy(sprintf('INSTR(%s.%s, :%s)', $rootAlias, $property, $instrParameter), 'ASC') + ->addSelect(sprintf( + 'INSTR(%s.%s, :%s) AS HIDDEN %s', + $rootAlias, + $property, + $instrParameter, + $positionAlias + )) + ->addOrderBy($positionAlias, 'ASC') ->addOrderBy(sprintf('%s.%s', $rootAlias, $property), 'ASC') ->setParameter($likeParameter, sprintf('%%%s%%', $value)) ->setParameter($instrParameter, $value) diff --git a/src/Library/Link.php b/src/Library/Link.php index ed56969..a1801fa 100644 --- a/src/Library/Link.php +++ b/src/Library/Link.php @@ -16,15 +16,30 @@ class Link */ public ?string $rel; - public static function tryFrom($value): self + /** + * The known organization behind this link. + */ + public function getOrg(): LinkOrg + { + return LinkOrg::fromUrl($this->url); + } + + public static function tryFrom(mixed $value): self { if ($value instanceof self) { return $value; } + if (\is_array($value)) { + $link = new self(); + $link->url = $value['url']; + $link->rel = $value['rel']; + + return $link; + } + $link = new self(); - $link->url = $value['url']; - $link->rel = $value['rel']; + $link->url = $value; return $link; } diff --git a/src/Library/LinkOrg.php b/src/Library/LinkOrg.php new file mode 100644 index 0000000..72d243b --- /dev/null +++ b/src/Library/LinkOrg.php @@ -0,0 +1,39 @@ + self::Instagram, + 'facebook.com' => self::Facebook, + 'fb.com' => self::Facebook, + 'github.com' => self::GitHub, + 'linkedin.com' => self::LinkedIn, + 'identi.ca' => self::Identica, + ]; + } + + public static function fromUrl(string $url): self + { + $host = \parse_url($url, PHP_URL_HOST) ?? ''; + $host = \join('.', \array_slice(\explode('.', $host), -2)); + + foreach (self::domains() as $domain => $network) { + if ($host === $domain || \str_ends_with($host, '.'.$domain)) { + return $network; + } + } + + return self::Unknown; + } +} diff --git a/src/Mapping/Transformer/RawLinksMapTransformer.php b/src/Mapping/Transformer/RawLinksMapTransformer.php new file mode 100644 index 0000000..8d8ae20 --- /dev/null +++ b/src/Mapping/Transformer/RawLinksMapTransformer.php @@ -0,0 +1,47 @@ +scoutService->get($rawLink); + + $link = new Link(); + $link->url = $info->getUri(); + $link->rel = $this->getRel($info); + + $links[] = $link; + } + + return $links; + } + + private function getRel(ScoutResult $info): ?string + { + $nodes = (new \DOMXPath($info->getDocument()->getDocument()))->query('//a[@rel]'); + + if ($nodes->length === 0) { + return null; + } + + $anchor = $nodes->item(0); + if (!$anchor instanceof \DOMElement) { + return null; + } + + return $anchor->getAttribute('rel'); + } +} diff --git a/src/OAuth/DecidimProvider.php b/src/OAuth2/Client/DecidimProvider.php similarity index 88% rename from src/OAuth/DecidimProvider.php rename to src/OAuth2/Client/DecidimProvider.php index f3f701b..a36c5c8 100644 --- a/src/OAuth/DecidimProvider.php +++ b/src/OAuth2/Client/DecidimProvider.php @@ -1,6 +1,6 @@ : * type: generic - * provider_class: App\OAuth\DecidimProvider + * provider_class: App\OAuth2\Client\DecidimProvider * provider_options: * url: - * client_id: "%env()%" - * client_secret: "%env()%" + * client_id: "%env()%" + * client_secret: "%env()%" * redirect_route: oauth_providers_callback * redirect_params: * provider: decidim_ diff --git a/src/OAuth/TokenOwner.php b/src/OAuth2/Client/TokenOwner.php similarity index 96% rename from src/OAuth/TokenOwner.php rename to src/OAuth2/Client/TokenOwner.php index 98a0d2e..1242c76 100644 --- a/src/OAuth/TokenOwner.php +++ b/src/OAuth2/Client/TokenOwner.php @@ -1,6 +1,6 @@ setIdentifier((string) $user->getId()); + + return $userEntity; + } +} diff --git a/src/Repository/AddressRepository.php b/src/Repository/AddressRepository.php new file mode 100644 index 0000000..46ee39e --- /dev/null +++ b/src/Repository/AddressRepository.php @@ -0,0 +1,43 @@ + + */ +class AddressRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Address::class); + } + + // /** + // * @return Address[] Returns an array of Address objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('a.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Address + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Security/Voter/RewardClaimVoter.php b/src/Security/Voter/RewardClaimVoter.php new file mode 100644 index 0000000..fb56a85 --- /dev/null +++ b/src/Security/Voter/RewardClaimVoter.php @@ -0,0 +1,64 @@ +voteEdit($subject, $token->getUser()); + case self::OWNS: + return $this->voteOwns($subject, $token->getUser()); + } + + return false; + } + + private function voteEdit(RewardClaimApiResource $claim, ?UserInterface $user): bool + { + if (!$user instanceof User) { + return false; + } + + if ($user->hasRoles(['ROLE_ADMIN'])) { + return true; + } + + return $this->isOwnerOf($claim->reward->project, $user); + } + + private function voteOwns(RewardClaimApiResource $claim, ?UserInterface $user): bool + { + if (!$user instanceof User) { + return false; + } + + if ($user->hasRoles(['ROLE_ADMIN'])) { + return true; + } + + return $this->isOwnerOf($claim->reward, $user); + } +} diff --git a/src/Security/Voter/UserOwnedVoterTrait.php b/src/Security/Voter/UserOwnedVoterTrait.php index 1bdd014..3e664a2 100644 --- a/src/Security/Voter/UserOwnedVoterTrait.php +++ b/src/Security/Voter/UserOwnedVoterTrait.php @@ -3,6 +3,7 @@ namespace App\Security\Voter; use App\ApiResource\Accounting\AccountingApiResource; +use App\ApiResource\Project\ProjectApiResource; use App\ApiResource\User\UserApiResource; use App\Entity\User\User; use App\Entity\UserOwnedInterface; @@ -31,6 +32,10 @@ trait UserOwnedVoterTrait return $subject->id === $user->getAccounting()->getId(); } + if ($subject instanceof ProjectApiResource) { + return $subject->owner->id === $user->getId(); + } + if ($subject instanceof UserOwnedInterface) { return $subject->isOwnedBy($user); } diff --git a/src/Service/Scout/FileUriException.php b/src/Service/Scout/NonCrawlableUriException.php similarity index 74% rename from src/Service/Scout/FileUriException.php rename to src/Service/Scout/NonCrawlableUriException.php index 6e701a0..11ab582 100644 --- a/src/Service/Scout/FileUriException.php +++ b/src/Service/Scout/NonCrawlableUriException.php @@ -4,13 +4,13 @@ namespace App\Service\Scout; use Psr\Http\Message\UriInterface; -class FileUriException extends \Exception +class NonCrawlableUriException extends \Exception { private UriInterface $uri; public function __construct( UriInterface $uri, - string $message = 'Cannot scout URLs to files (%s)', + string $message = 'Cannot scout %s: it is not a Web page', int $code = 0, ?\Throwable $previous = null, ) { @@ -19,9 +19,6 @@ class FileUriException extends \Exception $this->uri = $uri; } - /** - * The URI to the file. - */ public function getUri(): UriInterface { return $this->uri; diff --git a/src/Service/Scout/ScoutService.php b/src/Service/Scout/ScoutService.php index 7173d8d..3c7110f 100644 --- a/src/Service/Scout/ScoutService.php +++ b/src/Service/Scout/ScoutService.php @@ -5,6 +5,7 @@ namespace App\Service\Scout; use Embed\Embed; use Embed\Http\Crawler; use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\ResponseInterface; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\Url; use Symfony\Component\Validator\Validation; @@ -39,8 +40,8 @@ class ScoutService /** * @param string $url A URL to an external resource * - * @throws InvalidUriException When the given $url string could not be validated as an actual URL - * @throws FileUriException If the given $url string points to a file + * @throws InvalidUriException When the given $url string could not be validated as an actual URL + * @throws NonCrawlableUriException If the given $url string points to a file */ public function get(string $url): ScoutResult { @@ -54,9 +55,12 @@ class ScoutService } $uri = $this->embed->getCrawler()->createUri($url); + $response = $this->httpClient->sendRequest( + new \GuzzleHttp\Psr7\Request('HEAD', $url) + ); - if (\pathinfo($uri->getPath(), \PATHINFO_EXTENSION)) { - throw new FileUriException($uri); + if (!$this->isCrawlable($response)) { + throw new NonCrawlableUriException($uri); } $info = $this->embed->get($url); @@ -82,4 +86,20 @@ class ScoutService return $result; } + + private function isCrawlable(ResponseInterface $response): bool + { + $contentType = $response->getHeaderLine('Content-Type'); + + if ($contentType === '') { + return true; + } + + $contentType = \strtolower(\trim(\explode(';', $contentType)[0])); + + return \in_array($contentType, [ + 'text/html', + 'application/xhtml+xml', + ], true); + } } diff --git a/src/State/Project/RewardClaimStateProcessor.php b/src/State/Project/RewardClaimStateProcessor.php index a66ea95..a906cbd 100644 --- a/src/State/Project/RewardClaimStateProcessor.php +++ b/src/State/Project/RewardClaimStateProcessor.php @@ -7,44 +7,36 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\ApiResource\Project\RewardClaimApiResource; use App\Dto\RewardClaimCreationDto; +use App\Dto\RewardClaimUpdationDto; use App\Entity\Project\RewardClaim; use App\Mapping\AutoMapper; +use App\Repository\Project\RewardClaimRepository; +use App\Security\Voter\AccountingVoter; use App\State\EntityStateProcessor; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Security\Core\Exception\AuthenticationException; class RewardClaimStateProcessor implements ProcessorInterface { public function __construct( - private EntityStateProcessor $entityStateProcessor, private AutoMapper $autoMapper, + private RewardClaimRepository $rewardClaimRepository, private Security $security, + private EntityStateProcessor $entityStateProcessor, ) {} /** - * @param RewardClaimApiResource|RewardClaimCreationDto $data + * @param RewardClaimCreationDto|RewardClaimUpdationDto $data * * @return RewardClaimApiResource|null */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { - /** @var RewardClaim */ - $claim = $this->autoMapper->map($data, RewardClaim::class); - - if ($data instanceof RewardClaimCreationDto) { - $owner = $this->security->getUser(); - - if (!$owner) { - throw new AuthenticationException(); - } - - $claim->setOwner($owner); - - $reward = $claim->getReward(); - $reward->addClaim($claim); - - $claim->setReward($reward); - } + $claim = match ($data::class) { + RewardClaimUpdationDto::class => $this->getClaimFromUpdate($context), + RewardClaimCreationDto::class => $this->getClaimFromCreate($data), + }; $claim = $this->entityStateProcessor->process($claim, $operation, $uriVariables, $context); @@ -52,6 +44,37 @@ class RewardClaimStateProcessor implements ProcessorInterface return; } + if ($claim === null) { + return null; + } + return $this->autoMapper->map($claim, $data); } + + private function getClaimFromUpdate(array $context): RewardClaim + { + $claim = $this->rewardClaimRepository->find($context['previous_data']->id); + if (!$claim) { + throw new NotFoundHttpException(); + } + + return $claim; + } + + private function getClaimFromCreate(RewardClaimCreationDto $data): RewardClaim + { + /** @var RewardClaim */ + $claim = $this->autoMapper->map($data, RewardClaim::class); + + $origin = $claim->getCharge()->getCheckout()->getOrigin(); + if (!$this->security->isGranted(AccountingVoter::EDIT, $origin)) { + throw new AuthenticationException(); + } + + $owner = $origin->getUser(); + + $claim->setOwner($owner); + + return $claim; + } } diff --git a/src/State/User/UserStateProcessor.php b/src/State/User/UserStateProcessor.php index a420b35..9c9deca 100644 --- a/src/State/User/UserStateProcessor.php +++ b/src/State/User/UserStateProcessor.php @@ -2,44 +2,58 @@ namespace App\State\User; -use ApiPlatform\Doctrine\Common\State\PersistProcessor; use ApiPlatform\Metadata as API; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\State\ProcessorInterface; +use App\ApiResource\User\UserApiResource; +use App\Dto\User\UserUpdationDto; use App\Entity\User\Organization; use App\Entity\User\User; use App\Entity\User\UserType; use App\Mapping\AutoMapper; -use Symfony\Component\DependencyInjection\Attribute\Autowire; +use App\Repository\User\UserRepository; +use App\State\EntityStateProcessor; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; class UserStateProcessor implements ProcessorInterface { public function __construct( private AutoMapper $autoMapper, - #[Autowire(service: PersistProcessor::class)] - private ProcessorInterface $persistProcessor, + private UserRepository $userRepository, + private UserPasswordHasherInterface $userPasswordHasher, + private EntityStateProcessor $entityProcessor, ) {} /** - * @param UserApiResource $data + * @param UserUpdationDto $data * - * @return UserApiResource + * @return UserUpdationDto */ public function process(mixed $data, API\Operation $operation, array $uriVariables = [], array $context = []) { + $user = $this->userRepository->find($context['previous_data']->id); + if (!$user) { + throw new NotFoundHttpException(); + } + /** @var User */ - $user = $this->autoMapper->map($data, User::class); + $user = $this->autoMapper->map($data, $user); + + if (isset($data->password)) { + $user->setPassword($this->userPasswordHasher->hashPassword($user, $data->password)); + } if ($user->isType(UserType::Organization) && $user->getOrganization() === null) { $user->setOrganization(Organization::for($user)); } - $user = $this->persistProcessor->process($user, $operation, $uriVariables, $context); + $user = $this->entityProcessor->process($user, $operation, $uriVariables, $context); if ($operation instanceof DeleteOperationInterface) { return; } - return $this->autoMapper->map($user, $data); + return $this->autoMapper->map($user, UserApiResource::class); } } diff --git a/src/Validator/NotExisting.php b/src/Validator/NotExisting.php new file mode 100644 index 0000000..1b53b9c --- /dev/null +++ b/src/Validator/NotExisting.php @@ -0,0 +1,26 @@ +entity; + $repository = $this->entityManager->getRepository($entityClass); + + if ( + $value === null + || $value === '' + || $repository->findOneBy([$constraint->property => $value]) === null + ) { + return; + } + + $this->context->buildViolation($constraint->message) + ->setParameter('{{ property }}', $constraint->property) + ->setParameter('{{ value }}', $value) + ->setParameter('{{ entity }}', $this->getClassShortName($entityClass)) + ->addViolation() + ; + } + + private function getClassShortName(string $class): string + { + $reflect = new \ReflectionClass($class); + + return $reflect->getShortName(); + } +} diff --git a/tests/Service/Scout/ScoutServiceTest.php b/tests/Service/Scout/ScoutServiceTest.php index 256f0db..496ea3f 100644 --- a/tests/Service/Scout/ScoutServiceTest.php +++ b/tests/Service/Scout/ScoutServiceTest.php @@ -2,8 +2,8 @@ namespace App\Tests\Service\Scout; -use App\Service\Scout\FileUriException; use App\Service\Scout\InvalidUriException; +use App\Service\Scout\NonCrawlableUriException; use App\Service\Scout\ScoutService; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -38,17 +38,17 @@ class ScoutServiceTest extends KernelTestCase } /** - * @dataProvider providePathsToBinaryFiles + * @dataProvider provideNonCrawlablePaths */ - public function testThrowsExceptionOnPathsToBinaryFiles(string $url) + public function testThrowsExceptionOnNonCrawlablePaths(string $url) { - $this->expectException(FileUriException::class); - $this->expectExceptionMessageMatches(\sprintf('/\(%s\)$/', \preg_quote($url, '/'))); + $this->expectException(NonCrawlableUriException::class); + $this->expectExceptionMessageMatches(\sprintf('/%s/', \preg_quote($url, '/'))); $this->scout->get($url); } - public function providePathsToBinaryFiles(): array + public function provideNonCrawlablePaths(): array { return [ ['https://sialmaraz.es/wp-content/uploads/2025/01/MANIFESTACION-ALMARAZ-18_1.mp4'], -- 2.54.0 From 8053e8f800d3a4454ddbff1113d2cf40ed8dd2eb Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 2 Oct 2026 03:25:04 +0200 Subject: [PATCH 75/81] feat: update pumping to DTFs --- src/Benzina/InvestsPump.php | 6 +++++- src/Benzina/ProjectsBudgetPump.php | 4 ++++ src/Benzina/ProjectsPump.php | 25 ++++++++++++++++++++++++- src/Benzina/ProjectsRewardsPump.php | 4 ++++ src/Benzina/UsersPump.php | 11 ++++++----- 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/Benzina/InvestsPump.php b/src/Benzina/InvestsPump.php index f192e67..30630f6 100644 --- a/src/Benzina/InvestsPump.php +++ b/src/Benzina/InvestsPump.php @@ -85,6 +85,10 @@ class InvestsPump implements PumpInterface public function pump(mixed $record, array $context): void { + if (!in_array($record['project'], ProjectsPump::SELECTED_PROJECTS)) { + return; + } + if (!$record['user'] || empty($record['user'])) { return; } @@ -260,7 +264,7 @@ class InvestsPump implements PumpInterface $originId = $origin->getId(); $projectId = $target->getId(); - $cacheKey = $projectId.'-'.$originId; + $cacheKey = $projectId . '-' . $originId; if (isset($this->supportCache[$cacheKey])) { return $this->supportRepository->find($this->supportCache[$cacheKey]); diff --git a/src/Benzina/ProjectsBudgetPump.php b/src/Benzina/ProjectsBudgetPump.php index b0657cb..0f23808 100644 --- a/src/Benzina/ProjectsBudgetPump.php +++ b/src/Benzina/ProjectsBudgetPump.php @@ -48,6 +48,10 @@ class ProjectsBudgetPump implements PumpInterface public function pump(mixed $record, array $context): void { + if (!in_array($record['project'], ProjectsPump::SELECTED_PROJECTS)) { + return; + } + if (empty($record['cost']) || empty($record['amount'])) { return; } diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index eddaf30..b5d0f81 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -28,6 +28,29 @@ class ProjectsPump implements PumpInterface use LocalizedPumpTrait; use TerritoryPumpTrait; + public const SELECTED_PROJECTS = [ + 'la-nuestra-despega', + 'la-nuestra', + 'un-netflix-para-el-99-decenas-de-creadores-y-cinea', + 'iridia-vs-pegasus', + 'impulsa-la-cooperativa-de-informacion-climatica', + 'amb-memoria-drets-humans', + 'ara-drets-humans', + 'amb-memoria-drets-humans', + 'quien-cuida-a-las-que-cuidan', + 'con-vosotras-si-campana-de-financiacion-duque-de-a', + 'top-manta-bcn', + 'salvem-el-delta-del-llobregat', + 'refugio-una-huida-para-sobrevivir', + 'plokte', + 'lanzamos-sonda-internacional-y-los-cinco-elementos', + 'la-kacharreria', + 'portal-de-ressenyes-sobre-pisos', + 'indicat-de-llogaters-i-llogateres/', + 'jornaleras', + 'las-kellys' + ]; + public function __construct( private ProjectRepository $projectRepository, private PumpedUserRepository $userRepository, @@ -46,7 +69,7 @@ class ProjectsPump implements PumpInterface public function pump(mixed $record, array $context): void { - if (empty($record['name'])) { + if (empty($record['name']) || !in_array($record['id'], self::SELECTED_PROJECTS)) { return; } diff --git a/src/Benzina/ProjectsRewardsPump.php b/src/Benzina/ProjectsRewardsPump.php index 1d68a4c..03d48df 100644 --- a/src/Benzina/ProjectsRewardsPump.php +++ b/src/Benzina/ProjectsRewardsPump.php @@ -52,6 +52,10 @@ class ProjectsRewardsPump implements PumpInterface public function pump(mixed $record, array $context): void { + if (!in_array($record['project'], ProjectsPump::SELECTED_PROJECTS)) { + return; + } + if (empty($record['reward']) || empty($record['amount'])) { return; } diff --git a/src/Benzina/UsersPump.php b/src/Benzina/UsersPump.php index 0775a3a..0ffb03c 100644 --- a/src/Benzina/UsersPump.php +++ b/src/Benzina/UsersPump.php @@ -75,9 +75,10 @@ class UsersPump implements PumpInterface private function processUser(User $user, array $record, array $context): User { - $user->setHandle($this->buildHandle($record)); + $handle = hash('sha256', $record['email']); + $user->setHandle($handle); $user->setPassword($record['password'] ?? ''); - $user->setEmail($record['email']); + $user->setEmail(\sprintf('%s@goteo.org', $handle)); $user->setEmailConfirmed(false); $user->setActive(false); $user->setMigrated(true); @@ -85,11 +86,11 @@ class UsersPump implements PumpInterface $user->setDateCreated($this->getDateCreated($record)); $user->setDateUpdated(new \DateTime()); $user->setType($this->getUserType($record)); - $user->setLinks($this->getLinks($record)); + // $user->setLinks($this->getLinks($record)); $user->setTerritory($this->getTerritory($record)); - $user->setDescription($record['about']); + // $user->setDescription($record['about']); $user->setRoles($this->getRoles($record, $context)); - $user->setAvatar($this->getAvatar($record)); + // $user->setAvatar($this->getAvatar($record)); match ($user->getType()) { UserType::Individual => $user = $this->setUserPerson($record, $user), -- 2.54.0 From e1b7fec3ba4b1bc6dad12d6e142119b2787c22d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Subiabre=20Garc=C3=ADa?= Date: Fri, 2 Oct 2026 03:26:21 +0200 Subject: [PATCH 76/81] chore: sync main to open-core develop (#2) * fix: upgrade goteo/benzina to 0.6 * feat: increase Nominatim cache TTL * fix: allow long addresses for territory data * fix: build project addresses from goteo 3 territory fields * fix: handle empty strings for video sources * fix: update project drafts deadline to 2026 * feat: split project description into 4 fields * feat: bump doctrine related dependencies * feat: bump symfony related dependencies * feat: make categories work on idOrSlug * fix: allow filtering on project descBrief property * fix: update constraints on project update to allow optional updates of properties * fix: update Projects test suite to new project shape * fix: order of DROP operations in migration Version20260813171902 * fix: move accounting balance and checkout to supports listeners to onFlush events * fix: bad order of filter safeguards * chore: code style by php-cs-fixer * fix: prevent uninitialized fields on api resource from breaking AccountingVoter * fix: small bug preventing code improvements on Accounting api resource * feat: add cover property on ProjectReward resource * feat: add cover property to Project resources * feat: use video cover for project cover * feat: add migration for property cover on project and rewards * fix: small improvements on UserOwned helpers * feat: add territory to project creation * feat: mark creation fields as required * feat: add CollaborationCandidacy resource * fix: update old validations access of new types * feat: add validation for charges targetting projects not in campaign * feat: validate new collaboration candicacies are to open collaborations * feat: prevent future object access issues in SameProjectRewardChargeValidator * fix: return void on succesfull delete operations * fix: force set of isFinite property * feat: allow to filter ProjectBudgetItem by type and deadline * feat: remove example values from .env * feat: add testing encryption keys * feat: make project in BudgetItem body required * feat: add testing encryption keys to actions * feat: add collaborations property to ProjectApiResource * feat: Update Users PATCH body (#246) * feat: move UserSignupDto to Dto\User namespace * fix: bad parsing of INSTR by ApiPlatform pagination layer * fix: make User resources be processed by dedicated update processor * fix: specify authentication usage of UserSignupDto::password property * feat: improve non-crawlable URI detection for ScoutService * feat: add "org" to Links with known organizations behind links * feat: map raw uri strings to full Link objects * fix: bad loading of existing user data in UserStateProcessor * feat: add NotExisting validator * feat: use NotExisting constraint on Users handle, email, Projects titles and Tipjar names * fix: check for existing node item in URI rel detection * fix: make app:oauth2-server:consent-client true by default * feat: bump league/oauth2-server-bundle to 1.2.2 * feat: add UserConverter decorator to have league/oauth2-server JWT's sub have User id instead of handle * fix: specify OAuth2 and separate server from client utilities * fix: make app_user_provider use ID instead of handle * feat: add explaining comment to doctrine_encryption.key_id * feat: add status to reward claims * feat: make default RewardClaim entity status in_pending * feat: add shorter check for ownership of projects * feat: allow to modify status of reward claims * feat: add shipping address to reward claims * fix: remove requirement of status in claim update op * fix: update filters and remove obsolete attributes from ProjectRewardClaim resource * feat: update DB layer for project reward claims * fix: code style issues by PHP-CS-Fixer * feat: add DoctrineLoggablePumpTrait to remove Gedmo Loggable event listener * fix: update legacy ref to FileUriException * feat: pump reward claim status and shipping address from v3 records * fix: update pumps batch sizes and injected dependencies * feat: pump user roles * feat: add true image cover retrieval and leave video as fallback for ProjectsPump * feat: pump project collaborations * feat: allow cascading of collaborations * fix: set isFulfilled of collaboration * fix: link collaboration to in-pumping project * fix: add trailing comma to list of fulfilling statuses * test: update PHPUnit CI to use bin/phpunit instead of action * feat: set memory_limit of PHPUnit run on CI to 128M * fix: add user avatar to user pump processing * fix: set invests batch size to 1 to avoid support duplicates on insert * Merge pull request #253 from goteo/fix/pumping-bugs fix: Pumping bugs -- 2.54.0 From 2dec0d1db5efd58850fba30d9c6beca2b2e217c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Subiabre=20Garc=C3=ADa?= Date: Fri, 2 Oct 2026 16:03:20 +0200 Subject: [PATCH 77/81] fix: HTTP (!s) scheme in callback for OAuth2 providers (#254) * fix: bad path to oauth2 provider logo * feat: set https scheme on router on prod context * feat: configure oauth2 jwts ttl via env var * fix: bad nesting of access_token_ttl yaml option --- .env | 1 + config/packages/league_oauth2_server.yaml | 1 + config/services.yaml | 6 ++++++ templates/security/login.html.twig | 2 +- 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.env b/.env index c1ef931..9897b70 100644 --- a/.env +++ b/.env @@ -69,6 +69,7 @@ OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.pem OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem OAUTH_PASSPHRASE=df7ec1f514c44825926e3dabaf34e3f5 OAUTH_ENCRYPTION_KEY=325b6ce98c009bb83e885657ec08d142 +OAUTH_ACCESS_TOKEN_TTL=PT1H ###< league/oauth2-server-bundle ### ###> knpuniversity/oauth2-client-bundle ### diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml index cfdec5f..fbc177c 100644 --- a/config/packages/league_oauth2_server.yaml +++ b/config/packages/league_oauth2_server.yaml @@ -2,6 +2,7 @@ league_oauth2_server: client: classname: App\Entity\OAuth2Client authorization_server: + access_token_ttl: '%env(OAUTH_ACCESS_TOKEN_TTL)%' private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%' private_key_passphrase: '%env(resolve:OAUTH_PASSPHRASE)%' encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%' diff --git a/config/services.yaml b/config/services.yaml index fbe65ed..bacb5c7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -4,6 +4,12 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + env(OAUTH_ACCESS_TOKEN_TTL): PT1H + +when@prod: + parameters: + router.request_context.scheme: 'https' + asset.request_context.secure: true services: # default configuration for services in *this* file diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index d3d49cd..79dabc0 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -47,7 +47,7 @@
  • -- 2.54.0 From 86ba2c4fe4cd9f86217148cf9e5f5a75ea7e1da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Subiabre=20Garc=C3=ADa?= Date: Mon, 5 Oct 2026 15:43:20 +0200 Subject: [PATCH 78/81] fix: HTTPS in OAuth2 redirect_uri (#255) * feat: remove router schema force via config * fix: configure trusted proxies via env vars * fix: typo in var description --- .env | 3 +++ config/packages/framework.yaml | 2 ++ config/services.yaml | 5 ----- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env b/.env index 9897b70..4b7704a 100644 --- a/.env +++ b/.env @@ -18,6 +18,9 @@ APP_ENV=dev APP_SECRET=b8ec44a08e5f4ba917879713a4a8e63c APP_LOCALE='es' +# DO NOT LEAVE OPEN UNLESS YOU'VE GUARANTEED THAT TRAFFIC WILL ONLY COME FROM TRUSTED ORIGINS +# https://symfony.com/doc/6.4/deployment/proxies.html +APP_TRUSTED_PROXIES='127.0.0.1,REMOTE_ADDR' ###< symfony/framework-bundle ### ###> doctrine/doctrine-bundle ### diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 387064a..8fd40c8 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -28,6 +28,8 @@ framework: headers: 'User-Agent': 'Platoniq Goteo-v4-API' + trusted_proxies: '%env(APP_TRUSTED_PROXIES)%' + when@test: framework: test: true diff --git a/config/services.yaml b/config/services.yaml index bacb5c7..4680e63 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -6,11 +6,6 @@ parameters: env(OAUTH_ACCESS_TOKEN_TTL): PT1H -when@prod: - parameters: - router.request_context.scheme: 'https' - asset.request_context.secure: true - services: # default configuration for services in *this* file _defaults: -- 2.54.0 From 14ee28be4fa4deeff50113c26fca32513fb7a4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Subiabre=20Garc=C3=ADa?= Date: Wed, 7 Oct 2026 13:31:44 +0200 Subject: [PATCH 79/81] [feat] Project reviews (#177) * feat: start project reviews entity * feat: add review areas entity * feat: add review comments entity * fix: code styles improvements by php-cs-fixer * feat: expose project review resources in the API * feat: add timestampt to review resources * feat: add SQL migration * fix: update ProjectReview API docs and operations * feat: refactor ProjectsListener to dedicated project status listener * fix: make default review area risk high * feat: add ReviewService for creating default project reviews * fix: renamed timestampt entity traits * fix: update doctrine migration * feat: test for release date to be set greater than today's start * feat: add test for project campaign review * fix: update person and organization patch validations * fix: test if review entity is truly persisted * fix: bad start statuses in project tests * fix: ProjectStatusListener not persisting side effect data * fix: centralize project status change side-effects processing in ProjectService * fix: small review persistence tweaks * feat: allow only concerned users to see and edit review areas * feat: apply validations and security for concerned users to review comments --- migrations/Version20261006114714.php | 40 ++++++ src/ApiResource/Project/ReviewApiResource.php | 57 ++++++++ .../Project/ReviewAreaApiResource.php | 54 +++++++ .../Project/ReviewCommentApiResource.php | 50 +++++++ .../User/OrganizationApiResource.php | 9 +- src/ApiResource/User/PersonApiResource.php | 6 +- src/Entity/Project/Project.php | 41 ++++++ src/Entity/Project/Review.php | 124 ++++++++++++++++ src/Entity/Project/ReviewArea.php | 133 ++++++++++++++++++ src/Entity/Project/ReviewAreaRisk.php | 21 +++ src/Entity/Project/ReviewComment.php | 76 ++++++++++ src/Entity/Project/ReviewType.php | 16 +++ src/EventListener/ProjectsListener.php | 37 ----- src/OpenApi/RedocExtensionsTrait.php | 3 + .../Project/ReviewAreaRepository.php | 43 ++++++ .../Project/ReviewCommentRepository.php | 43 ++++++ src/Repository/Project/ReviewRepository.php | 43 ++++++ src/Security/Voter/ReviewAreaVoter.php | 57 ++++++++ src/Security/Voter/UserOwnedVoter.php | 2 +- src/Service/Project/ProjectService.php | 36 +++++ src/Service/Project/ReviewService.php | 71 ++++++++++ src/State/Project/ProjectStateProcessor.php | 35 +++-- src/Validator/UserOwned.php | 19 +++ src/Validator/UserOwnedValidator.php | 47 +++++++ tests/Entity/ProjectCalendarTest.php | 11 +- tests/Entity/ProjectCampaignReviewTest.php | 81 +++++++++++ 26 files changed, 1093 insertions(+), 62 deletions(-) create mode 100644 migrations/Version20261006114714.php create mode 100644 src/ApiResource/Project/ReviewApiResource.php create mode 100644 src/ApiResource/Project/ReviewAreaApiResource.php create mode 100644 src/ApiResource/Project/ReviewCommentApiResource.php create mode 100644 src/Entity/Project/Review.php create mode 100644 src/Entity/Project/ReviewArea.php create mode 100644 src/Entity/Project/ReviewAreaRisk.php create mode 100644 src/Entity/Project/ReviewComment.php create mode 100644 src/Entity/Project/ReviewType.php delete mode 100644 src/EventListener/ProjectsListener.php create mode 100644 src/Repository/Project/ReviewAreaRepository.php create mode 100644 src/Repository/Project/ReviewCommentRepository.php create mode 100644 src/Repository/Project/ReviewRepository.php create mode 100644 src/Security/Voter/ReviewAreaVoter.php create mode 100644 src/Service/Project/ReviewService.php create mode 100644 src/Validator/UserOwned.php create mode 100644 src/Validator/UserOwnedValidator.php create mode 100644 tests/Entity/ProjectCampaignReviewTest.php diff --git a/migrations/Version20261006114714.php b/migrations/Version20261006114714.php new file mode 100644 index 0000000..0ade74e --- /dev/null +++ b/migrations/Version20261006114714.php @@ -0,0 +1,40 @@ +addSql('CREATE TABLE review (id INT AUTO_INCREMENT NOT NULL, project_id INT NOT NULL, reviewer_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, date_created DATETIME NOT NULL, date_updated DATETIME NOT NULL, INDEX IDX_794381C6166D1F9C (project_id), INDEX IDX_794381C670574616 (reviewer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE review_area (id INT AUTO_INCREMENT NOT NULL, review_id INT NOT NULL, title VARCHAR(255) NOT NULL, summary LONGTEXT DEFAULT NULL, risk VARCHAR(255) NOT NULL, date_created DATETIME NOT NULL, date_updated DATETIME NOT NULL, INDEX IDX_352070703E2E969B (review_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE review_comment (id INT AUTO_INCREMENT NOT NULL, author_id INT DEFAULT NULL, area_id INT NOT NULL, body LONGTEXT NOT NULL, date_created DATETIME NOT NULL, date_updated DATETIME NOT NULL, INDEX IDX_F9AE69BF675F31B (author_id), INDEX IDX_F9AE69BBD0F409C (area_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C6166D1F9C FOREIGN KEY (project_id) REFERENCES project (id)'); + $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C670574616 FOREIGN KEY (reviewer_id) REFERENCES user (id)'); + $this->addSql('ALTER TABLE review_area ADD CONSTRAINT FK_352070703E2E969B FOREIGN KEY (review_id) REFERENCES review (id)'); + $this->addSql('ALTER TABLE review_comment ADD CONSTRAINT FK_F9AE69BF675F31B FOREIGN KEY (author_id) REFERENCES user (id)'); + $this->addSql('ALTER TABLE review_comment ADD CONSTRAINT FK_F9AE69BBD0F409C FOREIGN KEY (area_id) REFERENCES review_area (id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE review DROP FOREIGN KEY FK_794381C6166D1F9C'); + $this->addSql('ALTER TABLE review DROP FOREIGN KEY FK_794381C670574616'); + $this->addSql('ALTER TABLE review_area DROP FOREIGN KEY FK_352070703E2E969B'); + $this->addSql('ALTER TABLE review_comment DROP FOREIGN KEY FK_F9AE69BF675F31B'); + $this->addSql('ALTER TABLE review_comment DROP FOREIGN KEY FK_F9AE69BBD0F409C'); + $this->addSql('DROP TABLE review'); + $this->addSql('DROP TABLE review_area'); + $this->addSql('DROP TABLE review_comment'); + } +} diff --git a/src/ApiResource/Project/ReviewApiResource.php b/src/ApiResource/Project/ReviewApiResource.php new file mode 100644 index 0000000..82cc879 --- /dev/null +++ b/src/ApiResource/Project/ReviewApiResource.php @@ -0,0 +1,57 @@ + + */ + #[ORM\OneToMany(targetEntity: Review::class, mappedBy: 'project', cascade: ['persist'])] + private Collection $reviews; + + /* * A list of URLs provided by the Project owner.\ * e.g: social profiles, project website. * @@ -179,6 +185,7 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized $this->supports = new ArrayCollection(); $this->categories = new ArrayCollection(); $this->collaborations = new ArrayCollection(); + $this->reviews = new ArrayCollection(); } public function getId(): ?int @@ -378,6 +385,10 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this->status; } + /** + * DO NOT call this method unless you know what you are doing: + * Instead use `ProjectService::transition` or you WILL miss side-effects. + */ public function setStatus(ProjectStatus $status): static { $this->status = $status; @@ -594,6 +605,36 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized } /** + * @return Collection + */ + public function getReviews(): Collection + { + return $this->reviews; + } + + public function addReview(Review $review): static + { + if (!$this->reviews->contains($review)) { + $this->reviews->add($review); + $review->setProject($this); + } + + return $this; + } + + public function removeReview(Review $review): static + { + if ($this->reviews->removeElement($review)) { + // set the owning side to null (unless already changed) + if ($review->getProject() === $this) { + $review->setProject(null); + } + } + + return $this; + } + + /* * @return Link[] */ public function getLinks(): ?array diff --git a/src/Entity/Project/Review.php b/src/Entity/Project/Review.php new file mode 100644 index 0000000..14b81e3 --- /dev/null +++ b/src/Entity/Project/Review.php @@ -0,0 +1,124 @@ + + */ + #[ORM\OneToMany(targetEntity: ReviewArea::class, mappedBy: 'review', cascade: ['persist'])] + private Collection $areas; + + public function __construct() + { + $this->areas = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getProject(): ?Project + { + return $this->project; + } + + public function setProject(?Project $project): static + { + $this->project = $project; + + return $this; + } + + public function getReviewer(): ?User + { + return $this->reviewer; + } + + public function isReviewedBy(UserInterface $user): bool + { + return $this->reviewer?->getUserIdentifier() === $user->getUserIdentifier(); + } + + public function setReviewer(?User $reviewer): static + { + $this->reviewer = $reviewer; + + return $this; + } + + public function getType(): ?ReviewType + { + return $this->type; + } + + public function setType(ReviewType $type): static + { + $this->type = $type; + + return $this; + } + + /** + * @return Collection + */ + public function getAreas(): Collection + { + return $this->areas; + } + + public function addArea(ReviewArea $area): static + { + if (!$this->areas->contains($area)) { + $this->areas->add($area); + $area->setReview($this); + } + + return $this; + } + + public function removeArea(ReviewArea $area): static + { + if ($this->areas->removeElement($area)) { + // set the owning side to null (unless already changed) + if ($area->getReview() === $this) { + $area->setReview(null); + } + } + + return $this; + } +} diff --git a/src/Entity/Project/ReviewArea.php b/src/Entity/Project/ReviewArea.php new file mode 100644 index 0000000..95a2d07 --- /dev/null +++ b/src/Entity/Project/ReviewArea.php @@ -0,0 +1,133 @@ + + */ + #[ORM\OneToMany(targetEntity: ReviewComment::class, mappedBy: 'area')] + private Collection $comments; + + public function __construct() + { + $this->comments = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getReview(): ?Review + { + return $this->review; + } + + public function setReview(?Review $review): static + { + $this->review = $review; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function setSummary(string $summary): static + { + $this->summary = $summary; + + return $this; + } + + public function getRisk(): ?ReviewAreaRisk + { + return $this->risk; + } + + public function setRisk(?ReviewAreaRisk $risk): static + { + $this->risk = $risk; + + return $this; + } + + /** + * @return Collection + */ + public function getComments(): Collection + { + return $this->comments; + } + + public function addComment(ReviewComment $comment): static + { + if (!$this->comments->contains($comment)) { + $this->comments->add($comment); + $comment->setArea($this); + } + + return $this; + } + + public function removeComment(ReviewComment $comment): static + { + if ($this->comments->removeElement($comment)) { + // set the owning side to null (unless already changed) + if ($comment->getArea() === $this) { + $comment->setArea(null); + } + } + + return $this; + } +} diff --git a/src/Entity/Project/ReviewAreaRisk.php b/src/Entity/Project/ReviewAreaRisk.php new file mode 100644 index 0000000..8f83e16 --- /dev/null +++ b/src/Entity/Project/ReviewAreaRisk.php @@ -0,0 +1,21 @@ +id; + } + + public function getAuthor(): ?User + { + return $this->author; + } + + public function setAuthor(?User $author): static + { + $this->author = $author; + + return $this; + } + + public function getBody(): ?string + { + return $this->body; + } + + public function setBody(string $body): static + { + $this->body = $body; + + return $this; + } + + public function getArea(): ?ReviewArea + { + return $this->area; + } + + public function setArea(?ReviewArea $area): static + { + $this->area = $area; + + return $this; + } +} diff --git a/src/Entity/Project/ReviewType.php b/src/Entity/Project/ReviewType.php new file mode 100644 index 0000000..7a411aa --- /dev/null +++ b/src/Entity/Project/ReviewType.php @@ -0,0 +1,16 @@ +hasChangedField('status')) { - return; - } - - if ($project->getStatus() === ProjectStatus::InCampaign) { - $calendar = $this->calendarService->makeCalendar($project->getDeadline()); - - $project->setCalendar($calendar); - } - } -} diff --git a/src/OpenApi/RedocExtensionsTrait.php b/src/OpenApi/RedocExtensionsTrait.php index 9bb4402..fa6d167 100644 --- a/src/OpenApi/RedocExtensionsTrait.php +++ b/src/OpenApi/RedocExtensionsTrait.php @@ -31,6 +31,9 @@ trait RedocExtensionsTrait 'name' => 'Projects', 'tags' => [ 'Project', + 'ProjectReview', + 'ProjectReviewArea', + 'ProjectReviewComment', 'ProjectReward', 'ProjectRewardClaim', 'ProjectBudgetItem', diff --git a/src/Repository/Project/ReviewAreaRepository.php b/src/Repository/Project/ReviewAreaRepository.php new file mode 100644 index 0000000..49d5f7d --- /dev/null +++ b/src/Repository/Project/ReviewAreaRepository.php @@ -0,0 +1,43 @@ + + */ +class ReviewAreaRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ReviewArea::class); + } + + // /** + // * @return ReviewArea[] Returns an array of ReviewArea objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('r.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?ReviewArea + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/Project/ReviewCommentRepository.php b/src/Repository/Project/ReviewCommentRepository.php new file mode 100644 index 0000000..1ff661f --- /dev/null +++ b/src/Repository/Project/ReviewCommentRepository.php @@ -0,0 +1,43 @@ + + */ +class ReviewCommentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ReviewComment::class); + } + + // /** + // * @return ReviewComment[] Returns an array of ReviewComment objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('r.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?ReviewComment + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/Project/ReviewRepository.php b/src/Repository/Project/ReviewRepository.php new file mode 100644 index 0000000..b228bcf --- /dev/null +++ b/src/Repository/Project/ReviewRepository.php @@ -0,0 +1,43 @@ + + */ +class ReviewRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Review::class); + } + + // /** + // * @return Review[] Returns an array of Review objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('r.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Review + // { + // return $this->createQueryBuilder('r') + // ->andWhere('r.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Security/Voter/ReviewAreaVoter.php b/src/Security/Voter/ReviewAreaVoter.php new file mode 100644 index 0000000..5da27b5 --- /dev/null +++ b/src/Security/Voter/ReviewAreaVoter.php @@ -0,0 +1,57 @@ +getUser(); + if (!$user instanceof User) { + return false; + } + + if ($user->hasRoles(['ROLE_ADMIN'])) { + return true; + } + + /** @var ReviewArea */ + $area = $this->mapper->map($subject, ReviewArea::class); + if (!$area || !$area instanceof ReviewArea) { + return false; + } + + $project = $area->getReview()->getProject(); + + switch ($attribute) { + case self::EDIT: + return $area->getReview()->isReviewedBy($user); + case self::VIEW: + return $this->isOwnerOf($project, $user); + } + + return false; + } +} diff --git a/src/Security/Voter/UserOwnedVoter.php b/src/Security/Voter/UserOwnedVoter.php index 95f2ac9..a02fcff 100644 --- a/src/Security/Voter/UserOwnedVoter.php +++ b/src/Security/Voter/UserOwnedVoter.php @@ -32,6 +32,6 @@ class UserOwnedVoter extends Voter */ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { - return $this->isOwnerOf($subject, $token->getUser()); + return $subject !== null && $this->isOwnerOf($subject, $token->getUser()); } } diff --git a/src/Service/Project/ProjectService.php b/src/Service/Project/ProjectService.php index 375c4d7..4f5c7fb 100644 --- a/src/Service/Project/ProjectService.php +++ b/src/Service/Project/ProjectService.php @@ -4,6 +4,7 @@ namespace App\Service\Project; use App\Entity\Project\Project; use App\Entity\Project\ProjectStatus; +use App\Entity\Project\ReviewType; use App\Entity\User\User; use App\Security\Voter\UserOwnedVoterTrait; @@ -19,6 +20,11 @@ class ProjectService ProjectStatus::ToFunding->value => [ProjectStatus::InFunding], ]; + public function __construct( + private ReviewService $reviewService, + private CalendarService $calendarService, + ) {} + /** * Check if an User can move a Project to some status. * @@ -47,4 +53,34 @@ class ProjectService return false; } + + /** + * Moves a Project to a given ProjectStatus. + * + * @param ProjectStatus $to The new status to where the Project will move + * + * @return Project The resulting Project + */ + public function transition(Project $project, ProjectStatus $to): Project + { + $from = $project->getStatus(); + + if ($from === $to) { + return $project; + } + + $project->setStatus($to); + + return match ([$from, $to]) { + [ProjectStatus::InDraft, ProjectStatus::ToCampaignReview] => $project->addReview( + $this->reviewService->makeReview(ReviewType::Campaign) + ), + + [ProjectStatus::ToCampaign, ProjectStatus::InCampaign] => $project->setCalendar( + $this->calendarService->makeCalendar($project->getDeadline()) + ), + + default => $project, + }; + } } diff --git a/src/Service/Project/ReviewService.php b/src/Service/Project/ReviewService.php new file mode 100644 index 0000000..ecf8390 --- /dev/null +++ b/src/Service/Project/ReviewService.php @@ -0,0 +1,71 @@ +setType($type); + + /** @var ReviewArea[] */ + $areas = match ($type) { + ReviewType::Campaign => $this->getCampaignReviewAreas(), + ReviewType::Financial => $this->getFinancialReviewAreas(), + }; + + foreach ($areas as $area) { + $review->addArea($area); + } + + return $review; + } + + /** + * @return ReviewArea[] + */ + private function getCampaignReviewAreas(): array + { + $config = new ReviewArea(); + $config->setTitle('configuration'); + + $info = new ReviewArea(); + $info->setTitle('info'); + + $rewards = new ReviewArea(); + $rewards->setTitle('rewards'); + + $collabs = new ReviewArea(); + $collabs->setTitle('collaborations'); + + $budget = new ReviewArea(); + $budget->setTitle('budget'); + + $about = new ReviewArea(); + $about->setTitle('about'); + + return [ + $config, + $info, + $rewards, + $collabs, + $budget, + $about, + ]; + } + + /** + * @return ReviewArea[] + * + * @todo Add financial review areas + */ + private function getFinancialReviewAreas(): array + { + return []; + } +} diff --git a/src/State/Project/ProjectStateProcessor.php b/src/State/Project/ProjectStateProcessor.php index e9fcaac..8aeb4dd 100644 --- a/src/State/Project/ProjectStateProcessor.php +++ b/src/State/Project/ProjectStateProcessor.php @@ -10,8 +10,8 @@ use App\ApiResource\Project\ProjectApiResource; use App\Dto\ProjectCreationDto; use App\Dto\ProjectUpdationDto; use App\Entity\Project\Project; -use App\Entity\Project\ProjectCalendar; use App\Mapping\AutoMapper; +use App\Service\Project\CalendarService; use App\Service\Project\ProjectService; use App\State\EntityStateProcessor; use Symfony\Bundle\SecurityBundle\Security; @@ -24,6 +24,7 @@ class ProjectStateProcessor implements ProcessorInterface private AutoMapper $autoMapper, private Security $security, private ProjectService $projectService, + private CalendarService $calendarService, ) {} /** @@ -33,13 +34,11 @@ class ProjectStateProcessor implements ProcessorInterface */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { - if ($data instanceof ProjectCreationDto) { - $project = $this->getProjectFromCreation($data); - } elseif ($data instanceof ProjectUpdationDto) { - $project = $this->getProjectFromUpdate($data, $context); - } else { - throw new \InvalidArgumentException('Unsupported input for Project resource'); - } + $project = match ($data::class) { + ProjectCreationDto::class => $this->getProjectFromCreation($data), + ProjectUpdationDto::class => $this->getProjectFromUpdate($data, $context), + default => throw new \InvalidArgumentException('Unsupported input for Project resource'), + }; $project = $this->entityStateProcessor->process($project, $operation, $uriVariables, $context); @@ -60,7 +59,6 @@ class ProjectStateProcessor implements ProcessorInterface $project = $this->autoMapper->map($data, Project::class); $owner = $this->security->getUser(); - if (!$owner) { throw new AuthenticationException(); } @@ -68,28 +66,25 @@ class ProjectStateProcessor implements ProcessorInterface $project->setOwner($owner); if (!isset($data->calendar->release)) { - $data->calendar->release = new \DateTimeImmutable('+28 days'); + $project->setCalendar($this->calendarService->makeCalendar( + $project->getDeadline(), + new \DateTimeImmutable('+28 days') + )); } - $calendar = new ProjectCalendar(); - $calendar->release = $data->calendar->release; - - $project->setCalendar($calendar); - return $project; } private function getProjectFromUpdate(ProjectUpdationDto $data, array $context): Project { - /** @var Project */ - $project = $this->autoMapper->map($context['previous_data'], Project::class); - $actor = $this->security->getUser(); - if (!$actor) { throw new AuthenticationException(); } + /** @var Project */ + $project = $this->autoMapper->map($context['previous_data'], Project::class); + if (isset($data->status) && $data->status !== $project->getStatus()) { if (!$this->projectService->canTransition($actor, $project, $data->status)) { throw new AccessDeniedException(\sprintf( @@ -98,6 +93,8 @@ class ProjectStateProcessor implements ProcessorInterface $data->status->value )); } + + $project = $this->projectService->transition($project, $data->status); } return $this->autoMapper->map($data, $project); diff --git a/src/Validator/UserOwned.php b/src/Validator/UserOwned.php new file mode 100644 index 0000000..71a0c75 --- /dev/null +++ b/src/Validator/UserOwned.php @@ -0,0 +1,19 @@ +iriConverter->getIriFromResource($value); + $user = $this->security->getUser(); + + if ($value && $user) { + /** @var UserApiResource */ + $resource = $this->mapper->map($user, UserApiResource::class); + $resourceIri = $this->iriConverter->getIriFromResource($resource); + + if ($value === $resourceIri) { + return; + } + } + + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation() + ; + } +} diff --git a/tests/Entity/ProjectCalendarTest.php b/tests/Entity/ProjectCalendarTest.php index cfc2b4f..4c3eb20 100644 --- a/tests/Entity/ProjectCalendarTest.php +++ b/tests/Entity/ProjectCalendarTest.php @@ -8,6 +8,7 @@ use App\Entity\Project\ProjectDeadline; use App\Entity\Project\ProjectStatus; use App\Entity\User\User; use App\Factory\Project\ProjectFactory; +use App\Service\Project\ProjectService; use Doctrine\ORM\EntityManagerInterface; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -18,12 +19,14 @@ class ProjectCalendarTest extends ApiTestCase use Factories; private EntityManagerInterface $entityManager; + private ProjectService $projectService; private User $owner; public function setUp(): void { self::bootKernel(); $this->entityManager = static::getContainer()->get(EntityManagerInterface::class); + $this->projectService = static::getContainer()->get(ProjectService::class); $this->owner = $this->createTestUser(); $this->entityManager->persist($this->owner); @@ -44,6 +47,7 @@ class ProjectCalendarTest extends ApiTestCase { return ProjectFactory::createOne([ 'owner' => $this->owner, + 'status' => ProjectStatus::ToCampaign, 'deadline' => $deadline, ])->_real(); } @@ -56,7 +60,9 @@ class ProjectCalendarTest extends ApiTestCase $this->entityManager->persist($project); $this->entityManager->flush(); - $project->setStatus(ProjectStatus::InCampaign); + $project = $this->projectService->transition($project, ProjectStatus::InCampaign); + + $this->entityManager->persist($project); $this->entityManager->flush(); return $project; @@ -65,8 +71,11 @@ class ProjectCalendarTest extends ApiTestCase public function testReleaseDateUpdatesOnCampaignStart(): void { $project = $this->createProjectAndSetToInCampaign(); + $today = new \DateTime(); + $today->setTime(0, 0, 0, 0); $this->assertNotNull($project->getCalendar()->release); + $this->assertGreaterThan($today, $project->getCalendar()->release); } public function testMinimumDeadlineIsSetCorrectly(): void diff --git a/tests/Entity/ProjectCampaignReviewTest.php b/tests/Entity/ProjectCampaignReviewTest.php new file mode 100644 index 0000000..6c8774e --- /dev/null +++ b/tests/Entity/ProjectCampaignReviewTest.php @@ -0,0 +1,81 @@ +entityManager = static::getContainer()->get(EntityManagerInterface::class); + $this->projectService = static::getContainer()->get(ProjectService::class); + + $this->owner = $this->createTestUser(); + $this->entityManager->persist($this->owner); + $this->entityManager->flush(); + } + + private function createTestUser(string $handle = 'test_user', string $email = 'testuser@example.com'): User + { + $user = new User(); + $user->setHandle($handle); + $user->setEmail($email); + $user->setPassword('projectapitestpassword'); + + return $user; + } + + private function createTestProject(ProjectDeadline $deadline = ProjectDeadline::Minimum): Project + { + return ProjectFactory::createOne([ + 'owner' => $this->owner, + 'deadline' => $deadline, + 'status' => ProjectStatus::InDraft, + ])->_real(); + } + + private function createProjectAndSetToInCampaignReview(): Project + { + $project = $this->createTestProject(); + + $this->entityManager->persist($project); + $this->entityManager->flush(); + + $project = $this->projectService->transition($project, ProjectStatus::ToCampaignReview); + + $this->entityManager->persist($project); + $this->entityManager->flush(); + + return $project; + } + + public function testCreatesReviewOnStatusChange(): void + { + $project = $this->createProjectAndSetToInCampaignReview(); + $reviews = $this->entityManager->getRepository(Review::class)->findBy(['project' => $project->getId()]); + + $this->assertNotEmpty($reviews); + $this->assertEquals(1, \count($reviews)); + $this->assertEquals(ReviewType::Campaign->value, $reviews[0]->getType()->value); + } +} -- 2.54.0 From ac47aec378daafe320050822a7c68dbdc93bcc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Subiabre=20Garc=C3=ADa?= Date: Thu, 8 Oct 2026 13:02:07 +0200 Subject: [PATCH 80/81] feat: Themes (#257) * feat: add trait to unify get by id or slug logic * feat: add Theme resource * fix: categories and themes * feat: update api docs for projects to themes relationship * feat: update doctrine relationship of projects to themes * feat: add v3 categories to v4 themes pumping during projects pumping --- migrations/Version20261007154315.php | 32 ++++ src/ApiResource/CategoryApiResource.php | 5 +- .../Project/ProjectApiResource.php | 11 ++ src/ApiResource/ThemeApiResource.php | 64 +++++++ src/Benzina/CategorizationPumpTrait.php | 157 ++++++++++++++++++ src/Benzina/ProjectsPump.php | 25 +++ src/Dto/CategorizedInputDtoTrait.php | 32 ++++ src/Dto/CategoryInputDtoTrait.php | 19 --- src/Dto/ProjectCreationDto.php | 12 +- src/Dto/ProjectUpdationDto.php | 12 +- src/Entity/Project/Project.php | 32 ++++ src/Entity/Theme.php | 55 ++++++ src/OpenApi/RedocExtensionsTrait.php | 6 + src/Repository/CategoryRepository.php | 2 + src/Repository/FindByIdOrSlugTrait.php | 36 ++++ src/Repository/Project/ProjectRepository.php | 12 +- src/Repository/ThemeRepository.php | 45 +++++ src/State/CategoryStateProvider.php | 14 +- src/State/Project/ProjectStateProvider.php | 8 +- src/State/ThemeStateProvider.php | 33 ++++ 20 files changed, 562 insertions(+), 50 deletions(-) create mode 100644 migrations/Version20261007154315.php create mode 100644 src/ApiResource/ThemeApiResource.php create mode 100644 src/Benzina/CategorizationPumpTrait.php create mode 100644 src/Dto/CategorizedInputDtoTrait.php delete mode 100644 src/Dto/CategoryInputDtoTrait.php create mode 100644 src/Entity/Theme.php create mode 100644 src/Repository/FindByIdOrSlugTrait.php create mode 100644 src/Repository/ThemeRepository.php create mode 100644 src/State/ThemeStateProvider.php diff --git a/migrations/Version20261007154315.php b/migrations/Version20261007154315.php new file mode 100644 index 0000000..2f5c97b --- /dev/null +++ b/migrations/Version20261007154315.php @@ -0,0 +1,32 @@ +addSql('CREATE TABLE project_theme (project_id INT NOT NULL, theme_id INT NOT NULL, INDEX IDX_8420EE96166D1F9C (project_id), INDEX IDX_8420EE9659027487 (theme_id), PRIMARY KEY(project_id, theme_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE theme (id INT AUTO_INCREMENT NOT NULL, slug VARCHAR(56) NOT NULL, name VARCHAR(255) NOT NULL, locales JSON NOT NULL COMMENT \'(DC2Type:json)\', UNIQUE INDEX UNIQ_9775E708989D9B62 (slug), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE project_theme ADD CONSTRAINT FK_8420EE96166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_theme ADD CONSTRAINT FK_8420EE9659027487 FOREIGN KEY (theme_id) REFERENCES theme (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_theme DROP FOREIGN KEY FK_8420EE96166D1F9C'); + $this->addSql('ALTER TABLE project_theme DROP FOREIGN KEY FK_8420EE9659027487'); + $this->addSql('DROP TABLE project_theme'); + $this->addSql('DROP TABLE theme'); + } +} diff --git a/src/ApiResource/CategoryApiResource.php b/src/ApiResource/CategoryApiResource.php index 71f7b49..02a2b21 100644 --- a/src/ApiResource/CategoryApiResource.php +++ b/src/ApiResource/CategoryApiResource.php @@ -12,9 +12,8 @@ use App\State\CategoryStateProvider; use Symfony\Component\Validator\Constraints as Assert; /** - * A Category can be used by other resources as a "topic intent".\ - * For example. Projects might relate with up to 2 Categories, which are used by the Project - * as a way to describe itself and can be used to discover similar Projects.\ + * A Category, like in books, is a way of grouping resources by type, genre, audience, or subject, for example: + * science-fiction, biography, fantasy, etc.\ * \ * Categories can only be modified by users with the role "ROLE_ADMIN", but can usually * be referenced by non-admin users in their own resources, such as Project owners. diff --git a/src/ApiResource/Project/ProjectApiResource.php b/src/ApiResource/Project/ProjectApiResource.php index c228fa0..191e55f 100644 --- a/src/ApiResource/Project/ProjectApiResource.php +++ b/src/ApiResource/Project/ProjectApiResource.php @@ -10,6 +10,7 @@ use App\ApiResource\Accounting\AccountingApiResource; use App\ApiResource\CategoryApiResource; use App\ApiResource\LocalizedApiResourceTrait; use App\ApiResource\Matchfunding\MatchCallSubmissionApiResource; +use App\ApiResource\ThemeApiResource; use App\ApiResource\TimestampedCreationApiResource; use App\ApiResource\TimestampedUpdationApiResource; use App\ApiResource\User\UserApiResource; @@ -134,6 +135,16 @@ class ProjectApiResource #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] public array $categories; + /** + * A list of the available Themes of this Project. + * + * @var ThemeApiResource[] + */ + #[Assert\NotBlank()] + #[API\ApiProperty(writableLink: false)] + #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] + public array $themes; + /** * ISO 3166 data about the Project's territory of interest. */ diff --git a/src/ApiResource/ThemeApiResource.php b/src/ApiResource/ThemeApiResource.php new file mode 100644 index 0000000..e087155 --- /dev/null +++ b/src/ApiResource/ThemeApiResource.php @@ -0,0 +1,64 @@ + new API\Link( + description: 'Theme identifier or slug', + ), + ] +)] +#[API\Patch(security: 'is_granted("ROLE_ADMIN")')] +#[API\Delete(security: 'is_granted("ROLE_ADMIN")')] +class ThemeApiResource +{ + use LocalizedApiResourceTrait; + + /** + * This value will identify this Theme in relationships with other resources. + */ + #[API\ApiProperty(identifier: true)] + public string $id; + + /** + * A unique, non white space, string identifier for this Theme. + */ + #[API\ApiProperty(writable: false)] + #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] + public string $slug; + + /** + * A human-readable self-descriptive string of what this Theme is about. + */ + #[Assert\NotBlank()] + #[API\ApiProperty()] + #[API\ApiFilter(SearchFilter::class, strategy: 'partial')] + public string $name; +} diff --git a/src/Benzina/CategorizationPumpTrait.php b/src/Benzina/CategorizationPumpTrait.php new file mode 100644 index 0000000..3b3921e --- /dev/null +++ b/src/Benzina/CategorizationPumpTrait.php @@ -0,0 +1,157 @@ + 'Educación', + 'culture' => 'Cultura', + 'journalism' => 'Periodismo', + 'science-and-tech' => 'Ciencia y Tecnología', + 'rural-and-ecology' => 'Desarrollo rural y Transición ecológica', + 'health-and-cares' => 'Salud y Cuidados', + 'sustainability' => 'Sostenibilidad', + 'democracy' => 'Derechos y Democracia', + 'social-economy' => 'Economía social', + ]; + + #[Required] + public function setThemeRepository(ThemeRepository $themeRepository) + { + $this->themeRepository = $themeRepository; + } + + public function getTheme(string $slug): Theme + { + $theme = $this->themeRepository->findOneBy(['slug' => $slug]); + + if (!$theme) { + $theme = new Theme(); + $theme->setSlug($slug); + $theme->setName(self::THEMES[$slug]); + } + + $theme->addLocale('es'); + + return $theme; + } + + private function matchCategories(array $categories, array $names): bool + { + $categories = array_map(fn($c) => $c['name'], $categories); + $matches = 0; + + foreach ($names as $name) { + if (in_array($name, $categories)) { + $matches = $matches + 1; + } + } + + return $matches === count($names); + } + + private function mapOldCategoriesToThemes(string $socialCommitmentName, array $categories): array + { + $themes = []; + + if ( + $socialCommitmentName === 'Solidario' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('democracy'); + } + + if ( + $socialCommitmentName === 'Software libre' + && $this->matchCategories($categories, ['Tecnológico', 'Científico']) + ) { + $themes[] = $this->getTheme('science-and-tech'); + } + + if ( + $socialCommitmentName === 'Generar empleo' + && $this->matchCategories($categories, ['Emprendedor', 'Social']) + ) { + $themes[] = $this->getTheme('social-economy'); + } + + if ( + $socialCommitmentName === 'Periodismo independiente' + && $this->matchCategories($categories, ['Comunicativo']) + ) { + $themes[] = $this->getTheme('journalism'); + } + + if ( + $socialCommitmentName === 'Educativo' + && $this->matchCategories($categories, ['Educativo']) + ) { + $themes[] = $this->getTheme('education'); + } + + if ( + $socialCommitmentName === 'Crear cultura' + && $this->matchCategories($categories, ['Cultural']) + ) { + $themes[] = $this->getTheme('culture'); + } + + if ( + $socialCommitmentName === 'Acción por el clima' + && $this->matchCategories($categories, ['Ecológico']) + ) { + $themes[] = $this->getTheme('sustainability'); + } + + if ( + $socialCommitmentName === 'Datos abiertos' + && $this->matchCategories($categories, ['Científico', 'Comunicativo']) + ) { + $themes[] = $this->getTheme('journalism'); + } + + if ( + $socialCommitmentName === 'Reforzar valores democráticos' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('democracy'); + } + + if ( + $socialCommitmentName === 'Igualdad de Género' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('democracy'); + } + + if ( + $socialCommitmentName === 'Salud y Cuidados' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('health-and-cares'); + } + + if ( + $socialCommitmentName === 'Energía y sostenibilidad' + && $this->matchCategories($categories, ['Científico', 'Ecológico']) + ) { + $themes[] = $this->getTheme('science-and-tech'); + } + + if ( + $socialCommitmentName === 'Desarrollo agrorural' + && $this->matchCategories($categories, ['Ecológico', 'Emprendedor']) + ) { + $themes[] = $this->getTheme('rural-and-ecology'); + } + + return $themes; + } +} diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index eddaf30..be316eb 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -27,6 +27,7 @@ class ProjectsPump implements PumpInterface use ProjectsPumpTrait; use LocalizedPumpTrait; use TerritoryPumpTrait; + use CategorizationPumpTrait; public function __construct( private ProjectRepository $projectRepository, @@ -106,6 +107,11 @@ class ProjectsPump implements PumpInterface $project->setDescGoal($record['motivation']); $project->setDescTeam($record['related']); + $themes = $this->getProjectThemes($record, $context); + foreach ($themes as $theme) { + $project->addTheme($theme); + } + $this->setPreventFlushAndClear(true); $this->persist($project, $context); @@ -407,4 +413,23 @@ class ProjectsPump implements PumpInterface return $collaborations; } + + private function getProjectThemes(array $record, array $context): array + { + $scQuery = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `social_commitment` sc WHERE sc.id = :scId' + ); + + $scQuery->execute(['scId' => $record['social_commitment']]); + $socialCommitment = $scQuery->fetch(); + + $catsQuery = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `category` c INNER JOIN `project_category` pc ON c.id = pc.category WHERE pc.project = :project' + ); + + $catsQuery->execute(['project' => $record['id']]); + $categories = $catsQuery->fetchAll(); + + return $this->mapOldCategoriesToThemes($socialCommitment['name'] ?? '', $categories); + } } diff --git a/src/Dto/CategorizedInputDtoTrait.php b/src/Dto/CategorizedInputDtoTrait.php new file mode 100644 index 0000000..075cf9a --- /dev/null +++ b/src/Dto/CategorizedInputDtoTrait.php @@ -0,0 +1,32 @@ + 'array', + 'items' => [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => '/categories/cooperativism', + ], + 'example' => [ + '/categories/open-source-software', + '/categories/cooperativism', + ], + ]; + + public const array THEMES_OPENAPI_CONTEXT = [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => '/themes/music', + ], + 'example' => [ + '/themes/music', + '/themes/art', + ], + ]; +} diff --git a/src/Dto/CategoryInputDtoTrait.php b/src/Dto/CategoryInputDtoTrait.php deleted file mode 100644 index a347c1d..0000000 --- a/src/Dto/CategoryInputDtoTrait.php +++ /dev/null @@ -1,19 +0,0 @@ - 'array', - 'items' => [ - 'type' => 'string', - 'format' => 'iri-reference', - 'example' => '/categories/music', - ], - 'example' => [ - '/categories/music', - '/categories/art', - ], - ]; -} diff --git a/src/Dto/ProjectCreationDto.php b/src/Dto/ProjectCreationDto.php index 50cf7c5..5a9daa5 100644 --- a/src/Dto/ProjectCreationDto.php +++ b/src/Dto/ProjectCreationDto.php @@ -4,6 +4,7 @@ namespace App\Dto; use ApiPlatform\Metadata as API; use App\ApiResource\CategoryApiResource; +use App\ApiResource\ThemeApiResource; use App\Entity\Project\Project; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectStatus; @@ -13,7 +14,7 @@ use Symfony\Component\Validator\Constraints as Assert; class ProjectCreationDto { - use CategoryInputDtoTrait; + use CategorizedInputDtoTrait; /** * Main headline for the Project. Must include at least one character between a-Z. @@ -40,6 +41,15 @@ class ProjectCreationDto #[API\ApiProperty(writableLink: false, openapiContext: self::CATEGORIES_OPENAPI_CONTEXT)] public array $categories; + /** + * List of Themes. + * + * @var ThemeApiResource[] + */ + #[Assert\Count(max: 1)] + #[API\ApiProperty(writableLink: false, openapiContext: self::THEMES_OPENAPI_CONTEXT)] + public array $themes; + /** * ISO 3166 data about the Project's territory of interest. */ diff --git a/src/Dto/ProjectUpdationDto.php b/src/Dto/ProjectUpdationDto.php index 44a64c8..92fa747 100644 --- a/src/Dto/ProjectUpdationDto.php +++ b/src/Dto/ProjectUpdationDto.php @@ -4,6 +4,7 @@ namespace App\Dto; use ApiPlatform\Metadata as API; use App\ApiResource\CategoryApiResource; +use App\ApiResource\ThemeApiResource; use App\Entity\Project\Project; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectDeadline; @@ -15,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert; class ProjectUpdationDto { - use CategoryInputDtoTrait; + use CategorizedInputDtoTrait; #[API\ApiProperty(identifier: true, writable: false)] public int $id; @@ -47,6 +48,15 @@ class ProjectUpdationDto #[API\ApiProperty(writableLink: false, openapiContext: self::CATEGORIES_OPENAPI_CONTEXT)] public array $categories; + /** + * List of Themes. + * + * @var ThemeApiResource[] + */ + #[Assert\Count(max: 1)] + #[API\ApiProperty(writableLink: false, openapiContext: self::THEMES_OPENAPI_CONTEXT)] + public array $themes; + /** * ISO 3166 data about the Project's territory of interest. */ diff --git a/src/Entity/Project/Project.php b/src/Entity/Project/Project.php index 83859bb..310be3f 100644 --- a/src/Entity/Project/Project.php +++ b/src/Entity/Project/Project.php @@ -13,6 +13,7 @@ use App\Entity\Matchfunding\MatchCallSubmission; use App\Entity\Matchfunding\MatchCallSubmissionStatus; use App\Entity\MigratedTrait; use App\Entity\Territory; +use App\Entity\Theme; use App\Entity\User\User; use App\Entity\UserOwnedInterface; use App\Entity\UserOwnedTrait; @@ -175,6 +176,12 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized #[ORM\Column(nullable: true)] private ?array $links = null; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Theme::class, cascade: ['persist'])] + private Collection $themes; + public function __construct() { $this->accounting = Accounting::of($this); @@ -186,6 +193,7 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized $this->categories = new ArrayCollection(); $this->collaborations = new ArrayCollection(); $this->reviews = new ArrayCollection(); + $this->themes = new ArrayCollection(); } public function getId(): ?int @@ -651,4 +659,28 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this; } + + /** + * @return Collection + */ + public function getThemes(): Collection + { + return $this->themes; + } + + public function addTheme(Theme $theme): static + { + if (!$this->themes->contains($theme)) { + $this->themes->add($theme); + } + + return $this; + } + + public function removeTheme(Theme $theme): static + { + $this->themes->removeElement($theme); + + return $this; + } } diff --git a/src/Entity/Theme.php b/src/Entity/Theme.php new file mode 100644 index 0000000..bb81cf9 --- /dev/null +++ b/src/Entity/Theme.php @@ -0,0 +1,55 @@ +id; + } + + public function getSlug(): ?string + { + return $this->slug; + } + + public function setSlug(string $slug): static + { + $this->slug = $slug; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } +} diff --git a/src/OpenApi/RedocExtensionsTrait.php b/src/OpenApi/RedocExtensionsTrait.php index fa6d167..5fb0990 100644 --- a/src/OpenApi/RedocExtensionsTrait.php +++ b/src/OpenApi/RedocExtensionsTrait.php @@ -41,7 +41,13 @@ trait RedocExtensionsTrait 'ProjectSupport', 'ProjectCollaboration', 'ProjectCollaborationCandidacy', + ], + ], + [ + 'name' => 'Categorization', + 'tags' => [ 'Category', + 'Theme', ], ], [ diff --git a/src/Repository/CategoryRepository.php b/src/Repository/CategoryRepository.php index 2ea6ad9..8106795 100644 --- a/src/Repository/CategoryRepository.php +++ b/src/Repository/CategoryRepository.php @@ -11,6 +11,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class CategoryRepository extends ServiceEntityRepository { + use FindByIdOrSlugTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Category::class); diff --git a/src/Repository/FindByIdOrSlugTrait.php b/src/Repository/FindByIdOrSlugTrait.php new file mode 100644 index 0000000..bb9abc7 --- /dev/null +++ b/src/Repository/FindByIdOrSlugTrait.php @@ -0,0 +1,36 @@ +find($idOrSlug); + } + + return $this->findOneBy(['slug' => $idOrSlug]); + } + + public function getByIdOrSlugQuery(mixed $idOrSlug): QueryBuilder + { + /** @var QueryBuilder */ + $queryBuilder = $this->createQueryBuilder('o'); + $queryBuilder->where(\ctype_digit((string) $idOrSlug) ? 'o.id = :idOrSlugField' : 'o.slug = :idOrSlugField'); + $queryBuilder->setParameter('idOrSlugField', $idOrSlug); + + return $queryBuilder; + } +} diff --git a/src/Repository/Project/ProjectRepository.php b/src/Repository/Project/ProjectRepository.php index 17849d2..1764372 100644 --- a/src/Repository/Project/ProjectRepository.php +++ b/src/Repository/Project/ProjectRepository.php @@ -4,6 +4,7 @@ namespace App\Repository\Project; use App\Entity\Accounting\Accounting; use App\Entity\Project\Project; +use App\Repository\FindByIdOrSlugTrait; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -17,6 +18,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class ProjectRepository extends ServiceEntityRepository { + use FindByIdOrSlugTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Project::class); @@ -31,15 +34,6 @@ class ProjectRepository extends ServiceEntityRepository ->getOneOrNullResult(); } - public function findOneByIdOrSlug(mixed $idOrSlug): ?Project - { - if (\is_numeric($idOrSlug)) { - return $this->find($idOrSlug); - } - - return $this->findOneBy(['slug' => $idOrSlug]); - } - // /** // * @return Project[] Returns an array of Project objects // */ diff --git a/src/Repository/ThemeRepository.php b/src/Repository/ThemeRepository.php new file mode 100644 index 0000000..87ec402 --- /dev/null +++ b/src/Repository/ThemeRepository.php @@ -0,0 +1,45 @@ + + */ +class ThemeRepository extends ServiceEntityRepository +{ + use FindByIdOrSlugTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Theme::class); + } + + // /** + // * @return Theme[] Returns an array of Theme objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('t') + // ->andWhere('t.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('t.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Theme + // { + // return $this->createQueryBuilder('t') + // ->andWhere('t.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/State/CategoryStateProvider.php b/src/State/CategoryStateProvider.php index f832bff..554cd75 100644 --- a/src/State/CategoryStateProvider.php +++ b/src/State/CategoryStateProvider.php @@ -20,20 +20,14 @@ class CategoryStateProvider implements ProviderInterface public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $idOrSlug = $uriVariables['idOrSlug']; - - $queryBuilder = $this->categoryRepository->createQueryBuilder('c'); - $queryBuilder->where(\is_numeric($idOrSlug) ? 'c.id = :value' : 'c.slug = :value'); - $queryBuilder->setParameter('value', $idOrSlug); - + $queryBuilder = $this->categoryRepository->getByIdOrSlugQuery($uriVariables['idOrSlug']); $query = $this->addLocalizationHints($queryBuilder, $this->getAcceptedLanguages($context)); + $category = $query->getOneOrNullResult(); - $project = $query->getOneOrNullResult(); - - if ($project === null) { + if ($category === null) { return null; } - return $this->autoMapper->map($project, CategoryApiResource::class); + return $this->autoMapper->map($category, CategoryApiResource::class); } } diff --git a/src/State/Project/ProjectStateProvider.php b/src/State/Project/ProjectStateProvider.php index 418548e..77cc230 100644 --- a/src/State/Project/ProjectStateProvider.php +++ b/src/State/Project/ProjectStateProvider.php @@ -20,14 +20,8 @@ class ProjectStateProvider implements ProviderInterface public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $idOrSlug = $uriVariables['idOrSlug']; - - $queryBuilder = $this->projectRepository->createQueryBuilder('p'); - $queryBuilder->where(\is_numeric($idOrSlug) ? 'p.id = :value' : 'p.slug = :value'); - $queryBuilder->setParameter('value', $idOrSlug); - + $queryBuilder = $this->projectRepository->getByIdOrSlugQuery($uriVariables['idOrSlug']); $query = $this->addLocalizationHints($queryBuilder, $this->getAcceptedLanguages($context)); - $project = $query->getOneOrNullResult(); if ($project === null) { diff --git a/src/State/ThemeStateProvider.php b/src/State/ThemeStateProvider.php new file mode 100644 index 0000000..2d4a467 --- /dev/null +++ b/src/State/ThemeStateProvider.php @@ -0,0 +1,33 @@ +themeRepository->getByIdOrSlugQuery($uriVariables['idOrSlug']); + $query = $this->addLocalizationHints($queryBuilder, $this->getAcceptedLanguages($context)); + $theme = $query->getOneOrNullResult(); + + if ($theme === null) { + return null; + } + + return $this->autoMapper->map($theme, ThemeApiResource::class); + } +} -- 2.54.0 From 302464e01e1d1b83561e6b43ff058aaffb4e45df Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Mon, 19 Oct 2026 15:36:25 +0200 Subject: [PATCH 81/81] feat: update ProjectCreationDto categories and themes input --- src/Dto/ProjectCreationDto.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Dto/ProjectCreationDto.php b/src/Dto/ProjectCreationDto.php index 5a9daa5..207241d 100644 --- a/src/Dto/ProjectCreationDto.php +++ b/src/Dto/ProjectCreationDto.php @@ -37,7 +37,7 @@ class ProjectCreationDto * @var CategoryApiResource[] */ #[Assert\NotBlank()] - #[Assert\Count(min: 1, max: 2)] + #[Assert\Count(exactly: 1)] #[API\ApiProperty(writableLink: false, openapiContext: self::CATEGORIES_OPENAPI_CONTEXT)] public array $categories; @@ -46,7 +46,7 @@ class ProjectCreationDto * * @var ThemeApiResource[] */ - #[Assert\Count(max: 1)] + #[Assert\Count(min: 1, max: 2)] #[API\ApiProperty(writableLink: false, openapiContext: self::THEMES_OPENAPI_CONTEXT)] public array $themes; -- 2.54.0