diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index 8d204803f..326918c61 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -10,6 +10,31 @@ servers: components: schemas: + Collection: + type: object + properties: + count: + type: number + format: u64 + pagination: + type: object + properties: + first: + type: number + format: u64 + items_per_page: + type: number + format: u64 + next: + type: string + format: uri + nullable: true + previous: + type: string + format: uri + nullable: true + more: + type: boolean EntityWorkflowAttachment: type: object properties: diff --git a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml index 3e40cab66..c9f1aacea 100644 --- a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -1,5 +1,10 @@ components: schemas: + TicketSimple: + type: object + properties: + id: + type: integer Motive: type: object properties: @@ -44,6 +49,40 @@ paths: responses: 200: description: "OK" + /1.0/ticket/ticket/list: + get: + tags: + - ticket + summary: List of tickets + parameters: + - name: byPerson + in: query + description: the id of the person + required: false + style: form + explode: false + schema: + type: array + items: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: OK + content: + application/json: + schema: + allOf: + - $ref: '#components/schemas/Collection' + - type: object + properties: + results: + type: array + items: + $ref: '#component/schema/TicketSimple' + + /1.0/ticket/motive.json: get: tags: diff --git a/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php b/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php index 36cde311a..aa9b16a59 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php @@ -20,7 +20,7 @@ class TicketControllerApi { public function __construct(private readonly SerializerInterface $serializer) {} - #[Route('/api/1.0/ticket/ticket/{id}', methods: ['GET'])] + #[Route('/api/1.0/ticket/ticket/{id}', requirements: ['id' => '\d+'], methods: ['GET'])] public function get(Ticket $ticket): JsonResponse { return new JsonResponse( diff --git a/src/Bundle/ChillTicketBundle/src/Controller/TicketListApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/TicketListApiController.php new file mode 100644 index 000000000..8c9a90ab5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/TicketListApiController.php @@ -0,0 +1,68 @@ +security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to list tickets.'); + } + + $params = []; + + if ($request->query->has('byPerson')) { + $params = explode(',', $request->query->get('byPerson')); + foreach ($params as $id) { + $params['byPerson'][] = $person = $this->personRepository->find($id); + + if (!$this->security->isGranted(PersonVoter::SEE, $person)) { + throw new AccessDeniedHttpException(sprintf('Not allowed to see a person with id %d', $id)); + } + } + } + + $nb = $this->ticketRepository->countTickets($params); + $paginator = $this->paginatorFactory->create($nb); + + $tickets = $this->ticketRepository->findTickets($params, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage()); + + $collection = new Collection($tickets, $paginator); + + return new JsonResponse( + $this->serializer->serialize($collection, 'json', ['groups' => 'read:simple']), + json: true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php new file mode 100644 index 000000000..634f55214 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php @@ -0,0 +1,63 @@ +buildQuery($params)->select('t')->getQuery()->setFirstResult($start) + ->setMaxResults($limit)->getResult(); + } + + public function countTickets(array $params): int + { + return $this->buildQuery($params)->select('COUNT(t)')->getQuery()->getSingleScalarResult(); + } + + private function buildQuery(array $params): QueryBuilder + { + $qb = $this->em->createQueryBuilder(); + $qb->from(Ticket::class, 't'); + // counter for all the loops + $i = 0; + + if (array_key_exists('byPerson', $params)) { + $or = $qb->expr()->orX(); + + foreach ($params['byPerson'] as $person) { + $or->add( + $qb->expr()->exists(sprintf( + 'SELECT 1 FROM %s tp_%d WHERE tp_%d.ticket = t AND tp_%d.person = :person_%d AND tp_%d.endDate IS NULL', + PersonHistory::class, + ++$i, + $i, + $i, + $i, + $i, + )) + ); + $qb->setParameter(sprintf('person_%d', $i), $person); + } + $qb->andWhere($or); + } + + return $qb; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepositoryInterface.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepositoryInterface.php new file mode 100644 index 000000000..e946c1ded --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepositoryInterface.php @@ -0,0 +1,37 @@ +} $params + * + * @return list + */ + public function findTickets(array $params, int $start = 0, int $limit = 100): array; + + /** + * Find tickets. + * + * @param array{byPerson?: list} $params + */ + public function countTickets(array $params): int; +} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts index c048121cf..4a244e9a4 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts @@ -5,6 +5,7 @@ import { UserGroupOrUser, } from "ChillMainAssets/types"; import { Person } from "ChillPersonAssets/types"; +import {Thirdparty} from "../../../../ChillThirdPartyBundle/Resources/public/types"; export interface Motive { type: "ticket_motive"; @@ -122,17 +123,29 @@ export type TicketHistoryLine = | EmergencyStateEvent | CallerStateEvent; -export interface Ticket { +interface BaseTicket { + type_extended: T; +} + +export interface TicketSimple extends BaseTicket<"ticket_ticket:simple"> { type: "ticket_ticket"; + type_extended: "ticket_ticket:simple"; id: number; externalRef: string; currentAddressees: UserGroupOrUser[]; currentPersons: Person[]; currentMotive: null | Motive; - history: TicketHistoryLine[]; - createdAt: DateTime | null; - updatedBy: User | null; currentState: TicketState | null; emergency: TicketEmergencyState | null; + caller: Person | Thirdparty | null; +} + +export interface Ticket extends BaseTicket<"ticket_ticket:extended"> { + type_extended: "ticket_ticket:extended"; + createdAt: DateTime | null; + createdBy: DateTime | null; + updatedBy: User | null; + updatedAt: DateTime | null; + history: TicketHistoryLine[]; caller: Person | null; } diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php index b10a0873d..61cbb9461 100644 --- a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php @@ -37,7 +37,7 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte throw new UnexpectedValueException(); } - return [ + $data = [ 'type' => 'ticket_ticket', 'id' => $object->getId(), 'externalRef' => $object->getExternalRef(), @@ -47,15 +47,27 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte 'currentAddressees' => $this->normalizer->normalize($object->getCurrentAddressee(), $format, ['groups' => 'read']), 'currentInputs' => $this->normalizer->normalize($object->getCurrentInputs(), $format, ['groups' => 'read']), 'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => 'read']), + 'currentState' => $object->getState()?->value ?? 'open', + 'emergency' => $object->getEmergencyStatus()?->value ?? 'no', + 'caller' => $this->normalizer->normalize($object->getCaller(), $format, ['groups' => 'read']), + ]; + + if ('read:simple' === $context['groups']) { + $data += ['type_extended' => 'ticket_ticket:simple']; + + return $data; + } + + $data += [ + 'type_extended' => 'ticket_ticket:extended', 'history' => array_values($this->serializeHistory($object, $format, ['groups' => 'read'])), 'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context), 'updatedAt' => $this->normalizer->normalize($object->getUpdatedAt(), $format, $context), 'updatedBy' => $this->normalizer->normalize($object->getUpdatedBy(), $format, $context), 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context), - 'currentState' => $object->getState()?->value ?? 'open', - 'emergency' => $object->getEmergencyStatus()?->value ?? 'no', - 'caller' => $this->normalizer->normalize($object->getCaller(), $format, ['groups' => 'read']), ]; + + return $data; } public function supportsNormalization($data, ?string $format = null) diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerTest.php new file mode 100644 index 000000000..f75ec9925 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerTest.php @@ -0,0 +1,213 @@ +prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets([])->willReturn(2); + $ticketRepository->findTickets([], 0, 10)->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn(Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal() + ); + + // Create request + $request = new Request(); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithPersonFilter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $person = new Person(); + $security->isGranted(PersonVoter::SEE, $person)->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets(Argument::that(fn($params) => isset($params['byPerson']) && in_array($person, $params['byPerson'])))->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn($params) => isset($params['byPerson']) && in_array($person, $params['byPerson'])), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn(Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $personRepository->find(123)->willReturn($person); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal() + ); + + // Create request with person filter + $request = new Request( + query: ['byPerson' => '123'] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithoutUserRole(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal() + ); + + // Create request + $request = new Request(); + + // Expect exception + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('Only users are allowed to list tickets.'); + + // Call controller method + $controller->listTicket($request); + } + + public function testListTicketWithPersonWithoutAccess(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $person = new Person(); + $security->isGranted(PersonVoter::SEE, $person)->willReturn(false); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $personRepository = $this->prophesize(PersonRepository::class); + $personRepository->find(123)->willReturn($person); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal() + ); + + // Create request with person filter + $request = new Request( + query: ['byPerson' => '123'] + ); + + // Expect exception + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('Not allowed to see a person with id 123'); + + // Call controller method + $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Repository/TicketACLAwareRepositoryTest.php b/src/Bundle/ChillTicketBundle/tests/Repository/TicketACLAwareRepositoryTest.php new file mode 100644 index 000000000..4dac71bb1 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Repository/TicketACLAwareRepositoryTest.php @@ -0,0 +1,66 @@ +entityManager = self::getContainer()->get(EntityManagerInterface::class); + $this->repository = new TicketACLAwareRepository($this->entityManager); + } + + public function testFindNoParameters(): void + { + // Test the findTickets method with byPerson parameter + $actual = $this->repository->findTickets([]); + + // Only verify that the query executes successfully without checking results + self::assertIsList($actual); + } + + public function testFindTicketByPerson(): void + { + $person = $this->getRandomPerson($this->entityManager); + + // Test the findTickets method with byPerson parameter + $actual = $this->repository->findTickets(['byPerson' => [$person]]); + + // Only verify that the query executes successfully without checking results + self::assertIsList($actual); + } + + public function testCountTicketsByPerson(): void + { + $person = $this->getRandomPerson($this->entityManager); + + $result = $this->repository->countTickets(['byPerson' => [$person]]); + + self::assertIsInt($result); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php index 2e3de468d..bda2fd485 100644 --- a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php @@ -81,6 +81,7 @@ class TicketNormalizerTest extends KernelTestCase $t, [ 'type' => 'ticket_ticket', + 'type_extended' => 'ticket_ticket:extended', 'createdAt' => $t->getCreatedAt()?->getTimestamp(), 'createdBy' => ['user'], 'id' => null, @@ -131,6 +132,7 @@ class TicketNormalizerTest extends KernelTestCase $ticket, [ 'type' => 'ticket_ticket', + 'type_extended' => 'ticket_ticket:extended', 'createdAt' => $ticket->getCreatedAt()?->getTimestamp(), 'createdBy' => ['user'], 'id' => null, @@ -169,6 +171,7 @@ class TicketNormalizerTest extends KernelTestCase $ticket, [ 'type' => 'ticket_ticket', + 'type_extended' => 'ticket_ticket:extended', 'createdAt' => $ticket->getCreatedAt()?->getTimestamp(), 'createdBy' => null, 'id' => null, @@ -189,6 +192,86 @@ class TicketNormalizerTest extends KernelTestCase ]; } + public function testNormalizeReadSimple(): void + { + // Create a ticket with some data + $ticket = new Ticket(); + $ticket->setExternalRef('TEST-123'); + + // Add state history + new StateHistory(StateEnum::OPEN, $ticket, new \DateTimeImmutable('2024-06-16T00:00:00Z')); + + // Add emergency status + new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket, new \DateTimeImmutable('2024-06-16T00:00:10Z')); + + // Add person + $personHistory = new PersonHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00')); + + // Add motive + $motiveHistory = new MotiveHistory(new Motive(), $ticket, new \DateTimeImmutable('2024-04-01T12:02:00')); + + // Add comment + $comment = new Comment('Test comment', $ticket); + $comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00')); + $comment->setCreatedBy(new User()); + + // Add addressee + new AddresseeHistory(new User(), new \DateTimeImmutable('2024-04-01T12:05:00'), $ticket); + + // Add caller + new CallerHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00')); + + // Set created/updated metadata + $ticket->setCreatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $ticket->setCreatedBy(new User()); + $ticket->setUpdatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $ticket->setUpdatedBy(new User()); + + // Normalize with read:simple group + $actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => 'read:simple']); + + // Expected keys in read:simple normalization + $expectedKeys = [ + 'type', + 'type_extended', + 'id', + 'externalRef', + 'currentPersons', + 'currentAddressees', + 'currentInputs', + 'currentMotive', + 'currentState', + 'emergency', + 'caller', + ]; + + // Keys that should not be present in read:simple normalization + $unexpectedKeys = [ + 'history', + 'createdAt', + 'updatedAt', + 'updatedBy', + 'createdBy', + ]; + + // Assert that all expected keys are present + foreach ($expectedKeys as $key) { + self::assertArrayHasKey($key, $actual, "Expected key '{$key}' is missing"); + } + + // Assert that none of the unexpected keys are present + foreach ($unexpectedKeys as $key) { + self::assertArrayNotHasKey($key, $actual, "Unexpected key '{$key}' is present"); + } + + // Assert specific values + self::assertEquals('ticket_ticket', $actual['type']); + self::assertEquals('ticket_ticket:simple', $actual['type_extended']); + self::assertEquals('TEST-123', $actual['externalRef']); + self::assertEquals('open', $actual['currentState']); + self::assertEquals('yes', $actual['emergency']); + } + private function buildNormalizer(): TicketNormalizer { $normalizer = $this->prophesize(NormalizerInterface::class);