diff --git a/.env b/.env index 0483eb2..7554b6d 100644 --- a/.env +++ b/.env @@ -18,6 +18,9 @@ APP_ENV=dev APP_SECRET=b8ec44a08e5f4ba917879713a4a8e63c 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/config/packages/framework.yaml b/config/packages/framework.yaml index 32e051e..200eb59 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -28,6 +28,8 @@ framework: headers: 'User-Agent': 'DTF Goteo v4 API' + trusted_proxies: '%env(APP_TRUSTED_PROXIES)%' + when@test: framework: test: true diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml index cfdec5f..fbc177c 100644 --- a/config/packages/league_oauth2_server.yaml +++ b/config/packages/league_oauth2_server.yaml @@ -2,6 +2,7 @@ league_oauth2_server: client: classname: App\Entity\OAuth2Client authorization_server: + access_token_ttl: '%env(OAUTH_ACCESS_TOKEN_TTL)%' private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%' private_key_passphrase: '%env(resolve:OAUTH_PASSPHRASE)%' encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%' diff --git a/config/services.yaml b/config/services.yaml index fbe65ed..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/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/src/ApiResource/CategoryApiResource.php b/src/ApiResource/CategoryApiResource.php index 71f7b49..02a2b21 100644 --- a/src/ApiResource/CategoryApiResource.php +++ b/src/ApiResource/CategoryApiResource.php @@ -12,9 +12,8 @@ use App\State\CategoryStateProvider; use Symfony\Component\Validator\Constraints as Assert; /** - * A Category can be used by other resources as a "topic intent".\ - * For example. Projects might relate with up to 2 Categories, which are used by the Project - * as a way to describe itself and can be used to discover similar Projects.\ + * A Category, like in books, is a way of grouping resources by type, genre, audience, or subject, for example: + * science-fiction, biography, fantasy, etc.\ * \ * Categories can only be modified by users with the role "ROLE_ADMIN", but can usually * be referenced by non-admin users in their own resources, such as Project owners. diff --git a/src/ApiResource/Project/ProjectApiResource.php b/src/ApiResource/Project/ProjectApiResource.php index c228fa0..191e55f 100644 --- a/src/ApiResource/Project/ProjectApiResource.php +++ b/src/ApiResource/Project/ProjectApiResource.php @@ -10,6 +10,7 @@ use App\ApiResource\Accounting\AccountingApiResource; use App\ApiResource\CategoryApiResource; use App\ApiResource\LocalizedApiResourceTrait; use App\ApiResource\Matchfunding\MatchCallSubmissionApiResource; +use App\ApiResource\ThemeApiResource; use App\ApiResource\TimestampedCreationApiResource; use App\ApiResource\TimestampedUpdationApiResource; use App\ApiResource\User\UserApiResource; @@ -134,6 +135,16 @@ class ProjectApiResource #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] public array $categories; + /** + * A list of the available Themes of this Project. + * + * @var ThemeApiResource[] + */ + #[Assert\NotBlank()] + #[API\ApiProperty(writableLink: false)] + #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] + public array $themes; + /** * ISO 3166 data about the Project's territory of interest. */ diff --git a/src/ApiResource/Project/ReviewApiResource.php b/src/ApiResource/Project/ReviewApiResource.php new file mode 100644 index 0000000..82cc879 --- /dev/null +++ b/src/ApiResource/Project/ReviewApiResource.php @@ -0,0 +1,57 @@ + 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/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/Benzina/CategorizationPumpTrait.php b/src/Benzina/CategorizationPumpTrait.php new file mode 100644 index 0000000..3b3921e --- /dev/null +++ b/src/Benzina/CategorizationPumpTrait.php @@ -0,0 +1,157 @@ + 'Educación', + 'culture' => 'Cultura', + 'journalism' => 'Periodismo', + 'science-and-tech' => 'Ciencia y Tecnología', + 'rural-and-ecology' => 'Desarrollo rural y Transición ecológica', + 'health-and-cares' => 'Salud y Cuidados', + 'sustainability' => 'Sostenibilidad', + 'democracy' => 'Derechos y Democracia', + 'social-economy' => 'Economía social', + ]; + + #[Required] + public function setThemeRepository(ThemeRepository $themeRepository) + { + $this->themeRepository = $themeRepository; + } + + public function getTheme(string $slug): Theme + { + $theme = $this->themeRepository->findOneBy(['slug' => $slug]); + + if (!$theme) { + $theme = new Theme(); + $theme->setSlug($slug); + $theme->setName(self::THEMES[$slug]); + } + + $theme->addLocale('es'); + + return $theme; + } + + private function matchCategories(array $categories, array $names): bool + { + $categories = array_map(fn($c) => $c['name'], $categories); + $matches = 0; + + foreach ($names as $name) { + if (in_array($name, $categories)) { + $matches = $matches + 1; + } + } + + return $matches === count($names); + } + + private function mapOldCategoriesToThemes(string $socialCommitmentName, array $categories): array + { + $themes = []; + + if ( + $socialCommitmentName === 'Solidario' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('democracy'); + } + + if ( + $socialCommitmentName === 'Software libre' + && $this->matchCategories($categories, ['Tecnológico', 'Científico']) + ) { + $themes[] = $this->getTheme('science-and-tech'); + } + + if ( + $socialCommitmentName === 'Generar empleo' + && $this->matchCategories($categories, ['Emprendedor', 'Social']) + ) { + $themes[] = $this->getTheme('social-economy'); + } + + if ( + $socialCommitmentName === 'Periodismo independiente' + && $this->matchCategories($categories, ['Comunicativo']) + ) { + $themes[] = $this->getTheme('journalism'); + } + + if ( + $socialCommitmentName === 'Educativo' + && $this->matchCategories($categories, ['Educativo']) + ) { + $themes[] = $this->getTheme('education'); + } + + if ( + $socialCommitmentName === 'Crear cultura' + && $this->matchCategories($categories, ['Cultural']) + ) { + $themes[] = $this->getTheme('culture'); + } + + if ( + $socialCommitmentName === 'Acción por el clima' + && $this->matchCategories($categories, ['Ecológico']) + ) { + $themes[] = $this->getTheme('sustainability'); + } + + if ( + $socialCommitmentName === 'Datos abiertos' + && $this->matchCategories($categories, ['Científico', 'Comunicativo']) + ) { + $themes[] = $this->getTheme('journalism'); + } + + if ( + $socialCommitmentName === 'Reforzar valores democráticos' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('democracy'); + } + + if ( + $socialCommitmentName === 'Igualdad de Género' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('democracy'); + } + + if ( + $socialCommitmentName === 'Salud y Cuidados' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('health-and-cares'); + } + + if ( + $socialCommitmentName === 'Energía y sostenibilidad' + && $this->matchCategories($categories, ['Científico', 'Ecológico']) + ) { + $themes[] = $this->getTheme('science-and-tech'); + } + + if ( + $socialCommitmentName === 'Desarrollo agrorural' + && $this->matchCategories($categories, ['Ecológico', 'Emprendedor']) + ) { + $themes[] = $this->getTheme('rural-and-ecology'); + } + + return $themes; + } +} diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index b5d0f81..4219dd9 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -27,6 +27,7 @@ class ProjectsPump implements PumpInterface use ProjectsPumpTrait; use LocalizedPumpTrait; use TerritoryPumpTrait; + use CategorizationPumpTrait; public const SELECTED_PROJECTS = [ 'la-nuestra-despega', @@ -129,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); @@ -430,4 +436,23 @@ class ProjectsPump implements PumpInterface return $collaborations; } + + private function getProjectThemes(array $record, array $context): array + { + $scQuery = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `social_commitment` sc WHERE sc.id = :scId' + ); + + $scQuery->execute(['scId' => $record['social_commitment']]); + $socialCommitment = $scQuery->fetch(); + + $catsQuery = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `category` c INNER JOIN `project_category` pc ON c.id = pc.category WHERE pc.project = :project' + ); + + $catsQuery->execute(['project' => $record['id']]); + $categories = $catsQuery->fetchAll(); + + return $this->mapOldCategoriesToThemes($socialCommitment['name'] ?? '', $categories); + } } diff --git a/src/Dto/CategorizedInputDtoTrait.php b/src/Dto/CategorizedInputDtoTrait.php new file mode 100644 index 0000000..075cf9a --- /dev/null +++ b/src/Dto/CategorizedInputDtoTrait.php @@ -0,0 +1,32 @@ + 'array', + 'items' => [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => '/categories/cooperativism', + ], + 'example' => [ + '/categories/open-source-software', + '/categories/cooperativism', + ], + ]; + + public const array THEMES_OPENAPI_CONTEXT = [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => '/themes/music', + ], + 'example' => [ + '/themes/music', + '/themes/art', + ], + ]; +} diff --git a/src/Dto/CategoryInputDtoTrait.php b/src/Dto/CategoryInputDtoTrait.php deleted file mode 100644 index a347c1d..0000000 --- a/src/Dto/CategoryInputDtoTrait.php +++ /dev/null @@ -1,19 +0,0 @@ - 'array', - 'items' => [ - 'type' => 'string', - 'format' => 'iri-reference', - 'example' => '/categories/music', - ], - 'example' => [ - '/categories/music', - '/categories/art', - ], - ]; -} diff --git a/src/Dto/ProjectCreationDto.php b/src/Dto/ProjectCreationDto.php index 50cf7c5..5a9daa5 100644 --- a/src/Dto/ProjectCreationDto.php +++ b/src/Dto/ProjectCreationDto.php @@ -4,6 +4,7 @@ namespace App\Dto; use ApiPlatform\Metadata as API; use App\ApiResource\CategoryApiResource; +use App\ApiResource\ThemeApiResource; use App\Entity\Project\Project; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectStatus; @@ -13,7 +14,7 @@ use Symfony\Component\Validator\Constraints as Assert; class ProjectCreationDto { - use CategoryInputDtoTrait; + use CategorizedInputDtoTrait; /** * Main headline for the Project. Must include at least one character between a-Z. @@ -40,6 +41,15 @@ class ProjectCreationDto #[API\ApiProperty(writableLink: false, openapiContext: self::CATEGORIES_OPENAPI_CONTEXT)] public array $categories; + /** + * List of Themes. + * + * @var ThemeApiResource[] + */ + #[Assert\Count(max: 1)] + #[API\ApiProperty(writableLink: false, openapiContext: self::THEMES_OPENAPI_CONTEXT)] + public array $themes; + /** * ISO 3166 data about the Project's territory of interest. */ diff --git a/src/Dto/ProjectUpdationDto.php b/src/Dto/ProjectUpdationDto.php index 44a64c8..92fa747 100644 --- a/src/Dto/ProjectUpdationDto.php +++ b/src/Dto/ProjectUpdationDto.php @@ -4,6 +4,7 @@ namespace App\Dto; use ApiPlatform\Metadata as API; use App\ApiResource\CategoryApiResource; +use App\ApiResource\ThemeApiResource; use App\Entity\Project\Project; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectDeadline; @@ -15,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert; class ProjectUpdationDto { - use CategoryInputDtoTrait; + use CategorizedInputDtoTrait; #[API\ApiProperty(identifier: true, writable: false)] public int $id; @@ -47,6 +48,15 @@ class ProjectUpdationDto #[API\ApiProperty(writableLink: false, openapiContext: self::CATEGORIES_OPENAPI_CONTEXT)] public array $categories; + /** + * List of Themes. + * + * @var ThemeApiResource[] + */ + #[Assert\Count(max: 1)] + #[API\ApiProperty(writableLink: false, openapiContext: self::THEMES_OPENAPI_CONTEXT)] + public array $themes; + /** * ISO 3166 data about the Project's territory of interest. */ diff --git a/src/Entity/Project/Project.php b/src/Entity/Project/Project.php index 9b0e923..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; @@ -161,6 +162,12 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized 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; @@ -594,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 @@ -610,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; + } + + 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/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/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/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/UserOwnedVoter.php b/src/Security/Voter/UserOwnedVoter.php index 95f2ac9..a02fcff 100644 --- a/src/Security/Voter/UserOwnedVoter.php +++ b/src/Security/Voter/UserOwnedVoter.php @@ -32,6 +32,6 @@ class UserOwnedVoter extends Voter */ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { - return $this->isOwnerOf($subject, $token->getUser()); + return $subject !== null && $this->isOwnerOf($subject, $token->getUser()); } } diff --git a/src/Service/Project/ProjectService.php b/src/Service/Project/ProjectService.php index 375c4d7..4f5c7fb 100644 --- a/src/Service/Project/ProjectService.php +++ b/src/Service/Project/ProjectService.php @@ -4,6 +4,7 @@ namespace App\Service\Project; use App\Entity\Project\Project; use App\Entity\Project\ProjectStatus; +use App\Entity\Project\ReviewType; use App\Entity\User\User; use App\Security\Voter\UserOwnedVoterTrait; @@ -19,6 +20,11 @@ class ProjectService ProjectStatus::ToFunding->value => [ProjectStatus::InFunding], ]; + public function __construct( + private ReviewService $reviewService, + private CalendarService $calendarService, + ) {} + /** * Check if an User can move a Project to some status. * @@ -47,4 +53,34 @@ class ProjectService return false; } + + /** + * Moves a Project to a given ProjectStatus. + * + * @param ProjectStatus $to The new status to where the Project will move + * + * @return Project The resulting Project + */ + public function transition(Project $project, ProjectStatus $to): Project + { + $from = $project->getStatus(); + + if ($from === $to) { + return $project; + } + + $project->setStatus($to); + + return match ([$from, $to]) { + [ProjectStatus::InDraft, ProjectStatus::ToCampaignReview] => $project->addReview( + $this->reviewService->makeReview(ReviewType::Campaign) + ), + + [ProjectStatus::ToCampaign, ProjectStatus::InCampaign] => $project->setCalendar( + $this->calendarService->makeCalendar($project->getDeadline()) + ), + + default => $project, + }; + } } diff --git a/src/Service/Project/ReviewService.php b/src/Service/Project/ReviewService.php new file mode 100644 index 0000000..ecf8390 --- /dev/null +++ b/src/Service/Project/ReviewService.php @@ -0,0 +1,71 @@ +setType($type); + + /** @var ReviewArea[] */ + $areas = match ($type) { + ReviewType::Campaign => $this->getCampaignReviewAreas(), + ReviewType::Financial => $this->getFinancialReviewAreas(), + }; + + foreach ($areas as $area) { + $review->addArea($area); + } + + return $review; + } + + /** + * @return ReviewArea[] + */ + private function getCampaignReviewAreas(): array + { + $config = new ReviewArea(); + $config->setTitle('configuration'); + + $info = new ReviewArea(); + $info->setTitle('info'); + + $rewards = new ReviewArea(); + $rewards->setTitle('rewards'); + + $collabs = new ReviewArea(); + $collabs->setTitle('collaborations'); + + $budget = new ReviewArea(); + $budget->setTitle('budget'); + + $about = new ReviewArea(); + $about->setTitle('about'); + + return [ + $config, + $info, + $rewards, + $collabs, + $budget, + $about, + ]; + } + + /** + * @return ReviewArea[] + * + * @todo Add financial review areas + */ + private function getFinancialReviewAreas(): array + { + return []; + } +} diff --git a/src/State/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/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/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); + } +}