Merge branch 'ticket-app/backend-2' into 'ticket-app-master'

Add functionality to add comments to tickets

See merge request Chill-Projet/chill-bundles!681
This commit is contained in:
2024-04-23 21:42:07 +00:00
22 changed files with 798 additions and 7 deletions

View File

@@ -64,3 +64,32 @@ paths:
description: "ACCEPTED"
422:
description: "UNPROCESSABLE ENTITY"
/1.0/ticket/{id}/comment/add:
post:
tags:
- ticket
summary: Add a comment to an existing ticket
parameters:
- name: id
in: path
required: true
description: The ticket id
schema:
type: integer
format: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
content:
type: string
responses:
201:
description: "ACCEPTED"
422:
description: "UNPROCESSABLE ENTITY"

View File

@@ -0,0 +1,25 @@
<?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\Ticket;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation as Serializer;
final readonly class AddCommentCommand
{
public function __construct(
#[Assert\NotBlank()]
#[Assert\NotNull]
#[Serializer\Groups(['write'])]
public ?string $content = null,
) {}
}

View File

@@ -0,0 +1,31 @@
<?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\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\AddCommentCommand;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
final readonly class AddCommentCommandHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function handle(Ticket $ticket, AddCommentCommand $command): void
{
$comment = new Comment($command->content, $ticket);
$this->entityManager->persist($comment);
}
}

View File

@@ -0,0 +1,68 @@
<?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\Ticket\AddCommentCommand;
use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler;
use Chill\TicketBundle\Entity\Ticket;
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 AddCommentController
{
public function __construct(
private Security $security,
private SerializerInterface $serializer,
private ValidatorInterface $validator,
private AddCommentCommandHandler $addCommentCommandHandler,
private EntityManagerInterface $entityManager,
) {}
#[Route('/api/1.0/ticket/{id}/comment/add', name: 'chill_ticket_comment_add', methods: ['POST'])]
public function __invoke(Ticket $ticket, Request $request): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only user can add ticket comments.');
}
$command = $this->serializer->deserialize($request->getContent(), AddCommentCommand::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->addCommentCommandHandler->handle($ticket, $command);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($ticket, 'json', ['groups' => 'read']),
Response::HTTP_CREATED,
[],
true
);
}
}

View File

@@ -59,6 +59,11 @@ final readonly class ReplaceMotiveController
$this->entityManager->flush();
return new JsonResponse(null, Response::HTTP_CREATED);
return new JsonResponse(
$this->serializer->serialize($ticket, 'json', ['groups' => 'read']),
Response::HTTP_CREATED,
[],
true
);
}
}

View File

@@ -17,9 +17,11 @@ use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\JoinColumn;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity()]
#[ORM\Table(name: 'comment', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_comment' => Comment::class])]
class Comment implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
@@ -28,15 +30,19 @@ class Comment implements TrackCreationInterface, TrackUpdateInterface
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
public function __construct(
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
#[Serializer\Groups(['read'])]
private string $content,
#[ORM\ManyToOne(targetEntity: Ticket::class, inversedBy: 'comments')]
#[JoinColumn(nullable: false)]
private Ticket $ticket,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $content = ''
) {}
) {
$ticket->addComment($this);
}
public function getId(): ?int
{

View File

@@ -104,16 +104,27 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
->getValues();
}
/**
* @internal use @see{Comment::__construct} instead
*/
public function addComment(Comment $comment): void
{
$this->comments->add($comment);
}
/**
* Add a PersonHistory.
*
* This method should not be used, use @see{PersonHistory::__construct()} insted.
* @internal use @see{PersonHistory::__construct} instead
*/
public function addPersonHistory(PersonHistory $personHistory): void
{
$this->personHistories->add($personHistory);
}
/**
* @internal use @see{MotiveHistory::__construct} instead
*/
public function addMotiveHistory(MotiveHistory $motiveHistory): void
{
$this->motiveHistories->add($motiveHistory);

View File

@@ -36,10 +36,21 @@ interface MotiveHistory {
createdAt: DateTime|null,
}
interface Comment {
type: "ticket_comment",
id: number,
content: string,
createdBy: User|null,
createdAt: DateTime|null,
updatedBy: User|null,
updatedAt: DateTime|null,
}
interface AddPersonEvent extends TicketHistory<"add_person", PersonHistory> {};
interface AddCommentEvent extends TicketHistory<"add_comment", Comment> {};
interface SetMotiveEvent extends TicketHistory<"set_motive", MotiveHistory> {};
type TicketHistoryLine = AddPersonEvent | SetMotiveEvent;
type TicketHistoryLine = AddPersonEvent | AddCommentEvent | SetMotiveEvent;
export interface Ticket {
type: "ticket_ticket"

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Serializer\Normalizer;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\Ticket;
@@ -69,6 +70,15 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
],
$ticket->getPersonHistories()->toArray(),
),
...array_map(
fn (Comment $comment) => [
'event_type' => 'add_comment',
'at' => $comment->getCreatedAt(),
'by' => $comment->getCreatedBy(),
'data' => $comment,
],
$ticket->getComments()->toArray(),
),
];
usort(

View File

@@ -0,0 +1,52 @@
<?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\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\AddCommentCommand;
use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
/**
* @internal
*
* @coversNothing
*/
class AddCommentCommandHandlerTest extends TestCase
{
use ProphecyTrait;
public function testAddComment(): void
{
$handler = $this->buildCommand();
$ticket = new Ticket();
$command = new AddCommentCommand(content: 'test');
$handler->handle($ticket, $command);
self::assertCount(1, $ticket->getComments());
self::assertEquals('test', $ticket->getComments()[0]->getContent());
}
private function buildCommand(): AddCommentCommandHandler
{
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::type(Comment::class))->shouldBeCalled();
return new AddCommentCommandHandler($entityManager->reveal());
}
}

View File

@@ -0,0 +1,104 @@
<?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\Ticket\Handler\AddCommentCommandHandler;
use Chill\TicketBundle\Controller\AddCommentController;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\Ticket;
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\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @internal
*
* @coversNothing
*/
class AddCommentControllerTest 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 testAddComment(): void
{
$controller = $this->buildController(willFlush: true);
$ticket = new Ticket();
$request = new Request(content: <<<'JSON'
{"content": "test"}
JSON);
$response = $controller->__invoke($ticket, $request);
self::assertEquals(201, $response->getStatusCode());
}
public function testAddCommentWithBlankContent(): void
{
$controller = $this->buildController(willFlush: false);
$ticket = new Ticket();
$request = new Request(content: <<<'JSON'
{"content": ""}
JSON);
$response = $controller->__invoke($ticket, $request);
self::assertEquals(422, $response->getStatusCode());
$request = new Request(content: <<<'JSON'
{"content": null}
JSON);
$response = $controller->__invoke($ticket, $request);
self::assertEquals(422, $response->getStatusCode());
}
private function buildController(bool $willFlush): AddCommentController
{
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
$entityManager = $this->prophesize(EntityManagerInterface::class);
if ($willFlush) {
$entityManager->persist(Argument::type(Comment::class))->shouldBeCalled();
$entityManager->flush()->shouldBeCalled();
}
$commandHandler = new AddCommentCommandHandler($entityManager->reveal());
return new AddCommentController(
$security->reveal(),
$this->serializer,
$this->validator,
$commandHandler,
$entityManager->reveal(),
);
}
}

View File

@@ -11,7 +11,9 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Tests\Serializer\Normalizer;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory;
@@ -93,11 +95,20 @@ class TicketNormalizerTest extends KernelTestCase
// datetime
$normalizer->normalize(Argument::type(\DateTimeImmutable::class), 'json', Argument::type('array'))
->will(function ($args) { return $args[0]->getTimestamp(); });
// user
$normalizer->normalize(Argument::type(User::class), 'json', Argument::type('array'))
->willReturn(['user']);
// motive
$normalizer->normalize(Argument::type(Motive::class), 'json', Argument::type('array'))->willReturn(['type' => 'motive', 'id' => 0]);
// person history
$normalizer->normalize(Argument::type(PersonHistory::class), 'json', Argument::type('array'))
->willReturn(['personHistory']);
// motive history
$normalizer->normalize(Argument::type(MotiveHistory::class), 'json', Argument::type('array'))
->willReturn(['motiveHistory']);
$normalizer->normalize(Argument::type(Comment::class), 'json', Argument::type('array'))
->willReturn(['comment']);
// null values
$normalizer->normalize(null, 'json', Argument::type('array'))->willReturn(null);
$ticketNormalizer = new TicketNormalizer();
@@ -109,6 +120,7 @@ class TicketNormalizerTest extends KernelTestCase
public static function provideTickets(): iterable
{
yield [
// this a nearly empty ticket
new Ticket(),
[
'type' => 'ticket_ticket',
@@ -122,10 +134,14 @@ class TicketNormalizerTest extends KernelTestCase
],
];
// ticket with more features
$ticket = new Ticket();
$ticket->setExternalRef('2134');
$personHistory = new PersonHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00'));
$ticketHistory = new MotiveHistory(new Motive(), $ticket, new \DateTimeImmutable('2024-04-01T12:02:00'));
$comment = new Comment('blabla test', $ticket);
$comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00'));
$comment->setCreatedBy(new User());
yield [
$ticket,
@@ -137,7 +153,7 @@ class TicketNormalizerTest extends KernelTestCase
'currentAddressees' => [],
'currentInputs' => [],
'currentMotive' => ['type' => 'motive', 'id' => 0],
'history' => [['event_type' => 'add_person'], ['event_type' => 'set_motive']],
'history' => [['event_type' => 'add_person'], ['event_type' => 'set_motive'], ['event_type' => 'add_comment']],
],
];
}