[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:
@@ -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)',
|
||||
)]
|
||||
|
||||
@@ -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)',
|
||||
)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user