From 87150114fd90e90e86e0d4c9710495c8bb31a256 Mon Sep 17 00:00:00 2001 From: Baptiste Leduc Date: Mon, 21 Sep 2026 19:07:11 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Migre=20l'entit=C3=A9=20CompanyMemberInvita?= =?UTF-8?q?tion=20de=20Ting=20vers=20Doctrine=20(afup/web#2383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phpstan-baseline.php | 38 +---- .../CompanyMembership/InvitationMail.php | 6 +- .../Entity/CompanyMemberInvitation.php | 42 +++++ .../CompanyMemberInvitationRepository.php | 45 +++++ .../Association/Enum/InvitationEtat.php | 12 ++ .../Form/CompanyMemberInvitationType.php | 2 +- .../Association/Form/CompanyMemberType.php | 4 +- .../Association/Model/CompanyMember.php | 1 + .../Model/CompanyMemberInvitation.php | 128 --------------- .../CompanyMemberInvitationRepository.php | 87 ---------- .../Website/Member/MembersAction.php | 47 +++--- .../Website/Membership/CompanyAction.php | 25 +-- .../Membership/MemberInvitationAction.php | 12 +- .../CompanyMemberInvitationRepositoryTest.php | 154 ++++++++++++++++++ .../Form/CompanyMemberTypeTest.php | 14 +- 15 files changed, 312 insertions(+), 305 deletions(-) create mode 100644 sources/AppBundle/Association/Entity/CompanyMemberInvitation.php create mode 100644 sources/AppBundle/Association/Entity/Repository/CompanyMemberInvitationRepository.php create mode 100644 sources/AppBundle/Association/Enum/InvitationEtat.php delete mode 100644 sources/AppBundle/Association/Model/CompanyMemberInvitation.php delete mode 100644 sources/AppBundle/Association/Model/Repository/CompanyMemberInvitationRepository.php create mode 100644 tests/integration/AppBundle/Association/Entity/Repository/CompanyMemberInvitationRepositoryTest.php diff --git a/phpstan-baseline.php b/phpstan-baseline.php index f85ead6d8..ed1c961b8 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -965,32 +965,8 @@ 'message' => '#^Parameter \\#2 \\$array of function implode expects array\\, list given\\.$#', 'identifier' => 'argument.type', 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Association/Model/CompanyMember.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Method AppBundle\\\\Association\\\\Model\\\\Repository\\\\CompanyMemberInvitationRepository\\:\\:initMetadata\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#', - 'identifier' => 'missingType.iterableValue', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Association/Model/Repository/CompanyMemberInvitationRepository.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Method AppBundle\\\\Association\\\\Model\\\\Repository\\\\CompanyMemberInvitationRepository\\:\\:initMetadata\\(\\) should return M of CCMBenchmark\\\\Ting\\\\Repository\\\\Metadata but returns CCMBenchmark\\\\Ting\\\\Repository\\\\Metadata\\\\.$#', - 'identifier' => 'return.type', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Association/Model/Repository/CompanyMemberInvitationRepository.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Method AppBundle\\\\Association\\\\Model\\\\Repository\\\\CompanyMemberInvitationRepository\\:\\:loadPendingInvitationsByCompany\\(\\) return type with generic interface CCMBenchmark\\\\Ting\\\\Repository\\\\CollectionInterface does not specify its types\\: T$#', - 'identifier' => 'missingType.generics', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Association/Model/Repository/CompanyMemberInvitationRepository.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$databaseName of method CCMBenchmark\\\\Ting\\\\Repository\\\\Metadata\\\\:\\:setDatabase\\(\\) expects string, mixed given\\.$#', - 'identifier' => 'argument.type', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Association/Model/Repository/CompanyMemberInvitationRepository.php', -]; + 'path' => __DIR__ . '/sources/AppBundle/Association/Model/CompanyMember.php', + ]; $ignoreErrors[] = [ 'message' => '#^Cannot access offset \'id\' on mixed\\.$#', 'identifier' => 'offsetAccess.nonOffsetAccessible', @@ -4933,12 +4909,6 @@ 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Controller/Website/Member/MembersAction.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Method AppBundle\\\\Controller\\\\Website\\\\Member\\\\MembersAction\\:\\:addUser\\(\\) has parameter \\$pendingInvitations with generic interface CCMBenchmark\\\\Ting\\\\Repository\\\\CollectionInterface but does not specify its types\\: T$#', - 'identifier' => 'missingType.generics', - 'count' => 1, - 'path' => __DIR__ . '/sources/AppBundle/Controller/Website/Member/MembersAction.php', -]; $ignoreErrors[] = [ 'message' => '#^Method AppBundle\\\\Controller\\\\Website\\\\Member\\\\MembersAction\\:\\:addUser\\(\\) has parameter \\$users with generic interface CCMBenchmark\\\\Ting\\\\Repository\\\\CollectionInterface but does not specify its types\\: T$#', 'identifier' => 'missingType.generics', @@ -4976,13 +4946,13 @@ 'path' => __DIR__ . '/sources/AppBundle/Controller/Website/Member/MembersAction.php', ]; $ignoreErrors[] = [ - 'message' => '#^Argument of an invalid type array\\\\|null supplied for foreach, only iterables are supported\\.$#', + 'message' => '#^Argument of an invalid type array\\\\|null supplied for foreach, only iterables are supported\\.$#', 'identifier' => 'foreach.nonIterable', 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Controller/Website/Membership/CompanyAction.php', ]; $ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$value of function count expects array\\|Countable, array\\\\|null given\\.$#', + 'message' => '#^Parameter \\#1 \\$value of function count expects array\\|Countable, array\\\\|null given\\.$#', 'identifier' => 'argument.type', 'count' => 1, 'path' => __DIR__ . '/sources/AppBundle/Controller/Website/Membership/CompanyAction.php', diff --git a/sources/AppBundle/Association/CompanyMembership/InvitationMail.php b/sources/AppBundle/Association/CompanyMembership/InvitationMail.php index 4f11228bd..aca4a60b9 100644 --- a/sources/AppBundle/Association/CompanyMembership/InvitationMail.php +++ b/sources/AppBundle/Association/CompanyMembership/InvitationMail.php @@ -5,7 +5,7 @@ namespace AppBundle\Association\CompanyMembership; use AppBundle\Association\Model\CompanyMember; -use AppBundle\Association\Model\CompanyMemberInvitation; +use AppBundle\Association\Entity\CompanyMemberInvitation; use AppBundle\Email\Mailer\Mailer; use AppBundle\Email\Mailer\MailUser; use AppBundle\Email\Mailer\MailUserFactory; @@ -36,7 +36,7 @@ public function sendInvitation(CompanyMember $companyMember, CompanyMemberInvita '%lastname%' => $companyMember->getLastName(), '%link%' => $this->router->generate( 'company_invitation', - ['invitationId' => $invitation->getId(), 'token' => $invitation->getToken()], + ['invitationId' => $invitation->id, 'token' => $invitation->token], UrlGeneratorInterface::ABSOLUTE_URL, ), ], @@ -45,7 +45,7 @@ public function sendInvitation(CompanyMember $companyMember, CompanyMemberInvita return $this->mailer->sendTransactional(new Message( sprintf('%s vous invite à profiter de son compte "Membre AFUP"', $companyMember->getCompanyName()), MailUserFactory::sponsors(), - new MailUser($invitation->getEmail()), + new MailUser($invitation->email), ), $text); } } diff --git a/sources/AppBundle/Association/Entity/CompanyMemberInvitation.php b/sources/AppBundle/Association/Entity/CompanyMemberInvitation.php new file mode 100644 index 000000000..953756801 --- /dev/null +++ b/sources/AppBundle/Association/Entity/CompanyMemberInvitation.php @@ -0,0 +1,42 @@ + + */ +final class CompanyMemberInvitationRepository extends EntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CompanyMemberInvitation::class); + } + + /** + * @return array + */ + public function loadPendingInvitationsByCompany(int $companyId): array + { + return ($queryBuilder = $this->createQueryBuilder('i')) + ->where($queryBuilder->expr()->eq('i.companyId', ':companyId')) + ->setParameter('companyId', $companyId) + ->andWhere($queryBuilder->expr()->eq('i.status', ':status')) + ->setParameter('status', InvitationEtat::EnAttente) + ->orderBy('i.submittedOn', 'ASC') + ->getQuery() + ->getResult(); + } + + public function findPendingByEmail(int $companyId, string $email): ?CompanyMemberInvitation + { + return $this->findOneBy([ + 'companyId' => $companyId, + 'email' => $email, + 'status' => InvitationEtat::EnAttente, + ]); + } +} diff --git a/sources/AppBundle/Association/Enum/InvitationEtat.php b/sources/AppBundle/Association/Enum/InvitationEtat.php new file mode 100644 index 000000000..321066dbd --- /dev/null +++ b/sources/AppBundle/Association/Enum/InvitationEtat.php @@ -0,0 +1,12 @@ +getEmail())) { + if ($first instanceof CompanyMemberInvitation && '' !== trim($first->email)) { return; } diff --git a/sources/AppBundle/Association/Model/CompanyMember.php b/sources/AppBundle/Association/Model/CompanyMember.php index 93e5856c3..56513a232 100644 --- a/sources/AppBundle/Association/Model/CompanyMember.php +++ b/sources/AppBundle/Association/Model/CompanyMember.php @@ -5,6 +5,7 @@ namespace AppBundle\Association\Model; use AppBundle\Association\CompanyMembership\SubscriptionManagement; +use AppBundle\Association\Entity\CompanyMemberInvitation; use CCMBenchmark\Ting\Entity\NotifyProperty; use CCMBenchmark\Ting\Entity\NotifyPropertyInterface; use Cocur\Slugify\Slugify; diff --git a/sources/AppBundle/Association/Model/CompanyMemberInvitation.php b/sources/AppBundle/Association/Model/CompanyMemberInvitation.php deleted file mode 100644 index 8dfa96f02..000000000 --- a/sources/AppBundle/Association/Model/CompanyMemberInvitation.php +++ /dev/null @@ -1,128 +0,0 @@ -id; - } - - public function setId(int $id): self - { - $this->propertyChanged('id', $this->id ?? null, $id); - $this->id = $id; - return $this; - } - - public function getCompanyId(): int - { - return $this->companyId; - } - - public function setCompanyId(int $companyId): self - { - $this->propertyChanged('companyId', $this->companyId ?? null, $companyId); - $this->companyId = $companyId; - return $this; - } - - public function getEmail(): string - { - return $this->email; - } - - public function setEmail(string $email): self - { - $this->propertyChanged('email', $this->email, $email); - $this->email = $email; - return $this; - } - - public function getManager(): bool - { - return $this->manager; - } - - public function setManager(bool $manager): self - { - $this->propertyChanged('manager', $this->manager, $manager); - $this->manager = $manager; - return $this; - } - - public function getSubmittedOn(): \DateTime - { - return $this->submittedOn; - } - - public function setSubmittedOn(\DateTime $submittedOn): self - { - $this->propertyChanged('submittedOn', $this->submittedOn ?? null, $submittedOn); - $this->submittedOn = $submittedOn; - return $this; - } - - /** - * @return self::STATUS_* - */ - public function getStatus(): int - { - return $this->status; - } - - /** - * @param self::STATUS_* $status - */ - public function setStatus(int $status): self - { - $this->propertyChanged('status', $this->status, $status); - $this->status = $status; - return $this; - } - - public function getToken(): string - { - return $this->token; - } - - public function setToken(string $token): self - { - $this->propertyChanged('token', $this->token ?? null, $token); - $this->token = $token; - return $this; - } -} diff --git a/sources/AppBundle/Association/Model/Repository/CompanyMemberInvitationRepository.php b/sources/AppBundle/Association/Model/Repository/CompanyMemberInvitationRepository.php deleted file mode 100644 index db214a040..000000000 --- a/sources/AppBundle/Association/Model/Repository/CompanyMemberInvitationRepository.php +++ /dev/null @@ -1,87 +0,0 @@ - - */ -class CompanyMemberInvitationRepository extends Repository implements MetadataInitializer -{ - /** - * @return CollectionInterface&iterable - */ - public function loadPendingInvitationsByCompany(CompanyMember $companyMember) - { - return $this->getBy([ - 'companyId' => $companyMember->getId(), - 'status' => CompanyMemberInvitation::STATUS_PENDING, - ]); - } - - /** - * @inheritDoc - */ - public static function initMetadata(SerializerFactoryInterface $serializerFactory, array $options = []) - { - $metadata = new Metadata($serializerFactory); - - $metadata->setEntity(CompanyMemberInvitation::class); - $metadata->setConnectionName('main'); - $metadata->setDatabase($options['database']); - $metadata->setTable('afup_personnes_morales_invitations'); - - $metadata - ->addField([ - 'columnName' => 'id', - 'fieldName' => 'id', - 'primary' => true, - 'autoincrement' => true, - 'type' => 'int', - ]) - ->addField([ - 'columnName' => 'company_id', - 'fieldName' => 'companyId', - 'type' => 'int', - ]) - ->addField([ - 'columnName' => 'email', - 'fieldName' => 'email', - 'type' => 'string', - ]) - ->addField([ - 'columnName' => 'token', - 'fieldName' => 'token', - 'type' => 'string', - ]) - ->addField([ - 'columnName' => 'manager', - 'fieldName' => 'manager', - 'type' => 'bool', - 'serializer' => Boolean::class, - ]) - ->addField([ - 'columnName' => 'submitted_on', - 'fieldName' => 'submittedOn', - 'type' => 'datetime', - ]) - ->addField([ - 'columnName' => 'status', - 'fieldName' => 'status', - 'type' => 'int', - ]) - ; - - return $metadata; - } -} diff --git a/sources/AppBundle/Controller/Website/Member/MembersAction.php b/sources/AppBundle/Controller/Website/Member/MembersAction.php index aca9b7c97..ba6ce43d2 100644 --- a/sources/AppBundle/Controller/Website/Member/MembersAction.php +++ b/sources/AppBundle/Controller/Website/Member/MembersAction.php @@ -8,8 +8,9 @@ use AppBundle\Association\CompanyMembership\UserCompany; use AppBundle\Association\Form\CompanyMemberInvitationType; use AppBundle\Association\Model\CompanyMember; -use AppBundle\Association\Model\CompanyMemberInvitation; -use AppBundle\Association\Model\Repository\CompanyMemberInvitationRepository; +use AppBundle\Association\Entity\CompanyMemberInvitation; +use AppBundle\Association\Entity\Repository\CompanyMemberInvitationRepository; +use AppBundle\Association\Enum\InvitationEtat; use AppBundle\Association\Model\Repository\CompanyMemberRepository; use AppBundle\Association\Model\Repository\UserRepository; use AppBundle\Association\Model\User; @@ -56,25 +57,25 @@ public function __invoke(Request $request): Response } $users = $this->userRepository->loadActiveUsersByCompany($company); - $pendingInvitations = $this->companyMemberInvitationRepository->loadPendingInvitationsByCompany($company); + $pendingInvitations = $this->companyMemberInvitationRepository->loadPendingInvitationsByCompany((int) $company->getId()); $invitation = new CompanyMemberInvitation(); $invitationForm = $this->createForm(CompanyMemberInvitationType::class, $invitation); $invitationForm->handleRequest($request); - $canAddUser = $pendingInvitations->count() + $users->count() < $company->getMaxMembers(); + $canAddUser = count($pendingInvitations) + $users->count() < $company->getMaxMembers(); if ($request->isMethod(Request::METHOD_POST)) { if ($invitationForm->isSubmitted() && $invitationForm->isValid()) { if ($canAddUser) { - $this->addUser($company, $invitation, $users, $pendingInvitations); + $this->addUser($company, $invitation, $users); } else { $this->addFlash('error', 'Vous avez atteint le nombre maximum de membres'); } } elseif (!$this->csrfTokenManager->isTokenValid(new CsrfToken('member_company_members', $request->request->get('token')))) { $this->addFlash('error', 'Erreur lors de la soumission du formulaire (jeton CSRF invalide). Merci de réessayer.'); } elseif ($request->request->has('delete_invitation')) { - $this->removeInvitation($request->request->getString('delete_invitation'), $pendingInvitations); + $this->removeInvitation($request->request->getString('delete_invitation'), $company); } elseif ($request->request->has('resend_invitation')) { - $this->resendInvitation($request->request->getString('resend_invitation'), $pendingInvitations, $company); + $this->resendInvitation($request->request->getString('resend_invitation'), $company); } elseif ($request->request->has('promote_up')) { $this->promoteUser($request->request->getString('promote_up'), $users); } elseif ($request->request->has('promote_down')) { @@ -103,11 +104,10 @@ private function addUser( CompanyMember $company, CompanyMemberInvitation $invitation, CollectionInterface $users, - CollectionInterface $pendingInvitations, ): void { // Check if there is already a pending invitation for this email and this company - $matchingUser = $this->collectionFilter->findOne($users, 'getEmail', $invitation->getEmail()); - $matchingInvitation = $this->collectionFilter->findOne($pendingInvitations, 'getEmail', $invitation->getEmail()); + $matchingUser = $this->collectionFilter->findOne($users, 'getEmail', $invitation->email); + $matchingInvitation = $this->companyMemberInvitationRepository->findPendingByEmail((int) $company->getId(), $invitation->email); if ($matchingInvitation !== null || $matchingUser !== null) { $this->addFlash('error', 'Vous ne pouvez pas envoyer plusieurs invitations au même email.'); @@ -116,28 +116,24 @@ private function addUser( } // Handle invitation - $invitation - ->setSubmittedOn(new DateTime()) - ->setCompanyId($company->getId()) - ->setToken(base64_encode(random_bytes(30))) - ->setStatus(CompanyMemberInvitation::STATUS_PENDING); + $invitation->submittedOn = new DateTime(); + $invitation->companyId = (int) $company->getId(); + $invitation->token = base64_encode(random_bytes(30)); + $invitation->status = InvitationEtat::EnAttente; $this->companyMemberInvitationRepository->save($invitation); // Send mail to the other guy, begging for him to join the company $this->eventDispatcher->addListener(KernelEvents::TERMINATE, function () use ($company, $invitation): void { $this->invitationMail->sendInvitation($company, $invitation); }); - $this->addFlash('notice', sprintf('L\'invitation a été envoyée à l\'adresse %s.', $invitation->getEmail())); + $this->addFlash('notice', sprintf('L\'invitation a été envoyée à l\'adresse %s.', $invitation->email)); } - /** - * @param CollectionInterface $pendingInvitations - */ - private function removeInvitation(string $emailToDelete, CollectionInterface $pendingInvitations): void + private function removeInvitation(string $emailToDelete, CompanyMember $company): void { - $invitationToDelete = $this->collectionFilter->findOne($pendingInvitations, 'getEmail', $emailToDelete); + $invitationToDelete = $this->companyMemberInvitationRepository->findPendingByEmail((int) $company->getId(), $emailToDelete); if ($invitationToDelete !== null) { - $invitationToDelete->setStatus(CompanyMemberInvitation::STATUS_CANCELLED); + $invitationToDelete->status = InvitationEtat::Annulee; $this->companyMemberInvitationRepository->save($invitationToDelete); $this->addFlash('notice', 'L\'invitation a été annulée.'); } else { @@ -145,12 +141,9 @@ private function removeInvitation(string $emailToDelete, CollectionInterface $pe } } - /** - * @param CollectionInterface $pendingInvitations - */ - private function resendInvitation(string $emailToSend, CollectionInterface $pendingInvitations, CompanyMember $company): void + private function resendInvitation(string $emailToSend, CompanyMember $company): void { - $invitationToSend = $this->collectionFilter->findOne($pendingInvitations, 'getEmail', $emailToSend); + $invitationToSend = $this->companyMemberInvitationRepository->findPendingByEmail((int) $company->getId(), $emailToSend); if ($invitationToSend !== null) { $this->invitationMail->sendInvitation($company, $invitationToSend); diff --git a/sources/AppBundle/Controller/Website/Membership/CompanyAction.php b/sources/AppBundle/Controller/Website/Membership/CompanyAction.php index 030700671..f093ec4c7 100644 --- a/sources/AppBundle/Controller/Website/Membership/CompanyAction.php +++ b/sources/AppBundle/Controller/Website/Membership/CompanyAction.php @@ -6,10 +6,11 @@ use AppBundle\Association\CompanyMembership\InvitationMail; use AppBundle\Association\CompanyMembership\SubscriptionManagement; +use AppBundle\Association\Entity\CompanyMemberInvitation; +use AppBundle\Association\Entity\Repository\CompanyMemberInvitationRepository; +use AppBundle\Association\Enum\InvitationEtat; use AppBundle\Association\Form\CompanyMemberType; use AppBundle\Association\Model\CompanyMember; -use AppBundle\Association\Model\CompanyMemberInvitation; -use AppBundle\Association\Model\Repository\CompanyMemberInvitationRepository; use AppBundle\Association\Model\Repository\CompanyMemberRepository; use AppBundle\Twig\ViewRenderer; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -39,7 +40,11 @@ public function __invoke(Request $request): Response $data = new CompanyMember(); $data->setMaxMembers($defaultMembers); $data->setInvitations(array_map( - static fn(int $index): CompanyMemberInvitation => new CompanyMemberInvitation()->setManager(0 === $index), + static function (int $index): CompanyMemberInvitation { + $invitation = new CompanyMemberInvitation(); + $invitation->manager = 0 === $index; + return $invitation; + }, range(0, $defaultMembers - 1), )); @@ -54,18 +59,16 @@ public function __invoke(Request $request): Response $this->companyMemberRepository->save($member); foreach ($member->getInvitations() as $index => $invitation) { - if ($invitation->getEmail() === '') { + if ($invitation->email === '') { continue; } - $invitation - ->setSubmittedOn(new \DateTime()) - ->setCompanyId($member->getId()) - ->setToken(base64_encode(random_bytes(30))) - ->setStatus(CompanyMemberInvitation::STATUS_PENDING) - ; + $invitation->submittedOn = new \DateTime(); + $invitation->companyId = (int) $member->getId(); + $invitation->token = base64_encode(random_bytes(30)); + $invitation->status = InvitationEtat::EnAttente; if ($index === 0) { // By security, force first employee to be defined as a manager - $invitation->setManager(true); + $invitation->manager = true; } $this->companyMemberInvitationRepository->save($invitation); diff --git a/sources/AppBundle/Controller/Website/Membership/MemberInvitationAction.php b/sources/AppBundle/Controller/Website/Membership/MemberInvitationAction.php index cd5cf2d41..3e29fe63a 100644 --- a/sources/AppBundle/Controller/Website/Membership/MemberInvitationAction.php +++ b/sources/AppBundle/Controller/Website/Membership/MemberInvitationAction.php @@ -6,8 +6,8 @@ use AppBundle\Association\Event\NewMemberEvent; use AppBundle\Association\Form\UserType; -use AppBundle\Association\Model\CompanyMemberInvitation; -use AppBundle\Association\Model\Repository\CompanyMemberInvitationRepository; +use AppBundle\Association\Entity\Repository\CompanyMemberInvitationRepository; +use AppBundle\Association\Enum\InvitationEtat; use AppBundle\Association\Model\Repository\CompanyMemberRepository; use AppBundle\Association\Model\Repository\UserRepository; use AppBundle\Association\Model\User; @@ -31,10 +31,10 @@ public function __construct( public function __invoke(Request $request, int $invitationId, string $token): Response { - $invitation = $this->companyMemberInvitationRepository->getOneBy(['id' => $invitationId, 'token' => $token, 'status' => CompanyMemberInvitation::STATUS_PENDING]); + $invitation = $this->companyMemberInvitationRepository->findOneBy(['id' => $invitationId, 'token' => $token, 'status' => InvitationEtat::EnAttente]); $company = null; if ($invitation) { - $company = $this->companyMemberRepository->get($invitation->getCompanyId()); + $company = $this->companyMemberRepository->get($invitation->companyId); } if ($invitation === null || $company === null) { @@ -56,11 +56,11 @@ public function __invoke(Request $request, int $invitationId, string $token): Re ->setCompanyId($company->getId()) ; - if ($invitation->getManager()) { + if ($invitation->manager) { $user->setRoles(['ROLE_COMPANY_MANAGER', 'ROLE_USER']); } - $invitation->setStatus(CompanyMemberInvitation::STATUS_ACCEPTED); + $invitation->status = InvitationEtat::Acceptee; $this->userRepository->save($user); $this->companyMemberInvitationRepository->save($invitation); diff --git a/tests/integration/AppBundle/Association/Entity/Repository/CompanyMemberInvitationRepositoryTest.php b/tests/integration/AppBundle/Association/Entity/Repository/CompanyMemberInvitationRepositoryTest.php new file mode 100644 index 000000000..65fa4e926 --- /dev/null +++ b/tests/integration/AppBundle/Association/Entity/Repository/CompanyMemberInvitationRepositoryTest.php @@ -0,0 +1,154 @@ +get(CompanyMemberRepository::class); + $repository = self::getContainer()->get(CompanyMemberInvitationRepository::class); + + $company = $this->createCompany($companyMemberRepository, 'societe-a@example.com'); + $otherCompany = $this->createCompany($companyMemberRepository, 'societe-b@example.com'); + + $this->persistInvitation($repository, $company->getId(), 'en-attente@example.com', InvitationEtat::EnAttente); + $this->persistInvitation($repository, $company->getId(), 'annulee@example.com', InvitationEtat::Annulee); + $this->persistInvitation($repository, $company->getId(), 'acceptee@example.com', InvitationEtat::Acceptee); + $this->persistInvitation($repository, $otherCompany->getId(), 'autre-societe@example.com', InvitationEtat::EnAttente); + + $invitations = $repository->loadPendingInvitationsByCompany((int) $company->getId()); + + self::assertCount(1, $invitations); + self::assertSame('en-attente@example.com', $invitations[0]->email); + self::assertSame(InvitationEtat::EnAttente, $invitations[0]->status); + } + + public function testLoadPendingInvitationsByCompanySortsBySubmissionDate(): void + { + $companyMemberRepository = self::getContainer()->get(CompanyMemberRepository::class); + $repository = self::getContainer()->get(CompanyMemberInvitationRepository::class); + + $company = $this->createCompany($companyMemberRepository, 'tri@example.com'); + + $ancienne = $this->persistInvitation($repository, $company->getId(), 'ancienne@example.com', InvitationEtat::EnAttente); + $ancienne->submittedOn = new \DateTime('-2 days'); + $repository->save($ancienne); + + $recente = $this->persistInvitation($repository, $company->getId(), 'recente@example.com', InvitationEtat::EnAttente); + + $invitations = $repository->loadPendingInvitationsByCompany((int) $company->getId()); + + self::assertSame(['ancienne@example.com', 'recente@example.com'], array_map( + static fn(CompanyMemberInvitation $invitation): string => $invitation->email, + $invitations, + )); + self::assertGreaterThan($ancienne->submittedOn, $recente->submittedOn); + } + + public function testFindPendingByEmailReturnsPendingInvitationOfTheCompany(): void + { + $companyMemberRepository = self::getContainer()->get(CompanyMemberRepository::class); + $repository = self::getContainer()->get(CompanyMemberInvitationRepository::class); + + $company = $this->createCompany($companyMemberRepository, 'recherche@example.com'); + $otherCompany = $this->createCompany($companyMemberRepository, 'recherche-autre@example.com'); + + $this->persistInvitation($repository, $company->getId(), 'invite@example.com', InvitationEtat::EnAttente); + + $invitation = $repository->findPendingByEmail((int) $company->getId(), 'invite@example.com'); + + self::assertNotNull($invitation); + self::assertSame('invite@example.com', $invitation->email); + self::assertSame((int) $company->getId(), $invitation->companyId); + + // Le même email chez une autre société ne doit rien retourner. + self::assertNull($repository->findPendingByEmail((int) $otherCompany->getId(), 'invite@example.com')); + } + + public function testFindPendingByEmailIgnoresCancelledInvitation(): void + { + $companyMemberRepository = self::getContainer()->get(CompanyMemberRepository::class); + $repository = self::getContainer()->get(CompanyMemberInvitationRepository::class); + + $company = $this->createCompany($companyMemberRepository, 'annulation@example.com'); + + $invitation = $this->persistInvitation($repository, $company->getId(), 'annulee@example.com', InvitationEtat::EnAttente); + $invitation->status = InvitationEtat::Annulee; + $repository->save($invitation); + + self::assertNull($repository->findPendingByEmail((int) $company->getId(), 'annulee@example.com')); + } + + public function testSaveThenUpdateKeepsSameRow(): void + { + $companyMemberRepository = self::getContainer()->get(CompanyMemberRepository::class); + $repository = self::getContainer()->get(CompanyMemberInvitationRepository::class); + $connection = self::getContainer()->get(\Doctrine\DBAL\Connection::class); + + $company = $this->createCompany($companyMemberRepository, 'cycle@example.com'); + + $invitation = $this->persistInvitation($repository, $company->getId(), 'cycle@example.com', InvitationEtat::EnAttente); + $id = $invitation->id; + + $invitation->status = InvitationEtat::Acceptee; + $repository->save($invitation); + + self::assertSame($id, $invitation->id); + self::assertSame( + InvitationEtat::Acceptee->value, + (int) $connection->fetchOne('SELECT status FROM afup_personnes_morales_invitations WHERE id = :id', ['id' => $id]), + ); + self::assertSame(1, (int) $connection->fetchOne( + 'SELECT COUNT(*) FROM afup_personnes_morales_invitations WHERE company_id = :companyId', + ['companyId' => $company->getId()], + )); + } + + private function createCompany(CompanyMemberRepository $repository, string $email): CompanyMember + { + static $counter = 0; + $counter++; + + $company = (new CompanyMember()) + ->setFirstName('Prénom') + ->setLastName('Nom') + ->setEmail($email) + ->setCompanyName('Société de test ' . $counter) + ->setSiret('12345678901234') + ->setAddress('1 rue du Test') + ->setZipCode('75000') + ->setCity('Paris') + ->setCountry('FR'); + $repository->save($company); + + return $company; + } + + private function persistInvitation( + CompanyMemberInvitationRepository $repository, + int $companyId, + string $email, + InvitationEtat $status, + ): CompanyMemberInvitation { + $invitation = new CompanyMemberInvitation(); + $invitation->companyId = $companyId; + $invitation->email = $email; + $invitation->token = base64_encode(random_bytes(30)); + $invitation->manager = false; + $invitation->submittedOn = new \DateTime(); + $invitation->status = $status; + $repository->save($invitation); + + return $invitation; + } +} diff --git a/tests/unit/AppBundle/Association/Form/CompanyMemberTypeTest.php b/tests/unit/AppBundle/Association/Form/CompanyMemberTypeTest.php index 1f9bc2df4..4aaa7d751 100644 --- a/tests/unit/AppBundle/Association/Form/CompanyMemberTypeTest.php +++ b/tests/unit/AppBundle/Association/Form/CompanyMemberTypeTest.php @@ -4,9 +4,9 @@ namespace AppBundle\Tests\Association\Form; +use AppBundle\Association\Entity\CompanyMemberInvitation; use AppBundle\Association\Form\CompanyMemberType; use AppBundle\Association\Model\CompanyMember; -use AppBundle\Association\Model\CompanyMemberInvitation; use EWZ\Bundle\RecaptchaBundle\Form\Type\EWZRecaptchaType; use EWZ\Bundle\RecaptchaBundle\Locale\LocaleResolver; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; @@ -64,7 +64,7 @@ public function unPostForgeNePeutPasDeverrouillerLePremierGestionnaire(): void $form->submit($this->payload(['manager' => '0']), false); - self::assertTrue($this->invitation($data, 0)->getManager()); + self::assertTrue($this->invitation($data, 0)->manager); } #[Test] @@ -95,7 +95,7 @@ public function uneLigneVideEstSimplementIgnoree(): void ], false); self::assertCount(1, $this->invitations($data)); - self::assertSame('premier@example.com', $this->invitation($data, 0)->getEmail()); + self::assertSame('premier@example.com', $this->invitation($data, 0)->email); } #[Test] @@ -112,8 +112,8 @@ public function unMembreSupplementaireRenseigneEstBienRattache(): void ], false); self::assertCount(2, $this->invitations($data)); - self::assertSame('second@example.com', $this->invitation($data, 1)->getEmail()); - self::assertFalse($this->invitation($data, 1)->getManager(), 'seul le premier est gestionnaire'); + self::assertSame('second@example.com', $this->invitation($data, 1)->email); + self::assertFalse($this->invitation($data, 1)->manager, 'seul le premier est gestionnaire'); } private function invitation(CompanyMember $member, int $index): CompanyMemberInvitation @@ -144,7 +144,9 @@ private function payload(array $overrides = []): array private function newCompanyMember(): CompanyMember { $member = new CompanyMember(); - $member->setInvitations([new CompanyMemberInvitation()->setManager(true)]); + $invitation = new CompanyMemberInvitation(); + $invitation->manager = true; + $member->setInvitations([$invitation]); return $member; } From 21ef4b863183547d295042bf65e41e004de9a1d7 Mon Sep 17 00:00:00 2001 From: Baptiste Leduc Date: Mon, 21 Sep 2026 20:01:09 +0000 Subject: [PATCH 2/2] fix: remplace invitations.count par invitations|length dans le template membres (migration Doctrine) --- .../admin/association/membership/members_company.html.twig | 2 +- tests/behat/features/MembersArea/PersonneMorale.feature | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/templates/admin/association/membership/members_company.html.twig b/templates/admin/association/membership/members_company.html.twig index 892a19521..36ebd729f 100644 --- a/templates/admin/association/membership/members_company.html.twig +++ b/templates/admin/association/membership/members_company.html.twig @@ -123,7 +123,7 @@ {% if canAddUser == false %}

- Vous avez actuellement {{ users.count }} membre(s) rattaché(s) et {{ invitations.count }} invitation(s) + Vous avez actuellement {{ users.count }} membre(s) rattaché(s) et {{ invitations|length }} invitation(s) en attente. Vous ne pouvez envoyer de nouvelle invitation, votre nombre maximum de membre permis par votre cotisation étant atteint. Si vous souhaitez rattacher d'avantage de membres, merci de nous contacter à l'adresse tresorier@afup.org afin d'augmenter le nombre de membres lié à votre cotisation. diff --git a/tests/behat/features/MembersArea/PersonneMorale.feature b/tests/behat/features/MembersArea/PersonneMorale.feature index d90babf5c..b6b4188c9 100644 --- a/tests/behat/features/MembersArea/PersonneMorale.feature +++ b/tests/behat/features/MembersArea/PersonneMorale.feature @@ -35,6 +35,8 @@ Feature: Espace membre > Personne morale > Personnes rattachés # Envoyer la dernière Then I fill in "company_member_invitation[email]" with "email2@email.com" And I press "Envoyer l'invitation" + # Invitation max bloqué + Then I should see "Vous avez actuellement 1 membre(s) rattaché(s) et 2 invitation(s) en attente." # Renvoyer la dernière invitation And I press "Envoyer à nouveau" And I should see "L'invitation a été renvoyée." @@ -43,7 +45,6 @@ Feature: Espace membre > Personne morale > Personnes rattachés | | MyCorp vous invite à profiter de son compte "Membre AFUP" | | | MyCorp vous invite à profiter de son compte "Membre AFUP" | | | MyCorp vous invite à profiter de son compte "Membre AFUP" | - # Invitation max bloqué Then I should see "Vous avez actuellement 1 membre(s) rattaché(s) et 2 invitation(s) en attente." # Annulation d'une invitation And I press "Annuler"