mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-08-01 21:47:44 +00:00
Tickets: edit comments and mark them as deleted
This commit is contained in:
parent
568c8be7fd
commit
3400656d7c
@ -79,7 +79,7 @@
|
||||
"dev": "encore dev",
|
||||
"watch": "encore dev --watch",
|
||||
"build": "encore production --progress",
|
||||
"specs-build": "yaml-merge src/Bundle/ChillMainBundle/chill.api.specs.yaml src/Bundle/ChillPersonBundle/chill.api.specs.yaml src/Bundle/ChillCalendarBundle/chill.api.specs.yaml src/Bundle/ChillThirdPartyBundle/chill.api.specs.yaml src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml> templates/api/specs.yaml",
|
||||
"specs-build": "yaml-merge src/Bundle/ChillMainBundle/chill.api.specs.yaml src/Bundle/ChillPersonBundle/chill.api.specs.yaml src/Bundle/ChillCalendarBundle/chill.api.specs.yaml src/Bundle/ChillThirdPartyBundle/chill.api.specs.yaml src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml src/Bundle/ChillTicketBundle/chill.api.specs.yaml> templates/api/specs.yaml",
|
||||
"specs-validate": "swagger-cli validate templates/api/specs.yaml",
|
||||
"specs-create-dir": "mkdir -p templates/api",
|
||||
"specs": "yarn run specs-create-dir && yarn run specs-build && yarn run specs-validate",
|
||||
|
@ -1,5 +1,10 @@
|
||||
components:
|
||||
schemas:
|
||||
TicketComment:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
TicketSimple:
|
||||
type: object
|
||||
properties:
|
||||
@ -162,6 +167,101 @@ paths:
|
||||
description: "ACCEPTED"
|
||||
422:
|
||||
description: "UNPROCESSABLE ENTITY"
|
||||
/1.0/ticket/comment/{id}/edit:
|
||||
post:
|
||||
tags:
|
||||
- ticket
|
||||
summary: Edit the comment' content
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: The comment's id
|
||||
schema:
|
||||
type: integer
|
||||
format: integer
|
||||
minimum: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TicketComment'
|
||||
403:
|
||||
description: "Unauthorized"
|
||||
|
||||
/1.0/ticket/comment/{id}/delete:
|
||||
post:
|
||||
tags:
|
||||
- ticket
|
||||
summary: Soft-delete a comment within a ticket
|
||||
description: |
|
||||
This will soft delete a comment within a ticket.
|
||||
|
||||
Only the author of the comment is allowed to edit the comment.
|
||||
|
||||
If a comment is deleted, it can be restored by using the "restore" call.
|
||||
|
||||
The method is idempotent: it will have no effect on an already deleted comment.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: The comment's id
|
||||
schema:
|
||||
type: integer
|
||||
format: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TicketComment'
|
||||
403:
|
||||
description: "Unauthorized"
|
||||
|
||||
/1.0/ticket/comment/{id}/restore:
|
||||
post:
|
||||
tags:
|
||||
- ticket
|
||||
summary: Restore a comment within a ticket
|
||||
description: |
|
||||
This will restore a comment of a ticket.
|
||||
|
||||
Only the author of the comment is allowed to restore the comment.
|
||||
|
||||
If the comment is not deleted, this method has no effect.
|
||||
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: The comment's id
|
||||
schema:
|
||||
type: integer
|
||||
format: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TicketComment'
|
||||
403:
|
||||
description: "Unauthorized"
|
||||
|
||||
/1.0/ticket/{id}/persons/set:
|
||||
post:
|
||||
|
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Action\Comment\Handler;
|
||||
|
||||
use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
|
||||
final readonly class UpdateCommentContentCommandHandler implements UpdateCommentContentCommandHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
) {}
|
||||
|
||||
public function handle(Comment $comment, UpdateCommentContentCommand $command): void
|
||||
{
|
||||
$comment->setContent($command->content);
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Action\Comment\Handler;
|
||||
|
||||
use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
|
||||
interface UpdateCommentContentCommandHandlerInterface
|
||||
{
|
||||
public function handle(Comment $comment, UpdateCommentContentCommand $command): void;
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Action\Comment\Handler;
|
||||
|
||||
use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
|
||||
final readonly class UpdateCommentDeletedStatusCommandHandler implements UpdateCommentDeletedStatusCommandHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
) {}
|
||||
|
||||
public function handle(Comment $comment, UpdateCommentDeletedStatusCommand $command): void
|
||||
{
|
||||
$comment->setDeleted($command->delete);
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Action\Comment\Handler;
|
||||
|
||||
use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
|
||||
interface UpdateCommentDeletedStatusCommandHandlerInterface
|
||||
{
|
||||
public function handle(Comment $comment, UpdateCommentDeletedStatusCommand $command): void;
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Action\Comment;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||
|
||||
final readonly class UpdateCommentContentCommand
|
||||
{
|
||||
public function __construct(
|
||||
/**
|
||||
* The new content of the command.
|
||||
*
|
||||
* The command accept null values, but it should raise a validation error.
|
||||
*/
|
||||
#[Assert\NotBlank()]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Groups(['write'])]
|
||||
public ?string $content,
|
||||
) {}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Action\Comment;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||
|
||||
final readonly class UpdateCommentDeletedStatusCommand
|
||||
{
|
||||
public function __construct(
|
||||
/**
|
||||
* The deleted status to set for the comment.
|
||||
*/
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Groups(['write'])]
|
||||
public bool $delete,
|
||||
) {}
|
||||
}
|
@ -22,10 +22,12 @@ final readonly class AddCommentCommandHandler
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function handle(Ticket $ticket, AddCommentCommand $command): void
|
||||
public function handle(Ticket $ticket, AddCommentCommand $command): Comment
|
||||
{
|
||||
$comment = new Comment($command->content, $ticket);
|
||||
|
||||
$this->entityManager->persist($comment);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ namespace Chill\TicketBundle\Controller;
|
||||
use Chill\TicketBundle\Action\Ticket\AddCommentCommand;
|
||||
use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler;
|
||||
use Chill\TicketBundle\Entity\Ticket;
|
||||
use Chill\TicketBundle\Security\Voter\CommentVoter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@ -54,7 +55,11 @@ final readonly class AddCommentController
|
||||
);
|
||||
}
|
||||
|
||||
$this->addCommentCommandHandler->handle($ticket, $command);
|
||||
$comment = $this->addCommentCommandHandler->handle($ticket, $command);
|
||||
|
||||
if (!$this->security->isGranted(CommentVoter::CREATE, $comment)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to add comments to this ticket.');
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
|
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Controller;
|
||||
|
||||
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentContentCommandHandlerInterface;
|
||||
use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
use Chill\TicketBundle\Security\Voter\CommentVoter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
final readonly class UpdateCommentController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private SerializerInterface $serializer,
|
||||
private ValidatorInterface $validator,
|
||||
private UpdateCommentContentCommandHandlerInterface $updateCommentContentCommandHandler,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/api/1.0/ticket/comment/{id}/edit', name: 'chill_ticket_comment_edit', methods: ['POST'])]
|
||||
public function __invoke(Comment $comment, Request $request): Response
|
||||
{
|
||||
if (!$this->security->isGranted(CommentVoter::EDIT, $comment)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to edit this comment.');
|
||||
}
|
||||
|
||||
$command = $this->serializer->deserialize($request->getContent(), UpdateCommentContentCommand::class, 'json', ['groups' => 'write']);
|
||||
|
||||
$errors = $this->validator->validate($command);
|
||||
|
||||
if (count($errors) > 0) {
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($errors, 'json'),
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
[],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
$this->updateCommentContentCommandHandler->handle($comment, $command);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($comment, 'json', ['groups' => 'read']),
|
||||
Response::HTTP_OK,
|
||||
[],
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Controller;
|
||||
|
||||
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentDeletedStatusCommandHandlerInterface;
|
||||
use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
use Chill\TicketBundle\Security\Voter\CommentVoter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
final readonly class UpdateCommentDeletedStatusController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private SerializerInterface $serializer,
|
||||
private UpdateCommentDeletedStatusCommandHandlerInterface $updateCommentDeletedStatusCommandHandler,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/api/1.0/ticket/comment/{id}/delete', name: 'chill_ticket_comment_delete', methods: ['POST'])]
|
||||
public function deleteComment(Comment $comment): Response
|
||||
{
|
||||
return $this->updateCommentDeletedStatus($comment, true);
|
||||
}
|
||||
|
||||
#[Route('/api/1.0/ticket/comment/{id}/restore', name: 'chill_ticket_comment_restore', methods: ['POST'])]
|
||||
public function restoreComment(Comment $comment): Response
|
||||
{
|
||||
return $this->updateCommentDeletedStatus($comment, false);
|
||||
}
|
||||
|
||||
private function updateCommentDeletedStatus(Comment $comment, bool $delete): Response
|
||||
{
|
||||
if (!$this->security->isGranted(CommentVoter::EDIT, $comment)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to edit this comment.');
|
||||
}
|
||||
|
||||
$command = new UpdateCommentDeletedStatusCommand(delete: $delete);
|
||||
|
||||
$this->updateCommentDeletedStatusCommandHandler->handle($comment, $command);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($comment, 'json', ['groups' => 'read']),
|
||||
Response::HTTP_OK,
|
||||
[],
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
@ -38,6 +38,10 @@ class Comment implements TrackCreationInterface, TrackUpdateInterface
|
||||
#[Serializer\Groups(['read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
#[Serializer\Groups(['read'])]
|
||||
private bool $deleted = false;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
||||
#[Serializer\Groups(['read'])]
|
||||
@ -63,4 +67,19 @@ class Comment implements TrackCreationInterface, TrackUpdateInterface
|
||||
{
|
||||
return $this->ticket;
|
||||
}
|
||||
|
||||
public function setContent(string $content): void
|
||||
{
|
||||
$this->content = $content;
|
||||
}
|
||||
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return $this->deleted;
|
||||
}
|
||||
|
||||
public function setDeleted(bool $deleted): void
|
||||
{
|
||||
$this->deleted = $deleted;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Security\Voter;
|
||||
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* Check permissions on comments.
|
||||
*
|
||||
* - Adding a comment to the ticket requires to be allowed to write on the ticket;
|
||||
* - Only the user who created the comment is allowed to edit it;
|
||||
*/
|
||||
final class CommentVoter extends Voter
|
||||
{
|
||||
public const CREATE = 'CHILL_TICKET_COMMENT_CREATE';
|
||||
public const EDIT = 'CHILL_TICKET_COMMENT_EDIT';
|
||||
public const READ = 'CHILL_TICKET_COMMENT_READ';
|
||||
|
||||
private const ALL = [self::CREATE, self::EDIT, self::READ];
|
||||
|
||||
public function __construct(private readonly AccessDecisionManagerInterface $decisionManager) {}
|
||||
|
||||
protected function supports(string $attribute, $subject): bool
|
||||
{
|
||||
return $subject instanceof Comment && in_array($attribute, self::ALL, true);
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
{
|
||||
/* @var Comment $subject */
|
||||
return match ($attribute) {
|
||||
self::READ => !$subject->isDeleted() || $subject->getCreatedBy() === $token->getUser(),
|
||||
self::CREATE => $this->decisionManager->decide($token, [TicketVoter::WRITE], $subject->getTicket()),
|
||||
self::EDIT => $token->getUser() === $subject->getCreatedBy(),
|
||||
default => throw new \Symfony\Component\Security\Core\Exception\LogicException('Invalid attribute'),
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Security\Voter;
|
||||
|
||||
use Chill\TicketBundle\Entity\Ticket;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* Check permission on Ticket.
|
||||
*/
|
||||
final class TicketVoter extends Voter
|
||||
{
|
||||
public const WRITE = 'CHILL_TICKET_TICKET_WRITE';
|
||||
public const READ = 'CHILL_TICKET_TICKET_READ';
|
||||
|
||||
private const ALL = [self::WRITE, self::READ];
|
||||
|
||||
protected function supports(string $attribute, $subject): bool
|
||||
{
|
||||
return $subject instanceof Ticket && in_array($attribute, self::ALL, true);
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
@ -22,6 +22,8 @@ use Chill\TicketBundle\Entity\MotiveHistory;
|
||||
use Chill\TicketBundle\Entity\PersonHistory;
|
||||
use Chill\TicketBundle\Entity\StateHistory;
|
||||
use Chill\TicketBundle\Entity\Ticket;
|
||||
use Chill\TicketBundle\Security\Voter\CommentVoter;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
@ -31,6 +33,8 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
|
||||
{
|
||||
use NormalizerAwareTrait;
|
||||
|
||||
public function __construct(private Security $security) {}
|
||||
|
||||
public function normalize($object, ?string $format = null, array $context = [])
|
||||
{
|
||||
if (!$object instanceof Ticket) {
|
||||
@ -114,7 +118,7 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
|
||||
'by' => $comment->getCreatedBy(),
|
||||
'data' => $comment,
|
||||
],
|
||||
$ticket->getComments()->toArray(),
|
||||
$ticket->getComments()->filter(fn (Comment $comment) => $this->security->isGranted(CommentVoter::READ, $comment))->toArray(),
|
||||
),
|
||||
...$this->addresseesStates($ticket),
|
||||
...$this->personStates($ticket),
|
||||
|
@ -6,6 +6,9 @@ services:
|
||||
Chill\TicketBundle\Action\Ticket\Handler\:
|
||||
resource: '../Action/Ticket/Handler/'
|
||||
|
||||
Chill\TicketBundle\Action\Comment\Handler\:
|
||||
resource: '../Action/Comment/Handler/'
|
||||
|
||||
Chill\TicketBundle\Controller\:
|
||||
resource: '../Controller/'
|
||||
tags:
|
||||
@ -14,6 +17,9 @@ services:
|
||||
Chill\TicketBundle\Repository\:
|
||||
resource: '../Repository/'
|
||||
|
||||
Chill\TicketBundle\Security\Voter\:
|
||||
resource: '../Security/Voter/'
|
||||
|
||||
Chill\TicketBundle\Serializer\:
|
||||
resource: '../Serializer/'
|
||||
|
||||
|
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\Migrations\Ticket;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20250711115128 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add deleted column to comment table to support soft deletion of comments';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE chill_ticket.comment ADD deleted BOOLEAN DEFAULT false NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE chill_ticket.comment DROP deleted');
|
||||
}
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Tests\Action\Comment\Handler;
|
||||
|
||||
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentContentCommandHandler;
|
||||
use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
use Chill\TicketBundle\Entity\Ticket;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class UpdateCommentContentCommandHandlerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testUpdateCommentContent(): void
|
||||
{
|
||||
$handler = $this->buildCommand();
|
||||
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('initial content', $ticket);
|
||||
$command = new UpdateCommentContentCommand(content: 'updated content');
|
||||
|
||||
$handler->handle($comment, $command);
|
||||
|
||||
self::assertEquals('updated content', $comment->getContent());
|
||||
}
|
||||
|
||||
private function buildCommand(): UpdateCommentContentCommandHandler
|
||||
{
|
||||
$entityManager = $this->prophesize(EntityManagerInterface::class);
|
||||
|
||||
return new UpdateCommentContentCommandHandler($entityManager->reveal());
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Tests\Action\Comment\Handler;
|
||||
|
||||
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentDeletedStatusCommandHandler;
|
||||
use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
use Chill\TicketBundle\Entity\Ticket;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class UpdateCommentDeletedStatusCommandHandlerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testUpdateCommentDeletedStatus(): void
|
||||
{
|
||||
$handler = $this->buildCommand();
|
||||
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('content', $ticket);
|
||||
|
||||
// Initially, the comment should not be deleted
|
||||
self::assertFalse($comment->isDeleted());
|
||||
|
||||
// Test setting deleted to true
|
||||
$command = new UpdateCommentDeletedStatusCommand(delete: true);
|
||||
$handler->handle($comment, $command);
|
||||
self::assertTrue($comment->isDeleted());
|
||||
|
||||
// Test setting deleted back to false
|
||||
$command = new UpdateCommentDeletedStatusCommand(delete: false);
|
||||
$handler->handle($comment, $command);
|
||||
self::assertFalse($comment->isDeleted());
|
||||
}
|
||||
|
||||
private function buildCommand(): UpdateCommentDeletedStatusCommandHandler
|
||||
{
|
||||
return new UpdateCommentDeletedStatusCommandHandler();
|
||||
}
|
||||
}
|
@ -15,6 +15,7 @@ use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler;
|
||||
use Chill\TicketBundle\Controller\AddCommentController;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
use Chill\TicketBundle\Entity\Ticket;
|
||||
use Chill\TicketBundle\Security\Voter\CommentVoter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
@ -87,6 +88,7 @@ class AddCommentControllerTest extends KernelTestCase
|
||||
$entityManager = $this->prophesize(EntityManagerInterface::class);
|
||||
|
||||
if ($willFlush) {
|
||||
$security->isGranted(CommentVoter::CREATE, Argument::type(Comment::class))->willReturn(true);
|
||||
$entityManager->persist(Argument::type(Comment::class))->shouldBeCalled();
|
||||
$entityManager->flush()->shouldBeCalled();
|
||||
}
|
||||
|
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Tests\Controller;
|
||||
|
||||
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentContentCommandHandlerInterface;
|
||||
use Chill\TicketBundle\Controller\UpdateCommentController;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
use Chill\TicketBundle\Entity\Ticket;
|
||||
use Chill\TicketBundle\Security\Voter\CommentVoter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class UpdateCommentControllerTest extends KernelTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private SerializerInterface $serializer;
|
||||
private ValidatorInterface $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->validator = self::getContainer()->get(ValidatorInterface::class);
|
||||
$this->serializer = self::getContainer()->get(SerializerInterface::class);
|
||||
}
|
||||
|
||||
public function testUpdateComment(): void
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('initial content', $ticket);
|
||||
$controller = $this->buildController(willFlush: true, isGranted: true, comment: $comment);
|
||||
|
||||
$request = new Request(content: <<<'JSON'
|
||||
{"content": "updated content"}
|
||||
JSON);
|
||||
|
||||
$response = $controller->__invoke($comment, $request);
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testUpdateCommentWithBlankContent(): void
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('initial content', $ticket);
|
||||
$controller = $this->buildController(willFlush: false, isGranted: true, comment: $comment);
|
||||
|
||||
$request = new Request(content: <<<'JSON'
|
||||
{"content": ""}
|
||||
JSON);
|
||||
|
||||
$response = $controller->__invoke($comment, $request);
|
||||
|
||||
self::assertEquals(422, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testUpdateCommentWithNullContent(): void
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('initial content', $ticket);
|
||||
$controller = $this->buildController(willFlush: false, isGranted: true, comment: $comment);
|
||||
|
||||
$request = new Request(content: <<<'JSON'
|
||||
{"content": null}
|
||||
JSON);
|
||||
|
||||
$response = $controller->__invoke($comment, $request);
|
||||
|
||||
self::assertEquals(422, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testUpdateCommentWithoutAuthorization(): void
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('initial content', $ticket);
|
||||
$controller = $this->buildController(willFlush: false, isGranted: false, comment: $comment);
|
||||
|
||||
$request = new Request(content: <<<'JSON'
|
||||
{"content": "updated content"}
|
||||
JSON);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('You are not allowed to edit this comment.');
|
||||
|
||||
$controller->__invoke($comment, $request);
|
||||
}
|
||||
|
||||
private function buildController(bool $willFlush, bool $isGranted, Comment $comment): UpdateCommentController
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted(CommentVoter::EDIT, $comment)->willReturn($isGranted);
|
||||
|
||||
$entityManager = $this->prophesize(EntityManagerInterface::class);
|
||||
|
||||
if ($willFlush) {
|
||||
$entityManager->flush()->shouldBeCalled();
|
||||
}
|
||||
|
||||
$commandHandler = $this->prophesize(UpdateCommentContentCommandHandlerInterface::class);
|
||||
|
||||
if ($isGranted && $willFlush) {
|
||||
$commandHandler->handle($comment, Argument::any())->shouldBeCalled();
|
||||
}
|
||||
|
||||
return new UpdateCommentController(
|
||||
$security->reveal(),
|
||||
$this->serializer,
|
||||
$this->validator,
|
||||
$commandHandler->reveal(),
|
||||
$entityManager->reveal(),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\TicketBundle\Tests\Controller;
|
||||
|
||||
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentDeletedStatusCommandHandlerInterface;
|
||||
use Chill\TicketBundle\Controller\UpdateCommentDeletedStatusController;
|
||||
use Chill\TicketBundle\Entity\Comment;
|
||||
use Chill\TicketBundle\Entity\Ticket;
|
||||
use Chill\TicketBundle\Security\Voter\CommentVoter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class UpdateCommentDeletedStatusControllerTest extends KernelTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private SerializerInterface $serializer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->serializer = self::getContainer()->get(SerializerInterface::class);
|
||||
}
|
||||
|
||||
public function testDeleteComment(): void
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('content', $ticket);
|
||||
$controller = $this->buildController(willFlush: true, isGranted: true, comment: $comment);
|
||||
|
||||
$response = $controller->deleteComment($comment);
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testRestoreComment(): void
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('content', $ticket);
|
||||
$controller = $this->buildController(willFlush: true, isGranted: true, comment: $comment);
|
||||
|
||||
$response = $controller->restoreComment($comment);
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testDeleteCommentWithoutAuthorization(): void
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('content', $ticket);
|
||||
$controller = $this->buildController(willFlush: false, isGranted: false, comment: $comment);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('You are not allowed to edit this comment.');
|
||||
|
||||
$controller->deleteComment($comment);
|
||||
}
|
||||
|
||||
public function testRestoreCommentWithoutAuthorization(): void
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('content', $ticket);
|
||||
$controller = $this->buildController(willFlush: false, isGranted: false, comment: $comment);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$this->expectExceptionMessage('You are not allowed to edit this comment.');
|
||||
|
||||
$controller->restoreComment($comment);
|
||||
}
|
||||
|
||||
private function buildController(bool $willFlush, bool $isGranted, Comment $comment): UpdateCommentDeletedStatusController
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted(CommentVoter::EDIT, $comment)->willReturn($isGranted);
|
||||
|
||||
$entityManager = $this->prophesize(EntityManagerInterface::class);
|
||||
|
||||
if ($willFlush) {
|
||||
$entityManager->flush()->shouldBeCalled();
|
||||
}
|
||||
|
||||
$commandHandler = $this->prophesize(UpdateCommentDeletedStatusCommandHandlerInterface::class);
|
||||
|
||||
if ($isGranted && $willFlush) {
|
||||
$commandHandler->handle($comment, Argument::any())->shouldBeCalled();
|
||||
}
|
||||
|
||||
return new UpdateCommentDeletedStatusController(
|
||||
$security->reveal(),
|
||||
$this->serializer,
|
||||
$commandHandler->reveal(),
|
||||
$entityManager->reveal(),
|
||||
);
|
||||
}
|
||||
}
|
@ -25,10 +25,13 @@ use Chill\TicketBundle\Entity\PersonHistory;
|
||||
use Chill\TicketBundle\Entity\StateEnum;
|
||||
use Chill\TicketBundle\Entity\StateHistory;
|
||||
use Chill\TicketBundle\Entity\Ticket;
|
||||
use Chill\TicketBundle\Security\Voter\CommentVoter;
|
||||
use Chill\TicketBundle\Serializer\Normalizer\TicketNormalizer;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
/**
|
||||
@ -40,6 +43,13 @@ class TicketNormalizerTest extends KernelTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private ObjectProphecy $security;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->security = $this->prophesize(Security::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideTickets
|
||||
*/
|
||||
@ -192,6 +202,35 @@ class TicketNormalizerTest extends KernelTestCase
|
||||
];
|
||||
}
|
||||
|
||||
public function testNormalizeTicketWithCommentNotAllowed(): void
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('Test comment', $ticket);
|
||||
$comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00'));
|
||||
$comment->setCreatedBy(new User());
|
||||
|
||||
$this->security->isGranted(CommentVoter::READ, $comment)->willReturn(false)->shouldBeCalled();
|
||||
|
||||
$actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => ['read']]);
|
||||
|
||||
self::assertEmpty($actual['history']);
|
||||
}
|
||||
|
||||
public function testNormalizeTicketWithCommentAllowed(): void
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$comment = new Comment('Test comment', $ticket);
|
||||
$comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00'));
|
||||
$comment->setCreatedBy(new User());
|
||||
|
||||
$this->security->isGranted(CommentVoter::READ, $comment)->willReturn(true)->shouldBeCalled();
|
||||
|
||||
$actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => ['read']]);
|
||||
|
||||
self::assertCount(1, $actual['history']);
|
||||
self::assertEquals('add_comment', $actual['history'][0]['event_type']);
|
||||
}
|
||||
|
||||
public function testNormalizeReadSimple(): void
|
||||
{
|
||||
// Create a ticket with some data
|
||||
@ -274,6 +313,10 @@ class TicketNormalizerTest extends KernelTestCase
|
||||
|
||||
private function buildNormalizer(): TicketNormalizer
|
||||
{
|
||||
$this->security->isGranted(CommentVoter::READ, Argument::type(Comment::class))
|
||||
->willReturn(true);
|
||||
|
||||
|
||||
$normalizer = $this->prophesize(NormalizerInterface::class);
|
||||
|
||||
// empty array
|
||||
@ -285,14 +328,14 @@ class TicketNormalizerTest extends KernelTestCase
|
||||
|
||||
// array of mixed objects
|
||||
$normalizer->normalize(
|
||||
Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_object($arg[0])),
|
||||
Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_object($arg[0] ?? null)),
|
||||
'json',
|
||||
Argument::type('array')
|
||||
)->will(fn ($args) => array_fill(0, count($args[0]), 'embedded'));
|
||||
|
||||
// array of event type
|
||||
$normalizer->normalize(
|
||||
Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_array($arg[0]) && array_key_exists('event_type', $arg[0])),
|
||||
Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_array($arg[0] ?? null) && array_key_exists('event_type', $arg[0] ?? null)),
|
||||
'json',
|
||||
Argument::type('array')
|
||||
)->will(function ($args): array {
|
||||
@ -357,7 +400,7 @@ class TicketNormalizerTest extends KernelTestCase
|
||||
// null values
|
||||
$normalizer->normalize(null, 'json', Argument::type('array'))->willReturn(null);
|
||||
|
||||
$ticketNormalizer = new TicketNormalizer();
|
||||
$ticketNormalizer = new TicketNormalizer($this->security->reveal());
|
||||
$ticketNormalizer->setNormalizer($normalizer->reveal());
|
||||
|
||||
return $ticketNormalizer;
|
||||
|
Loading…
x
Reference in New Issue
Block a user