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 %}
+
+{% 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}'