diff --git a/assets/stylesheets/components/progress.css b/assets/stylesheets/components/progress.css index c8132685..ccb66760 100644 --- a/assets/stylesheets/components/progress.css +++ b/assets/stylesheets/components/progress.css @@ -8,7 +8,7 @@ progress { height: 2rem; background-color: var(--color-primary2); - border: 0.25rem solid var(--color-primary11); + border: 0.25rem solid var(--color-primary9); border-radius: 0.5rem; appearance: none; diff --git a/migrations/Version20230925082614CreateTicketContract.php b/migrations/Version20230925082614CreateTicketContract.php new file mode 100644 index 00000000..62063bab --- /dev/null +++ b/migrations/Version20230925082614CreateTicketContract.php @@ -0,0 +1,92 @@ +connection->getDatabasePlatform(); + if ($dbPlatform instanceof PostgreSQLPlatform) { + $this->addSql(<<addSql('CREATE INDEX IDX_6CE6D4D8700047D2 ON ticket_contract (ticket_id)'); + $this->addSql('CREATE INDEX IDX_6CE6D4D82576E0FD ON ticket_contract (contract_id)'); + $this->addSql(<<addSql(<<addSql(<<addSql(<<addSql(<<connection->getDatabasePlatform(); + if ($dbPlatform instanceof PostgreSQLPlatform) { + $this->addSql('ALTER TABLE ticket_contract DROP CONSTRAINT FK_6CE6D4D8700047D2'); + $this->addSql('ALTER TABLE ticket_contract DROP CONSTRAINT FK_6CE6D4D82576E0FD'); + $this->addSql('DROP TABLE ticket_contract'); + } elseif ($dbPlatform instanceof MariaDBPlatform) { + $this->addSql('ALTER TABLE ticket_contract DROP FOREIGN KEY FK_6CE6D4D8700047D2'); + $this->addSql('ALTER TABLE ticket_contract DROP FOREIGN KEY FK_6CE6D4D82576E0FD'); + $this->addSql('DROP TABLE ticket_contract'); + } + } +} diff --git a/src/Command/SeedsCommand.php b/src/Command/SeedsCommand.php index 4d05f57b..117acd62 100644 --- a/src/Command/SeedsCommand.php +++ b/src/Command/SeedsCommand.php @@ -62,6 +62,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'orga:create:tickets:messages:confidential', 'orga:see', 'orga:see:tickets:all', + 'orga:see:tickets:contracts', 'orga:see:tickets:messages:confidential', 'orga:update:tickets:actors', 'orga:update:tickets:priority', @@ -85,7 +86,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'orga:see:contracts', 'orga:see:contracts:notes', 'orga:see:tickets:all', + 'orga:see:tickets:contracts', 'orga:see:tickets:messages:confidential', + 'orga:update:tickets:contracts', ], ]); @@ -98,6 +101,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'orga:create:tickets', 'orga:create:tickets:messages', 'orga:see', + 'orga:see:tickets:contracts', 'orga:update:tickets:title', ], ]); diff --git a/src/Controller/Organizations/ContractsController.php b/src/Controller/Organizations/ContractsController.php index e12f7183..e0cd8303 100644 --- a/src/Controller/Organizations/ContractsController.php +++ b/src/Controller/Organizations/ContractsController.php @@ -11,6 +11,7 @@ use App\Entity\Organization; use App\Repository\ContractRepository; use App\Repository\OrganizationRepository; +use App\Service\Sorter\ContractSorter; use App\Utils; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bridge\Doctrine\Attribute\MapEntity; @@ -28,6 +29,7 @@ public function index( Request $request, ContractRepository $contractRepository, OrganizationRepository $organizationRepository, + ContractSorter $contractSorter, Security $security, ): Response { $this->denyAccessUnlessGranted('orga:see:contracts', $organization); @@ -51,7 +53,8 @@ public function index( $contracts = $contractRepository->findBy([ 'organization' => $allowedOrganizations, - ], ['endAt' => 'DESC']); + ]); + $contractSorter->sort($contracts); return $this->render('organizations/contracts/index.html.twig', [ 'organization' => $organization, diff --git a/src/Controller/Organizations/TicketsController.php b/src/Controller/Organizations/TicketsController.php index 63d65f7a..52d310da 100644 --- a/src/Controller/Organizations/TicketsController.php +++ b/src/Controller/Organizations/TicketsController.php @@ -10,6 +10,7 @@ use App\Entity\Message; use App\Entity\Organization; use App\Entity\Ticket; +use App\Repository\ContractRepository; use App\Repository\MessageRepository; use App\Repository\MessageDocumentRepository; use App\Repository\OrganizationRepository; @@ -132,6 +133,7 @@ public function new( public function create( Organization $organization, Request $request, + ContractRepository $contractRepository, MessageRepository $messageRepository, MessageDocumentRepository $messageDocumentRepository, OrganizationRepository $organizationRepository, @@ -284,6 +286,11 @@ public function create( $ticket->setAssignee($assignee); } + $contracts = $contractRepository->findOngoingByOrganization($organization); + if (count($contracts) === 1) { + $ticket->addContract($contracts[0]); + } + $errors = $validator->validate($ticket); if (count($errors) > 0) { return $this->renderBadRequest('organizations/tickets/new.html.twig', [ diff --git a/src/Controller/Tickets/ContractsController.php b/src/Controller/Tickets/ContractsController.php new file mode 100644 index 00000000..a4a5f58f --- /dev/null +++ b/src/Controller/Tickets/ContractsController.php @@ -0,0 +1,106 @@ +getOrganization(); + $this->denyAccessUnlessGranted('orga:update:tickets:contracts', $organization); + + /** @var \App\Entity\User $user */ + $user = $this->getUser(); + + if (!$ticket->hasActor($user)) { + $this->denyAccessUnlessGranted('orga:see:tickets:all', $organization); + } + + $ongoingContracts = $contractRepository->findOngoingByOrganization($organization); + $contractSorter->sort($ongoingContracts); + $initialOngoingContract = $ticket->getOngoingContract(); + + return $this->render('tickets/contracts/edit.html.twig', [ + 'ticket' => $ticket, + 'ongoingContracts' => $ongoingContracts, + 'ongoingContractUid' => $initialOngoingContract ? $initialOngoingContract->getUid() : null, + ]); + } + + #[Route('/tickets/{uid}/contracts/edit', name: 'update ticket contracts', methods: ['POST'])] + public function update( + Ticket $ticket, + Request $request, + ContractRepository $contractRepository, + TicketRepository $ticketRepository, + ContractSorter $contractSorter, + TranslatorInterface $translator, + ): Response { + $organization = $ticket->getOrganization(); + $this->denyAccessUnlessGranted('orga:update:tickets:contracts', $organization); + + /** @var \App\Entity\User $user */ + $user = $this->getUser(); + + if (!$ticket->hasActor($user)) { + $this->denyAccessUnlessGranted('orga:see:tickets:all', $organization); + } + + $ongoingContractUid = $request->request->getString('ongoingContractUid'); + + $csrfToken = $request->request->getString('_csrf_token'); + + $ongoingContracts = $contractRepository->findOngoingByOrganization($organization); + $contractSorter->sort($ongoingContracts); + $initialOngoingContract = $ticket->getOngoingContract(); + + if (!$this->isCsrfTokenValid('update ticket contracts', $csrfToken)) { + return $this->renderBadRequest('tickets/contracts/edit.html.twig', [ + 'ticket' => $ticket, + 'ongoingContracts' => $ongoingContracts, + 'ongoingContractUid' => $ongoingContractUid, + 'error' => $translator->trans('csrf.invalid', [], 'errors'), + ]); + } + + $newOngoingContract = null; + foreach ($ongoingContracts as $contract) { + if ($contract->getUid() === $ongoingContractUid) { + $newOngoingContract = $contract; + } + } + + if ($initialOngoingContract) { + $ticket->removeContract($initialOngoingContract); + } + + if ($newOngoingContract) { + $ticket->addContract($newOngoingContract); + } + + $ticketRepository->save($ticket, true); + + return $this->redirectToRoute('ticket', [ + 'uid' => $ticket->getUid(), + ]); + } +} diff --git a/src/Entity/Contract.php b/src/Entity/Contract.php index ee8db639..ffb88a0e 100644 --- a/src/Entity/Contract.php +++ b/src/Entity/Contract.php @@ -9,6 +9,8 @@ use App\EntityListener\EntitySetMetaListener; use App\Repository\ContractRepository; use App\Utils; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Translation\TranslatableMessage; @@ -87,6 +89,15 @@ class Contract implements MetaEntityInterface, ActivityRecordableInterface #[ORM\JoinColumn(nullable: false)] private ?Organization $organization = null; + /** @var Collection $tickets */ + #[ORM\ManyToMany(targetEntity: Ticket::class, mappedBy: 'contracts')] + private Collection $tickets; + + public function __construct() + { + $this->tickets = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -239,4 +250,12 @@ public function getDaysProgress(): int $interval = $this->startAt->diff($now); return intval($interval->format('%a')); } + + /** + * @return Collection + */ + public function getTickets(): Collection + { + return $this->tickets; + } } diff --git a/src/Entity/Role.php b/src/Entity/Role.php index 5e5607d6..e73fcd5d 100644 --- a/src/Entity/Role.php +++ b/src/Entity/Role.php @@ -44,8 +44,10 @@ class Role implements MetaEntityInterface, ActivityRecordableInterface 'orga:see:contracts', 'orga:see:contracts:notes', 'orga:see:tickets:all', + 'orga:see:tickets:contracts', 'orga:see:tickets:messages:confidential', 'orga:update:tickets:actors', + 'orga:update:tickets:contracts', 'orga:update:tickets:priority', 'orga:update:tickets:status', 'orga:update:tickets:title', diff --git a/src/Entity/Ticket.php b/src/Entity/Ticket.php index 954703b7..32d91d93 100644 --- a/src/Entity/Ticket.php +++ b/src/Entity/Ticket.php @@ -117,9 +117,14 @@ class Ticket implements MetaEntityInterface, ActivityRecordableInterface #[ORM\OneToOne(cascade: ['persist'])] private ?Message $solution = null; + /** @var Collection $contracts */ + #[ORM\ManyToMany(targetEntity: Contract::class, inversedBy: 'tickets')] + private Collection $contracts; + public function __construct() { $this->messages = new ArrayCollection(); + $this->contracts = new ArrayCollection(); } public function getId(): ?int @@ -403,4 +408,41 @@ public function setSolution(?Message $solution): self return $this; } + + /** + * @return Collection + */ + public function getContracts(): Collection + { + return $this->contracts; + } + + public function getOngoingContract(): ?Contract + { + $contracts = $this->getContracts(); + + foreach ($contracts as $contract) { + if ($contract->getStatus() === 'ongoing') { + return $contract; + } + } + + return null; + } + + public function addContract(Contract $contract): static + { + if (!$this->contracts->contains($contract)) { + $this->contracts->add($contract); + } + + return $this; + } + + public function removeContract(Contract $contract): static + { + $this->contracts->removeElement($contract); + + return $this; + } } diff --git a/src/MessageHandler/CreateTicketsFromMailboxEmailsHandler.php b/src/MessageHandler/CreateTicketsFromMailboxEmailsHandler.php index c117eac6..80fd64ea 100644 --- a/src/MessageHandler/CreateTicketsFromMailboxEmailsHandler.php +++ b/src/MessageHandler/CreateTicketsFromMailboxEmailsHandler.php @@ -6,11 +6,12 @@ namespace App\MessageHandler; -use App\Entity\Ticket; use App\Entity\MailboxEmail; use App\Entity\Message; +use App\Entity\Ticket; use App\Message\CreateTicketsFromMailboxEmails; use App\Message\SendMessageEmail; +use App\Repository\ContractRepository; use App\Repository\MailboxRepository; use App\Repository\MailboxEmailRepository; use App\Repository\MessageRepository; @@ -33,6 +34,7 @@ class CreateTicketsFromMailboxEmailsHandler { public function __construct( + private ContractRepository $contractRepository, private MailboxEmailRepository $mailboxEmailRepository, private MessageRepository $messageRepository, private MessageDocumentRepository $messageDocumentRepository, @@ -112,6 +114,11 @@ public function __invoke(CreateTicketsFromMailboxEmails $message): void $ticket->setPriority(Ticket::DEFAULT_WEIGHT); $ticket->setOrganization($requesterOrganization); $ticket->setRequester($requester); + + $contracts = $this->contractRepository->findOngoingByOrganization($requesterOrganization); + if (count($contracts) === 1) { + $ticket->addContract($contracts[0]); + } } $messageContent = $this->appMessageSanitizer->sanitize($mailboxEmail->getBody()); diff --git a/src/Repository/ContractRepository.php b/src/Repository/ContractRepository.php index 774a69ad..d613d197 100644 --- a/src/Repository/ContractRepository.php +++ b/src/Repository/ContractRepository.php @@ -7,6 +7,8 @@ namespace App\Repository; use App\Entity\Contract; +use App\Entity\Organization; +use App\Utils; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -44,4 +46,29 @@ public function remove(Contract $entity, bool $flush = false): void $this->getEntityManager()->flush(); } } + + /** + * @return Contract[] + */ + public function findOngoingByOrganization(Organization $organization): array + { + $entityManager = $this->getEntityManager(); + + $now = Utils\Time::now(); + $orgaIds = $organization->getParentOrganizationIds(); + $orgaIds[] = $organization->getId(); + + $query = $entityManager->createQuery(<<setParameter('now', $now); + $query->setParameter('orgaIds', $orgaIds); + + return $query->getResult(); + } } diff --git a/src/Service/Sorter/ContractSorter.php b/src/Service/Sorter/ContractSorter.php new file mode 100644 index 00000000..3ca53681 --- /dev/null +++ b/src/Service/Sorter/ContractSorter.php @@ -0,0 +1,28 @@ +getEndAt()->getTimestamp() - $c1->getEndAt()->getTimestamp(); + + if ($endAtDiff === 0) { + return $this->localeCompare($c1->getName(), $c2->getName()); + } + + return $endAtDiff; + }); + } +} diff --git a/templates/roles/_role_form.html.twig b/templates/roles/_role_form.html.twig index aedac0b3..b2450527 100644 --- a/templates/roles/_role_form.html.twig +++ b/templates/roles/_role_form.html.twig @@ -173,11 +173,21 @@ label: 'roles.permissions.orga.see.tickets.messages.confidential' | trans, }) }} + {{ include('roles/_permission_checkbox.html.twig', { + permission: 'orga:see:tickets:contracts', + label: 'roles.permissions.orga.see.tickets.contracts' | trans, + }) }} + {{ include('roles/_permission_checkbox.html.twig', { permission: 'orga:update:tickets:actors', label: 'roles.permissions.orga.update.tickets.actors' | trans, }) }} + {{ include('roles/_permission_checkbox.html.twig', { + permission: 'orga:update:tickets:contracts', + label: 'roles.permissions.orga.update.tickets.contracts' | trans, + }) }} + {{ include('roles/_permission_checkbox.html.twig', { permission: 'orga:update:tickets:priority', label: 'roles.permissions.orga.update.tickets.priority' | trans, diff --git a/templates/tickets/contracts/edit.html.twig b/templates/tickets/contracts/edit.html.twig new file mode 100644 index 00000000..b6afce4c --- /dev/null +++ b/templates/tickets/contracts/edit.html.twig @@ -0,0 +1,51 @@ +{# + # This file is part of Bileto. + # Copyright 2022-2023 Probesys + # SPDX-License-Identifier: AGPL-3.0-or-later + #} + +{% extends 'modal.html.twig' %} + +{% block title %}{{ 'tickets.contracts.edit.title' | trans }}{% endblock %} + +{% block body %} +
+ + + {% if error %} + {{ include('alerts/_error.html.twig', { message: error }, with_context = false) }} + {% endif %} + +
+ + + +
+ +
+ +
+
+{% endblock %} diff --git a/templates/tickets/show.html.twig b/templates/tickets/show.html.twig index 78d9ff1c..0d5b4a8c 100644 --- a/templates/tickets/show.html.twig +++ b/templates/tickets/show.html.twig @@ -457,6 +457,57 @@ {{ ticket.impactLabel | trans }}

+ + {% if is_granted('orga:see:tickets:contracts', organization) %} +
+
+ + {{ icon('contract') }} + {{ 'tickets.contract' | trans }} + + + {% if is_granted('orga:update:tickets:contracts', organization) %} + + {% endif %} +
+ + {% set ongoingContract = ticket.ongoingContract %} + {% if ongoingContract %} +

+ {% if is_granted('orga:see:contracts', organization) %} + + {{ ongoingContract.name }} + + {% else %} + {{ ongoingContract.name }} + {% endif %} +

+ +
+ + {{ 'contracts.hours_consumed' | trans({ 'hours': 0, 'maxHours': ongoingContract.maxHours }) | raw }} + + + + +
+ {% else %} +

+ {{ 'tickets.contracts.none' | trans }} +

+ {% endif %} +
+ {% endif %} diff --git a/tests/Controller/Organizations/TicketsControllerTest.php b/tests/Controller/Organizations/TicketsControllerTest.php index a916eca8..51683420 100644 --- a/tests/Controller/Organizations/TicketsControllerTest.php +++ b/tests/Controller/Organizations/TicketsControllerTest.php @@ -9,6 +9,7 @@ use App\Entity\Organization; use App\Entity\Ticket; use App\Tests\AuthorizationHelper; +use App\Tests\Factory\ContractFactory; use App\Tests\Factory\MessageFactory; use App\Tests\Factory\MessageDocumentFactory; use App\Tests\Factory\OrganizationFactory; @@ -289,6 +290,35 @@ public function testPostCreateSanitizesTheMessageContent(): void $this->assertSame('My message', $message->getContent()); } + public function testPostCreateAttachesAContractIfItExists(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $organization = OrganizationFactory::createOne(); + $this->grantOrga($user->object(), ['orga:create:tickets'], $organization->object()); + $title = 'My ticket'; + $messageContent = 'My message'; + $ongoingContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => Time::ago(1, 'week'), + 'endAt' => Time::fromNow(1, 'week'), + ]); + + $client->request('POST', "/organizations/{$organization->getUid()}/tickets/new", [ + '_csrf_token' => $this->generateCsrfToken($client, 'create organization ticket'), + 'title' => $title, + 'requesterUid' => $user->getUid(), + 'message' => $messageContent, + ]); + + $ticket = TicketFactory::first(); + $this->assertNotNull($ticket); + $ticketContract = $ticket->getOngoingContract(); + $this->assertNotNull($ticketContract); + $this->assertSame($ongoingContract->getId(), $ticketContract->getId()); + } + public function testPostCreateAttachesDocumentsToMessage(): void { $client = static::createClient(); diff --git a/tests/Controller/Tickets/ContractsControllerTest.php b/tests/Controller/Tickets/ContractsControllerTest.php new file mode 100644 index 00000000..cb5aae0e --- /dev/null +++ b/tests/Controller/Tickets/ContractsControllerTest.php @@ -0,0 +1,209 @@ +loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:update:tickets:contracts']); + $ticket = TicketFactory::createOne([ + 'createdBy' => $user, + ]); + + $client->request('GET', "/tickets/{$ticket->getUid()}/contracts/edit"); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('h1', 'Edit the contract'); + } + + public function testGetEditFailsIfAccessIsForbidden(): void + { + $this->expectException(AccessDeniedException::class); + + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $ticket = TicketFactory::createOne([ + 'createdBy' => $user, + ]); + + $client->catchExceptions(false); + $client->request('GET', "/tickets/{$ticket->getUid()}/contracts/edit"); + } + + public function testGetEditFailsIfAccessToTicketIsForbidden(): void + { + $this->expectException(AccessDeniedException::class); + + $client = static::createClient(); + $user = UserFactory::createOne(); + $otherUser = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:update:tickets:contracts']); + $ticket = TicketFactory::createOne([ + 'createdBy' => $otherUser, + ]); + + $client->catchExceptions(false); + $client->request('GET', "/tickets/{$ticket->getUid()}/contracts/edit"); + } + + public function testPostUpdateSavesTicketAndRedirects(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:update:tickets:contracts']); + $organization = OrganizationFactory::createOne(); + $oldContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => Utils\Time::ago(1, 'week'), + 'endAt' => Utils\Time::fromNow(1, 'week'), + ]); + $newContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => Utils\Time::ago(1, 'week'), + 'endAt' => Utils\Time::fromNow(1, 'week'), + ]); + $ticket = TicketFactory::createOne([ + 'organization' => $organization, + 'createdBy' => $user, + 'contracts' => [$oldContract], + ]); + + $client->request('POST', "/tickets/{$ticket->getUid()}/contracts/edit", [ + '_csrf_token' => $this->generateCsrfToken($client, 'update ticket contracts'), + 'ongoingContractUid' => $newContract->getUid(), + ]); + + $this->assertResponseRedirects("/tickets/{$ticket->getUid()}", 302); + $ticket->refresh(); + $contracts = $ticket->getContracts(); + $this->assertSame(1, count($contracts)); + $this->assertSame($newContract->getId(), $contracts[0]->getId()); + } + + public function testPostUpdateFailsIfCsrfTokenIsInvalid(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:update:tickets:contracts']); + $organization = OrganizationFactory::createOne(); + $oldContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => Utils\Time::ago(1, 'week'), + 'endAt' => Utils\Time::fromNow(1, 'week'), + ]); + $newContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => Utils\Time::ago(1, 'week'), + 'endAt' => Utils\Time::fromNow(1, 'week'), + ]); + $ticket = TicketFactory::createOne([ + 'organization' => $organization, + 'createdBy' => $user, + 'contracts' => [$oldContract], + ]); + + $client->request('POST', "/tickets/{$ticket->getUid()}/contracts/edit", [ + '_csrf_token' => 'not the token', + 'ongoingContractUid' => $newContract->getUid(), + ]); + + $this->assertSelectorTextContains('[data-test="alert-error"]', 'The security token is invalid'); + $ticket->refresh(); + $contracts = $ticket->getContracts(); + $this->assertSame(1, count($contracts)); + $this->assertSame($oldContract->getId(), $contracts[0]->getId()); + } + + public function testPostUpdateFailsIfAccessIsForbidden(): void + { + $this->expectException(AccessDeniedException::class); + + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $organization = OrganizationFactory::createOne(); + $oldContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => Utils\Time::ago(1, 'week'), + 'endAt' => Utils\Time::fromNow(1, 'week'), + ]); + $newContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => Utils\Time::ago(1, 'week'), + 'endAt' => Utils\Time::fromNow(1, 'week'), + ]); + $ticket = TicketFactory::createOne([ + 'organization' => $organization, + 'createdBy' => $user, + 'contracts' => [$oldContract], + ]); + + $client->catchExceptions(false); + $client->request('POST', "/tickets/{$ticket->getUid()}/contracts/edit", [ + '_csrf_token' => $this->generateCsrfToken($client, 'update ticket contracts'), + 'ongoingContractUid' => $newContract->getUid(), + ]); + } + + public function testPostUpdateFailsIfAccessToTicketIsForbidden(): void + { + $this->expectException(AccessDeniedException::class); + + $client = static::createClient(); + $user = UserFactory::createOne(); + $otherUser = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:update:tickets:contracts']); + $organization = OrganizationFactory::createOne(); + $oldContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => Utils\Time::ago(1, 'week'), + 'endAt' => Utils\Time::fromNow(1, 'week'), + ]); + $newContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => Utils\Time::ago(1, 'week'), + 'endAt' => Utils\Time::fromNow(1, 'week'), + ]); + $ticket = TicketFactory::createOne([ + 'organization' => $organization, + 'createdBy' => $otherUser, + 'contracts' => [$oldContract], + ]); + + $client->catchExceptions(false); + $client->request('POST', "/tickets/{$ticket->getUid()}/contracts/edit", [ + '_csrf_token' => $this->generateCsrfToken($client, 'update ticket contracts'), + 'ongoingContractUid' => $newContract->getUid(), + ]); + } +} diff --git a/tests/MessageHandler/CreateTicketsFromMailboxEmailsHandlerTest.php b/tests/MessageHandler/CreateTicketsFromMailboxEmailsHandlerTest.php index 457d4b36..9a757e8c 100644 --- a/tests/MessageHandler/CreateTicketsFromMailboxEmailsHandlerTest.php +++ b/tests/MessageHandler/CreateTicketsFromMailboxEmailsHandlerTest.php @@ -8,11 +8,13 @@ use App\Message\CreateTicketsFromMailboxEmails; use App\Tests\AuthorizationHelper; +use App\Tests\Factory\ContractFactory; use App\Tests\Factory\MailboxEmailFactory; use App\Tests\Factory\MessageFactory; use App\Tests\Factory\OrganizationFactory; use App\Tests\Factory\TicketFactory; use App\Tests\Factory\UserFactory; +use App\Utils\Time; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; use Symfony\Component\Messenger\MessageBusInterface; @@ -72,6 +74,43 @@ public function testInvokeCreatesATicketFromAMailboxEmail(): void $this->assertSame('email', $message->getVia()); } + public function testInvokeCreatesATicketAndCanAttachAContractIfItExists(): void + { + $client = static::createClient(); + $container = static::getContainer(); + /** @var MessageBusInterface */ + $bus = $container->get(MessageBusInterface::class); + + $organization = OrganizationFactory::createOne(); + $user = UserFactory::createOne([ + 'organization' => $organization, + ]); + // Log the user so the created Contract has a createdBy and a updatedBy + // fields set. + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:create:tickets'], $organization->object()); + $subject = Factory::faker()->words(3, true); + $body = Factory::faker()->randomHtml(); + $mailboxEmail = MailboxEmailFactory::createOne([ + 'from' => $user->getEmail(), + 'subject' => $subject, + 'htmlBody' => $body, + ]); + $ongoingContract = ContractFactory::createOne([ + 'organization' => $organization, + 'startAt' => Time::ago(1, 'week'), + 'endAt' => Time::fromNow(1, 'week'), + ]); + + $bus->dispatch(new CreateTicketsFromMailboxEmails()); + + $ticket = TicketFactory::first(); + $this->assertNotNull($ticket); + $ticketContract = $ticket->getOngoingContract(); + $this->assertNotNull($ticketContract); + $this->assertSame($ongoingContract->getId(), $ticketContract->getId()); + } + public function testInvokeAnswersToTicketIfTicketIdIsGiven(): void { $container = static::getContainer(); diff --git a/translations/messages+intl-icu.en_GB.yaml b/translations/messages+intl-icu.en_GB.yaml index ee29a51f..b76855d9 100644 --- a/translations/messages+intl-icu.en_GB.yaml +++ b/translations/messages+intl-icu.en_GB.yaml @@ -189,8 +189,10 @@ roles.permissions.orga.manage.contracts: 'Manage the contracts' roles.permissions.orga.see.contracts: 'See the contracts' roles.permissions.orga.see.contracts.notes: 'See the private notes of the contracts' roles.permissions.orga.see.tickets.all: 'See all the tickets of the organization' +roles.permissions.orga.see.tickets.contracts: 'See the contract of the tickets' roles.permissions.orga.see.tickets.messages.confidential: 'See confidential answers' roles.permissions.orga.update.tickets.actors: 'Update the actors of a ticket' +roles.permissions.orga.update.tickets.contracts: 'Update the contract of a ticket' roles.permissions.orga.update.tickets.priority: 'Update the priority of a ticket' roles.permissions.orga.update.tickets.status: 'Update the status of a ticket' roles.permissions.orga.update.tickets.title: 'Update the title of a ticket' @@ -209,6 +211,10 @@ settings.index.title: Settings tickets.actors: Actors tickets.actors.edit.title: 'Edit the actors' tickets.assignee: Assignee +tickets.contract: Contract +tickets.contracts.edit.title: 'Edit the contract' +tickets.contracts.none: 'No contract' +tickets.contracts.ongoing: 'Ongoing contract' tickets.events.assignee.assigned: '{username} assigned the ticket to {newValue}' tickets.events.assignee.changed: '{username} changed the assignee from {oldValue} to {newValue}' tickets.events.assignee.unassigned: '{username} removed the assignee {oldValue}' diff --git a/translations/messages+intl-icu.fr_FR.yaml b/translations/messages+intl-icu.fr_FR.yaml index 2cf88e81..dc9ca632 100644 --- a/translations/messages+intl-icu.fr_FR.yaml +++ b/translations/messages+intl-icu.fr_FR.yaml @@ -189,8 +189,10 @@ roles.permissions.orga.manage.contracts: 'Gérer les contrats' roles.permissions.orga.see.contracts: 'Voir les contrats' roles.permissions.orga.see.contracts.notes: 'Voir les notes privées des contrats' roles.permissions.orga.see.tickets.all: 'Voir tous les tickets de l’organisation' +roles.permissions.orga.see.tickets.contracts: 'Voir le contrat des tickets' roles.permissions.orga.see.tickets.messages.confidential: 'Voir les réponses confidentielles' roles.permissions.orga.update.tickets.actors: 'Mettre à jour les acteurs d’un ticket' +roles.permissions.orga.update.tickets.contracts: 'Mettre à jour le contrat d’un ticket' roles.permissions.orga.update.tickets.priority: 'Mettre à jour la priorité d’un ticket' roles.permissions.orga.update.tickets.status: 'Mettre à jour le statut d’un ticket' roles.permissions.orga.update.tickets.title: 'Mettre à jour le titre d’un ticket' @@ -209,6 +211,10 @@ settings.index.title: Gestion tickets.actors: Acteurs tickets.actors.edit.title: 'Modifier les acteurs' tickets.assignee: 'Attribué à' +tickets.contract: Contrat +tickets.contracts.edit.title: 'Modifier le contrat' +tickets.contracts.none: 'Aucun contrat' +tickets.contracts.ongoing: 'Contrat en cours' tickets.events.assignee.assigned: '{username} a attribué le ticket à {newValue}' tickets.events.assignee.changed: '{username} a changé l’attribution de {oldValue} à {newValue}' tickets.events.assignee.unassigned: '{username} a supprimé l’attribution de {oldValue}'