diff --git a/.env b/.env index c1ef931..7554b6d 100644 --- a/.env +++ b/.env @@ -17,7 +17,10 @@ ###> symfony/framework-bundle ### APP_ENV=dev APP_SECRET=b8ec44a08e5f4ba917879713a4a8e63c -APP_LOCALE='es' +APP_LOCALE='en' +# 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 ### @@ -69,6 +72,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/.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/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/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/framework.yaml b/config/packages/framework.yaml index 387064a..200eb59 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -26,7 +26,9 @@ framework: http_client: default_options: headers: - 'User-Agent': 'Platoniq Goteo-v4-API' + 'User-Agent': 'DTF Goteo v4 API' + + trusted_proxies: '%env(APP_TRUSTED_PROXIES)%' when@test: framework: 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/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/config/services.yaml b/config/services.yaml index fbe65ed..4680e63 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -4,6 +4,7 @@ # 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 services: # default configuration for services in *this* file 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/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/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/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/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/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/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/OrganizationApiResource.php b/src/ApiResource/User/OrganizationApiResource.php index 2b9e95c..585f7ce 100644 --- a/src/ApiResource/User/OrganizationApiResource.php +++ b/src/ApiResource/User/OrganizationApiResource.php @@ -42,12 +42,13 @@ class OrganizationApiResource public UserApiResource $user; /** - * ID for tax purposes. e.g: NIF (formerly CIF), Umsatzsteuer-Id, EID, etc. + * Organization ID for tax purposes. e.g: NIF (formerly CIF), Umsatzsteuer-Id, EID, etc. */ + #[Assert\NotBlank()] + #[Assert\Length(min: 2)] #[API\ApiProperty( securityPostDenormalize: 'is_granted("ORGANIZATION_EDIT", previous_object)', )] - #[Assert\NotBlank()] public string $taxId; /** @@ -55,10 +56,11 @@ class OrganizationApiResource * as it appears on legal documents issued by or for this organization.\ * Will be used as last option for the display name of the User. */ + #[Assert\NotBlank()] + #[Assert\Length(min: 1)] #[API\ApiProperty( securityPostDenormalize: 'is_granted("ORGANIZATION_EDIT", previous_object)', )] - #[Assert\NotBlank()] public string $legalName; /** @@ -66,6 +68,7 @@ class OrganizationApiResource * Might be similar to the legal name or completely different.\ * Will be used as display name for the User when present. */ + #[Assert\Length(min: 1)] #[API\ApiProperty( securityPostDenormalize: 'is_granted("ORGANIZATION_EDIT", previous_object)', )] diff --git a/src/ApiResource/User/PersonApiResource.php b/src/ApiResource/User/PersonApiResource.php index 4ebdddf..4020b04 100644 --- a/src/ApiResource/User/PersonApiResource.php +++ b/src/ApiResource/User/PersonApiResource.php @@ -7,6 +7,7 @@ use ApiPlatform\Metadata as API; use App\Entity\User\Person; use App\State\ApiResourceStateProcessor; use App\State\ApiResourceStateProvider; +use Symfony\Component\Validator\Constraints as Assert; /** * Most times a real person is behind a User account, @@ -43,6 +44,7 @@ class PersonApiResource /** * Personal ID for tax purposes. e.g: NIF, Steuer-ID, SSN, ITIN, etc. */ + #[Assert\Length(min: 2)] #[API\ApiProperty( securityPostDenormalize: 'is_granted("PERSON_EDIT", previous_object)', )] @@ -52,6 +54,7 @@ class PersonApiResource * First-part of the name of the person, * in most western conventions this is the given name(s). e.g: John, Juan, etc. */ + #[Assert\Length(min: 1)] #[API\ApiProperty( securityPostDenormalize: 'is_granted("PERSON_EDIT", previous_object)', )] @@ -59,8 +62,9 @@ class PersonApiResource /** * Last-part of the name of the person, - * in most western conventions this is the family name(s). e.g: Smith, Herrera García, etc. + * in most western conventions this is the family name(s). e.g: Smith, Herrera, etc. */ + #[Assert\Length(min: 1)] #[API\ApiProperty( securityPostDenormalize: 'is_granted("PERSON_EDIT", previous_object)', )] 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/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/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..30630f6 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 { @@ -86,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; } @@ -261,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 d461430..0f23808 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; @@ -49,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 bd7665e..4219dd9 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,28 +12,49 @@ 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; use TerritoryPumpTrait; + use CategorizationPumpTrait; + + 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 UserRepository $userRepository, + private PumpedUserRepository $userRepository, private TerritoryService $territoryService, private ScoutService $scoutService, ) {} @@ -48,7 +70,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; } @@ -82,11 +104,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); @@ -101,6 +130,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); @@ -124,13 +158,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 +268,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 +374,85 @@ 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; + } + + 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/Benzina/ProjectsRewardsPump.php b/src/Benzina/ProjectsRewardsPump.php index 81b0a70..03d48df 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 = [ @@ -53,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 0aa03c3..0ffb03c 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,11 +73,12 @@ 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)); + $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); @@ -83,9 +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)); match ($user->getType()) { UserType::Individual => $user = $this->setUserPerson($record, $user), @@ -204,4 +209,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/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 1965143..207241d 100644 --- a/src/Dto/ProjectCreationDto.php +++ b/src/Dto/ProjectCreationDto.php @@ -4,14 +4,17 @@ 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; use App\Entity\Territory; +use App\Validator\NotExisting; 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. @@ -19,6 +22,7 @@ class ProjectCreationDto #[Assert\NotBlank()] #[Assert\Regex('/[a-zA-Z]{1,}/')] #[Assert\Length(min: 3)] + #[NotExisting(Project::class, 'title')] public string $title; /** @@ -33,10 +37,19 @@ 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; + /** + * List of Themes. + * + * @var ThemeApiResource[] + */ + #[Assert\Count(min: 1, max: 2)] + #[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/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..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; @@ -157,10 +158,16 @@ 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; /** + * @var Collection + */ + #[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. * @@ -169,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); @@ -179,6 +192,8 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized $this->supports = new ArrayCollection(); $this->categories = new ArrayCollection(); $this->collaborations = new ArrayCollection(); + $this->reviews = new ArrayCollection(); + $this->themes = new ArrayCollection(); } public function getId(): ?int @@ -378,6 +393,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; @@ -561,6 +580,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)) { @@ -584,6 +613,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 @@ -600,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/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 @@ +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 @@ +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/Entity/User/User.php b/src/Entity/User/User.php index bad0900..bd95aa6 100644 --- a/src/Entity/User/User.php +++ b/src/Entity/User/User.php @@ -4,6 +4,7 @@ namespace App\Entity\User; use App\Entity\Accounting\Accounting; use App\Entity\Accounting\AccountingOwnerInterface; +use App\Entity\Address; use App\Entity\DateCreatedTrait; use App\Entity\DateUpdatedTrait; use App\Entity\DedupedTrait; @@ -124,6 +125,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $description = null; + /** + * @var Collection + */ + #[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/EventListener/ProjectsListener.php b/src/EventListener/ProjectsListener.php deleted file mode 100644 index b02dff4..0000000 --- a/src/EventListener/ProjectsListener.php +++ /dev/null @@ -1,37 +0,0 @@ -hasChangedField('status')) { - return; - } - - if ($project->getStatus() === ProjectStatus::InCampaign) { - $calendar = $this->calendarService->makeCalendar($project->getDeadline()); - - $project->setCalendar($calendar); - } - } -} 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/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. diff --git a/src/OpenApi/RedocExtensionsTrait.php b/src/OpenApi/RedocExtensionsTrait.php index 9bb4402..5fb0990 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', @@ -38,7 +41,13 @@ trait RedocExtensionsTrait 'ProjectSupport', 'ProjectCollaboration', 'ProjectCollaborationCandidacy', + ], + ], + [ + 'name' => 'Categorization', + 'tags' => [ 'Category', + 'Theme', ], ], [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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); + } +} 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/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/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 @@
  • 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); + } +} 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'],