[feat] Project reviews (#177)

* feat: start project reviews entity

* feat: add review areas entity

* feat: add review comments entity

* fix: code styles improvements by php-cs-fixer

* feat: expose project review resources in the API

* feat: add timestampt to review resources

* feat: add SQL migration

* fix: update ProjectReview API docs and operations

* feat: refactor ProjectsListener to dedicated project status listener

* fix: make default review area risk high

* feat: add ReviewService for creating default project reviews

* fix: renamed timestampt entity traits

* fix: update doctrine migration

* feat: test for release date to be set greater than today's start

* feat: add test for project campaign review

* fix: update person and organization patch validations

* fix: test if review entity is truly persisted

* fix: bad start statuses in project tests

* fix: ProjectStatusListener not persisting side effect data

* fix: centralize project status change side-effects processing in ProjectService

* fix: small review persistence tweaks

* feat: allow only concerned users to see and edit review areas

* feat: apply validations and security for concerned users to review comments
This commit is contained in:
2026-10-07 13:31:44 +02:00
committed by GitHub
parent 86ba2c4fe4
commit 14ee28be4f
26 changed files with 1093 additions and 62 deletions
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261006114714 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add review, review_area and review_comment tables';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\ApiResource\Project;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\TimestampedCreationApiResource;
use App\ApiResource\TimestampedUpdationApiResource;
use App\ApiResource\User\UserApiResource;
use App\Entity\Project\Review;
use App\Entity\Project\ReviewType;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
/**
* ProjectReviews are launched as health-checks for Projects.\
* \
* The reviews are focused on certain aspects of a Project's lifecycle. The types `campaign` and `financial` reviews
* are to evaluate the fitness of a Project before being greenlit for campaigning or the legitimacy of their raised funds, respectively.\
* \
* ProjectReview resources cannot be manually created, they are created automatically when a Project moves into a "to review" status:
* - `to_campaign_review`: will launch a related ProjectReview of `campaign` type
* - `to_financial_review`: will launch a related ProjectReview of `financial` type.
*/
#[API\ApiResource(
shortName: 'ProjectReview',
stateOptions: new Options(entityClass: Review::class),
provider: ApiResourceStateProvider::class,
processor: ApiResourceStateProcessor::class
)]
#[API\GetCollection()]
#[API\Get()]
#[API\Delete()]
#[API\Patch()]
class ReviewApiResource
{
use TimestampedCreationApiResource;
use TimestampedUpdationApiResource;
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public ProjectApiResource $project;
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public UserApiResource $reviewer;
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public ReviewType $type;
/**
* @var ReviewAreaApiResource[]
*/
public array $areas;
}
@@ -0,0 +1,54 @@
<?php
namespace App\ApiResource\Project;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\TimestampedCreationApiResource;
use App\ApiResource\TimestampedUpdationApiResource;
use App\Entity\Project\ReviewArea;
use App\Entity\Project\ReviewAreaRisk;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
/**
* A ProjectReviewArea represents one specific topic of evaluation for ProjectReviews.\
* \
* Conversations, feedback and evolution of the ProjectReview must happen around specific areas of review.\
* Each area holds an associated risk, which represents the trust the reviewer has for the reviewed Project's health in that area,
* areas can only be updated by the assigned reviewer or an user with `ROLE_ADMIN`.
*/
#[API\ApiResource(
shortName: 'ProjectReviewArea',
stateOptions: new Options(entityClass: ReviewArea::class),
provider: ApiResourceStateProvider::class,
processor: ApiResourceStateProcessor::class,
)]
#[API\GetCollection()]
#[API\Post(security: 'is_granted("ROLE_ADMIN")')]
#[API\Get(security: 'is_granted("REVIEWAREA_VIEW", object)')]
#[API\Patch(security: 'is_granted("REVIEWAREA_EDIT", object)')]
#[API\Delete(security: 'is_granted("REVIEWAREA_EDIT", object)')]
class ReviewAreaApiResource
{
use TimestampedCreationApiResource;
use TimestampedUpdationApiResource;
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public ReviewApiResource $review;
public string $title;
public string $summary;
public ReviewAreaRisk $risk;
/**
* @var ReviewCommentApiResource[]
*/
public array $comments;
}
@@ -0,0 +1,50 @@
<?php
namespace App\ApiResource\Project;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\TimestampedCreationApiResource;
use App\ApiResource\TimestampedUpdationApiResource;
use App\ApiResource\User\UserApiResource;
use App\Entity\Project\ReviewComment;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
use App\Validator\UserOwned;
use Symfony\Component\Validator\Constraints as Assert;
/**
* ProjectReviewComments hold the conversation between the reviewer and the reviewed Project owner.
*/
#[API\ApiResource(
shortName: 'ProjectReviewComment',
stateOptions: new Options(entityClass: ReviewComment::class),
provider: ApiResourceStateProvider::class,
processor: ApiResourceStateProcessor::class,
)]
#[API\GetCollection()]
#[API\Post(securityPostDenormalize: 'is_granted("REVIEWAREA_VIEW", object.area)')]
#[API\Get(security: 'is_granted("REVIEWAREA_VIEW", object.area)')]
#[API\Delete(security: 'is_granted("REVIEWAREA_VIEW", object.area)')]
class ReviewCommentApiResource
{
use TimestampedCreationApiResource;
use TimestampedUpdationApiResource;
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
#[Assert\NotBlank()]
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public ReviewAreaApiResource $area;
#[UserOwned()]
#[Assert\NotBlank()]
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public UserApiResource $author;
#[Assert\NotBlank()]
#[Assert\Length(min: 15)]
public string $body;
}
@@ -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)',
)]
+5 -1
View File
@@ -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)',
)]
+41
View File
@@ -161,6 +161,12 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
private Collection $collaborations;
/**
* @var Collection<int, Review>
*/
#[ORM\OneToMany(targetEntity: Review::class, mappedBy: 'project', cascade: ['persist'])]
private Collection $reviews;
/*
* A list of URLs provided by the Project owner.\
* e.g: social profiles, project website.
*
@@ -179,6 +185,7 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
$this->supports = new ArrayCollection();
$this->categories = new ArrayCollection();
$this->collaborations = new ArrayCollection();
$this->reviews = new ArrayCollection();
}
public function getId(): ?int
@@ -378,6 +385,10 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
return $this->status;
}
/**
* DO NOT call this method unless you know what you are doing:
* Instead use `ProjectService::transition` or you WILL miss side-effects.
*/
public function setStatus(ProjectStatus $status): static
{
$this->status = $status;
@@ -594,6 +605,36 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
}
/**
* @return Collection<int, Review>
*/
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
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace App\Entity\Project;
use App\Entity\DateCreatedTrait;
use App\Entity\DateUpdatedTrait;
use App\Entity\User\User;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Project\ReviewRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Entity(repositoryClass: ReviewRepository::class)]
class Review
{
use DateCreatedTrait;
use DateUpdatedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'reviews')]
#[ORM\JoinColumn(nullable: false)]
private ?Project $project = null;
#[ORM\ManyToOne]
private ?User $reviewer = null;
#[ORM\Column(enumType: ReviewType::class)]
private ?ReviewType $type = null;
/**
* @var Collection<int, ReviewArea>
*/
#[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<int, ReviewArea>
*/
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;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Entity\Project;
use App\Entity\DateCreatedTrait;
use App\Entity\DateUpdatedTrait;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Project\ReviewAreaRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Entity(repositoryClass: ReviewAreaRepository::class)]
class ReviewArea
{
use DateCreatedTrait;
use DateUpdatedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'areas')]
#[ORM\JoinColumn(nullable: false)]
private ?Review $review = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $summary = null;
#[ORM\Column(enumType: ReviewAreaRisk::class)]
private ReviewAreaRisk $risk = ReviewAreaRisk::High;
/**
* @var Collection<int, ReviewComment>
*/
#[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<int, ReviewComment>
*/
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;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Entity\Project;
enum ReviewAreaRisk: string
{
/**
* The risk acceptable or none. Green light.
*/
case Low = 'low';
/**
* The risk is moderate or somewhat worrying. Yellow light.
*/
case Mid = 'mid';
/**
* The risk is severe and/or worrying. Red light.
*/
case High = 'high';
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Entity\Project;
use App\Entity\DateCreatedTrait;
use App\Entity\DateUpdatedTrait;
use App\Entity\User\User;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Project\ReviewCommentRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Entity(repositoryClass: ReviewCommentRepository::class)]
class ReviewComment
{
use DateCreatedTrait;
use DateUpdatedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
private ?User $author = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $body = null;
#[ORM\ManyToOne(inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false)]
private ?ReviewArea $area = null;
public function getId(): ?int
{
return $this->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;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Entity\Project;
enum ReviewType: string
{
/**
* The goal is to determine if a Project is fit for campaigning or not.
*/
case Campaign = 'campaign';
/**
* The goal is to determine if a Project is fit for payment or not.
*/
case Financial = 'financial';
}
-37
View File
@@ -1,37 +0,0 @@
<?php
namespace App\EventListener;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectStatus;
use App\Service\Project\CalendarService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
#[AsEntityListener(
event: Events::preUpdate,
method: 'preUpdate',
entity: Project::class
)]
final class ProjectsListener
{
public function __construct(
private CalendarService $calendarService,
) {}
public function preUpdate(
Project $project,
PreUpdateEventArgs $event,
) {
if (!$event->hasChangedField('status')) {
return;
}
if ($project->getStatus() === ProjectStatus::InCampaign) {
$calendar = $this->calendarService->makeCalendar($project->getDeadline());
$project->setCalendar($calendar);
}
}
}
+3
View File
@@ -31,6 +31,9 @@ trait RedocExtensionsTrait
'name' => 'Projects',
'tags' => [
'Project',
'ProjectReview',
'ProjectReviewArea',
'ProjectReviewComment',
'ProjectReward',
'ProjectRewardClaim',
'ProjectBudgetItem',
@@ -0,0 +1,43 @@
<?php
namespace App\Repository\Project;
use App\Entity\Project\ReviewArea;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ReviewArea>
*/
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()
// ;
// }
}
@@ -0,0 +1,43 @@
<?php
namespace App\Repository\Project;
use App\Entity\Project\ReviewComment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ReviewComment>
*/
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()
// ;
// }
}
@@ -0,0 +1,43 @@
<?php
namespace App\Repository\Project;
use App\Entity\Project\Review;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Review>
*/
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()
// ;
// }
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Security\Voter;
use App\ApiResource\Project\ReviewAreaApiResource;
use App\Entity\Project\ReviewArea;
use App\Entity\User\User;
use App\Mapping\AutoMapper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class ReviewAreaVoter extends Voter
{
use UserOwnedVoterTrait;
public const EDIT = 'REVIEWAREA_EDIT';
public const VIEW = 'REVIEWAREA_VIEW';
public function __construct(
private AutoMapper $mapper,
) {}
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::VIEW])
&& $subject instanceof ReviewAreaApiResource;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->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;
}
}
+1 -1
View File
@@ -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());
}
}
+36
View File
@@ -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,
};
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Service\Project;
use App\Entity\Project\Review;
use App\Entity\Project\ReviewArea;
use App\Entity\Project\ReviewType;
class ReviewService
{
public function makeReview(ReviewType $type): Review
{
$review = new Review();
$review->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 [];
}
}
+16 -19
View File
@@ -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);
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class UserOwned extends Constraint
{
public string $message = 'Tried to reference "{{ value }}" while not authenticated as the very User.';
public function __construct(
public string $mode = 'strict',
?array $groups = null,
mixed $payload = null,
) {
parent::__construct([], $groups, $payload);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Validator;
use ApiPlatform\Metadata\IriConverterInterface;
use App\ApiResource\User\UserApiResource;
use App\Mapping\AutoMapper;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
final class UserOwnedValidator extends ConstraintValidator
{
public function __construct(
private Security $security,
private AutoMapper $mapper,
private IriConverterInterface $iriConverter,
) {}
/**
* @param UserOwned $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
if ($value === null || $value === '') {
return;
}
$value = $this->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()
;
}
}
+10 -1
View File
@@ -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
@@ -0,0 +1,81 @@
<?php
namespace App\Tests\Entity;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\Project\Review;
use App\Entity\Project\ReviewType;
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;
class ProjectCampaignReviewTest extends ApiTestCase
{
use ResetDatabase;
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);
$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);
}
}