Skip to content

Comments: api #7230

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: devel
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions api/fixtures/comments.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
App\Entity\Comment:
comment1:
camp: '@camp1'
activity: '@activity1'
text: <sentence()>
author: '@user1manager'
orphanDescription: null
comment2:
camp: '@camp1'
activity: '@activity1'
text: <sentence()>
author: '@user4unrelated'
orphanDescription: null
comment3:
camp: '@camp1'
activity: '@activity2'
text: <sentence()>
author: '@user1manager'
orphanDescription: null
64 changes: 64 additions & 0 deletions api/migrations/schema/Version20250413090555.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250413090555 extends AbstractMigration {
public function getDescription(): string {
return 'Add comment entity';
}

public function up(Schema $schema): void {
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE comment (id VARCHAR(16) NOT NULL, createTime TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updateTime TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, text TEXT NOT NULL, orphanDescription TEXT DEFAULT NULL, campId VARCHAR(16) NOT NULL, activityId VARCHAR(16) DEFAULT NULL, authorId VARCHAR(16) NOT NULL, PRIMARY KEY(id))
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_9474526C6D299429 ON comment (campId)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_9474526C1335E2FC ON comment (activityId)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_9474526CA196F9FD ON comment (authorId)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_9474526C9D468A55 ON comment (createTime)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_9474526C55AA53E2 ON comment (updateTime)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE comment ADD CONSTRAINT FK_9474526C6D299429 FOREIGN KEY (campId) REFERENCES camp (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE comment ADD CONSTRAINT FK_9474526C1335E2FC FOREIGN KEY (activityId) REFERENCES activity (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE comment ADD CONSTRAINT FK_9474526CA196F9FD FOREIGN KEY (authorId) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
}

public function down(Schema $schema): void {
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE comment DROP CONSTRAINT FK_9474526C6D299429
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE comment DROP CONSTRAINT FK_9474526C1335E2FC
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE comment DROP CONSTRAINT FK_9474526CA196F9FD
SQL);
$this->addSql(<<<'SQL'
DROP TABLE comment
SQL);
}
}
22 changes: 22 additions & 0 deletions api/src/Entity/Activity.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ class Activity extends BaseEntity implements BelongsToCampInterface {
#[ORM\Column(type: 'text')]
public string $location = '';

/**
* All comments of the activity.
*/
#[ApiProperty(
writable: false,
uriTemplate: Comment::ACTIVITY_SUBRESOURCE_URI_TEMPLATE,
example: '/activity/1a2b3c4d/comments'
)]
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'activity')]
public Collection $comments;

/**
* The list of people that are responsible for planning or carrying out this activity.
*/
Expand All @@ -189,6 +200,7 @@ public function __construct() {
parent::__construct();
$this->scheduleEntries = new ArrayCollection();
$this->activityResponsibles = new ArrayCollection();
$this->comments = new ArrayCollection();
}

public function getCamp(): ?Camp {
Expand Down Expand Up @@ -290,4 +302,14 @@ public function removeActivityResponsible(ActivityResponsible $activityResponsib

return $this;
}

public function removeComment(Comment $comment): self {
if ($this->comments->removeElement($comment)) {
if ($comment->activity === $this) {
$comment->activity = null;
}
}

return $this;
}
}
8 changes: 8 additions & 0 deletions api/src/Entity/Camp.php
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,13 @@ class Camp extends BaseEntity implements BelongsToCampInterface, CopyFromPrototy
#[ORM\JoinColumn(nullable: false)]
public ?User $owner = null;

/**
* All comments of the camp.
*/
#[ApiProperty(readable: false, writable: false)]
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'camp', cascade: ['persist', 'remove'], orphanRemoval: true)]
public Collection $comments;

public function __construct() {
parent::__construct();
$this->collaborations = new ArrayCollection();
Expand All @@ -397,6 +404,7 @@ public function __construct() {
$this->materialLists = new ArrayCollection();
$this->checklists = new ArrayCollection();
$this->campRootContentNodes = new ArrayCollection();
$this->comments = new ArrayCollection();
}

/**
Expand Down
121 changes: 121 additions & 0 deletions api/src/Entity/Comment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

namespace App\Entity;

use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Post;
use App\InputFilter;
use App\Repository\CommentRepository;
use App\State\CommentCreateProcessor;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

/**
* A Comment.
*/
#[ApiResource(
operations: [
new Get(
security: 'is_granted("CAMP_COLLABORATOR", object) or object.author === user',
),
new Delete(
security: 'object.author === user',
),
new GetCollection(
security: 'is_authenticated()'
),
new Post(
processor: CommentCreateProcessor::class,
denormalizationContext: ['groups' => ['create', 'write']],
securityPostDenormalize: 'is_granted("CAMP_COLLABORATOR", object)',
),
new GetCollection(
uriTemplate: self::ACTIVITY_SUBRESOURCE_URI_TEMPLATE,
uriVariables: [
'activityId' => new Link(
toProperty: 'activity',
fromClass: Activity::class,
security: 'is_granted("CAMP_COLLABORATOR", activity)',
),
],
security: 'is_fully_authenticated()',
),
],
denormalizationContext: ['groups' => ['write']],
normalizationContext: ['groups' => ['read']],
)]
#[ApiFilter(filterClass: SearchFilter::class, properties: ['camp', 'activity'])]
#[ORM\Entity(repositoryClass: CommentRepository::class)]
class Comment extends BaseEntity implements BelongsToCampInterface {
public const ACTIVITY_SUBRESOURCE_URI_TEMPLATE = '/activities/{activityId}/comments{._format}';

/**
* The camp this comment belongs to.
*/
#[ApiProperty(example: '/camps/1a2b3c4d')]
#[Groups(['read', 'create'])]
#[ORM\ManyToOne(targetEntity: Camp::class, inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false, onDelete: 'cascade')]
public ?Camp $camp = null;

/**
* The activity this comment belongs to.
*/
#[Assert\Expression('this.activity.camp == this.camp', 'The activity must belong to the camp.')]
#[ApiProperty(example: '/activities/1a2b3c4d')]
#[Groups(['read', 'create'])]
#[ORM\ManyToOne(targetEntity: Activity::class, inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: true)]
public ?Activity $activity = null;

/**
* The author of the comment.
*/
#[Assert\DisableAutoMapping] // avoids validation error when author is null in payload
#[ApiProperty(example: '/users/1a2b3c4d', writable: false)]
#[Groups(['read', 'create'])]
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false, onDelete: 'cascade')]
public ?User $author = null;

/**
* The actual comment.
*/
#[InputFilter\Trim]
#[InputFilter\CleanText]
#[Assert\NotBlank]
#[Assert\Length(max: 1024)]
#[ApiProperty(example: 'This activity is great!')]
#[Groups(['read', 'create'])]
#[ORM\Column(type: 'text', nullable: false)]
public ?string $text = null;

/**
* Persisted description of the context where the comment was originally writen.
* Only non-null when activity pointer is null, i.e. activity was deleted.
* Currently defined as the title of the activity when it was deleted.
*/
#[InputFilter\Trim]
#[InputFilter\CleanText]
#[Assert\Length(max: 32)]
#[ApiProperty(example: 'Sportolympiade', writable: false)]
#[Groups(['read', 'create'])]
#[ORM\Column(type: 'text', nullable: true)]
public ?string $orphanDescription = null;

public function __construct() {
parent::__construct();
}

public function getCamp(): ?Camp {
return $this->camp;
}
}
8 changes: 8 additions & 0 deletions api/src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,19 @@ class User extends BaseEntity implements UserInterface, PasswordAuthenticatedUse
#[ORM\JoinColumn(nullable: false, unique: true, onDelete: 'restrict')]
public Profile $profile;

/**
* All comments of the user.
*/
#[ApiProperty(readable: false, writable: false)]
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'author')]
public Collection $comments;

public function __construct() {
parent::__construct();
$this->ownedCamps = new ArrayCollection();
$this->collaborations = new ArrayCollection();
$this->userCamps = new ArrayCollection();
$this->comments = new ArrayCollection();
}

/**
Expand Down
42 changes: 42 additions & 0 deletions api/src/Repository/CommentRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace App\Repository;

use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Comment;
use App\Entity\User;
use App\Entity\UserCamp;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;

/**
* @method null|Comment find($id, $lockMode = null, $lockVersion = null)
* @method null|Comment findOneBy(array $criteria, array $orderBy = null)
* @method Comment[] findAll()
* @method Comment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class CommentRepository extends ServiceEntityRepository implements CanFilterByUserInterface {
use FiltersByCampCollaboration;

public function __construct(ManagerRegistry $registry) {
parent::__construct($registry, Comment::class);
}

public function filterByUser(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, User $user): void {
$rootAlias = $queryBuilder->getRootAliases()[0];

$campsQry = $queryBuilder->getEntityManager()->createQueryBuilder();
$campsQry->select('identity(uc.camp)');
$campsQry->from(UserCamp::class, 'uc');
$campsQry->where('uc.user = :current_user');

$queryBuilder->andWhere(
$queryBuilder->expr()->orX(
"{$rootAlias}.author = :current_user",
$queryBuilder->expr()->in("{$rootAlias}.camp", $campsQry->getDQL())
)
);
$queryBuilder->setParameter('current_user', $user);
}
}
8 changes: 8 additions & 0 deletions api/src/State/ActivityRemoveProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Activity;
use App\Entity\Comment;
use App\State\Util\AbstractRemoveProcessor;
use Doctrine\ORM\EntityManagerInterface;

Expand All @@ -26,5 +27,12 @@ public function onBefore($data, Operation $operation, array $uriVariables = [],
// Deleting rootContentNode would normally be done automatically with orphanRemoval:true
// However, this currently runs into an error due to https://github.com/doctrine-extensions/DoctrineExtensions/issues/2510
$this->em->remove($data->rootContentNode);

/** @var Comment[] $comments */
$comments = $data->comments;
foreach ($comments as $comment) {
$comment->orphanDescription = $comment->activity->title;
$comment->activity->removeComment($comment);
}
}
}
35 changes: 35 additions & 0 deletions api/src/State/CommentCreateProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Comment;
use App\Entity\User;
use App\State\Util\AbstractPersistProcessor;
use Symfony\Bundle\SecurityBundle\Security;

/**
* @template-extends AbstractPersistProcessor<Comment>
*/
class CommentCreateProcessor extends AbstractPersistProcessor {
private Security $security;

public function __construct(ProcessorInterface $decorated, Security $security) {
parent::__construct($decorated);
$this->security = $security;
}

/**
* @param Comment $data
*/
public function onBefore($data, Operation $operation, array $uriVariables = [], array $context = []): Comment {
/** @var User $user */
$user = $this->security->getUser();

// Set the user as the author of the comment
$data->author = $user;

return $data;
}
}
Loading
Loading