diff --git a/migrations/Version20260322100000.php b/migrations/Version20260322100000.php new file mode 100644 index 0000000..a1c4332 --- /dev/null +++ b/migrations/Version20260322100000.php @@ -0,0 +1,27 @@ +addSql('CREATE TABLE organizer_invitation (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, company_name VARCHAR(255) NOT NULL, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, message TEXT DEFAULT NULL, status VARCHAR(20) NOT NULL, token VARCHAR(64) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, responded_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_ORG_INV_TOKEN ON organizer_invitation (token)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE organizer_invitation'); + } +} diff --git a/phpstan.neon b/phpstan.neon index e41ee0d..9dcbdc6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,7 +6,7 @@ parameters: - src/Kernel.php ignoreErrors: - - message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout|Event|Category|Billet|BilletDesign|BilletBuyer|BilletBuyerItem|BilletOrder)::\$id .* never assigned#' + message: '#Property App\\Entity\\(EmailTracking|MessengerLog|User|Payout|Event|Category|Billet|BilletDesign|BilletBuyer|BilletBuyerItem|BilletOrder|OrganizerInvitation)::\$id .* never assigned#' reportUnmatched: false paths: - src/Entity/EmailTracking.php @@ -20,6 +20,7 @@ parameters: - src/Entity/BilletBuyer.php - src/Entity/BilletBuyerItem.php - src/Entity/BilletOrder.php + - src/Entity/OrganizerInvitation.php - message: '#Parameter \#1 \$params of method Stripe\\Service\\.*::create\(\) expects#' path: src/Controller/OrderController.php diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 9f191a6..56f32c8 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Entity\OrganizerInvitation; use App\Entity\User; use App\Service\MailerService; use App\Service\MeilisearchService; @@ -441,4 +442,68 @@ class AdminController extends AbstractController 'searchQuery' => $searchQuery, ]); } + + #[Route('/organisateurs/inviter', name: 'app_admin_invite_organizer', methods: ['GET', 'POST'])] + public function inviteOrganizer(Request $request, EntityManagerInterface $em, MailerService $mailerService): Response + { + $invitations = $em->getRepository(OrganizerInvitation::class)->findBy([], ['createdAt' => 'DESC']); + + if ($request->isMethod('POST')) { + $companyName = trim($request->request->getString('company_name')); + $firstName = trim($request->request->getString('first_name')); + $lastName = trim($request->request->getString('last_name')); + $email = trim($request->request->getString('email')); + $message = trim($request->request->getString('message')) ?: null; + + if ('' === $companyName || '' === $firstName || '' === $lastName || '' === $email) { + $this->addFlash('error', 'Tous les champs obligatoires doivent etre remplis.'); + + return $this->redirectToRoute('app_admin_invite_organizer'); + } + + $invitation = new OrganizerInvitation(); + $invitation->setCompanyName($companyName); + $invitation->setFirstName($firstName); + $invitation->setLastName($lastName); + $invitation->setEmail($email); + $invitation->setMessage($message); + + $em->persist($invitation); + $em->flush(); + + $acceptUrl = $this->generateUrl('app_invitation_respond', [ + 'token' => $invitation->getToken(), + 'action' => 'accept', + ], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL); + + $refuseUrl = $this->generateUrl('app_invitation_respond', [ + 'token' => $invitation->getToken(), + 'action' => 'refuse', + ], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL); + + $html = $this->renderView('email/organizer_invitation.html.twig', [ + 'invitation' => $invitation, + 'acceptUrl' => $acceptUrl, + 'refuseUrl' => $refuseUrl, + ]); + + $mailerService->sendEmail( + $email, + 'Invitation organisateur - E-Ticket', + $html, + 'E-Ticket ', + null, + false, + ); + + $this->addFlash('success', 'Invitation envoyee a '.$email.'.'); + + return $this->redirectToRoute('app_admin_invite_organizer'); + } + + return $this->render('admin/invite_organizer.html.twig', [ + 'invitations' => $invitations, + ]); + } + } diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php index 2eae55c..e605e07 100644 --- a/src/Controller/HomeController.php +++ b/src/Controller/HomeController.php @@ -6,6 +6,7 @@ use App\Entity\Billet; use App\Entity\BilletBuyer; use App\Entity\BilletOrder; use App\Entity\Category; +use App\Entity\OrganizerInvitation; use App\Entity\Event; use App\Entity\User; use App\Service\EventIndexService; @@ -236,4 +237,27 @@ class HomeController extends AbstractController { return $this->render('home/offline.html.twig'); } + + #[Route('/invitation/{token}/{action}', name: 'app_invitation_respond', requirements: ['action' => 'accept|refuse'], methods: ['GET'])] + public function respondInvitation(string $token, string $action, EntityManagerInterface $em): Response + { + $invitation = $em->getRepository(OrganizerInvitation::class)->findOneBy(['token' => $token]); + if (!$invitation || OrganizerInvitation::STATUS_SENT !== $invitation->getStatus()) { + throw $this->createNotFoundException(); + } + + if ('accept' === $action) { + $invitation->setStatus(OrganizerInvitation::STATUS_ACCEPTED); + } else { + $invitation->setStatus(OrganizerInvitation::STATUS_REFUSED); + } + + $invitation->setRespondedAt(new \DateTimeImmutable()); + $em->flush(); + + return $this->render('home/invitation_response.html.twig', [ + 'invitation' => $invitation, + 'accepted' => 'accept' === $action, + ]); + } } diff --git a/src/Entity/OrganizerInvitation.php b/src/Entity/OrganizerInvitation.php new file mode 100644 index 0000000..175980a --- /dev/null +++ b/src/Entity/OrganizerInvitation.php @@ -0,0 +1,152 @@ +token = bin2hex(random_bytes(32)); + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCompanyName(): ?string + { + return $this->companyName; + } + + public function setCompanyName(string $companyName): static + { + $this->companyName = $companyName; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(?string $message): static + { + $this->message = $message; + + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): static + { + $this->status = $status; + + return $this; + } + + public function getToken(): string + { + return $this->token; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getRespondedAt(): ?\DateTimeImmutable + { + return $this->respondedAt; + } + + public function setRespondedAt(?\DateTimeImmutable $respondedAt): static + { + $this->respondedAt = $respondedAt; + + return $this; + } +} diff --git a/src/Repository/OrganizerInvitationRepository.php b/src/Repository/OrganizerInvitationRepository.php new file mode 100644 index 0000000..bda51d8 --- /dev/null +++ b/src/Repository/OrganizerInvitationRepository.php @@ -0,0 +1,18 @@ + + */ +class OrganizerInvitationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, OrganizerInvitation::class); + } +} diff --git a/templates/admin/invite_organizer.html.twig b/templates/admin/invite_organizer.html.twig new file mode 100644 index 0000000..edb6479 --- /dev/null +++ b/templates/admin/invite_organizer.html.twig @@ -0,0 +1,89 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}Inviter un organisateur - Admin{% endblock %} + +{% block body %} +
+

Inviter un organisateur

+

Envoyer une invitation par email a un futur organisateur.

+ + {% for message in app.flashes('success') %} +

{{ message }}

+ {% endfor %} + {% for message in app.flashes('error') %} +

{{ message }}

+ {% endfor %} + +
+
+

Nouvelle invitation

+
+
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + {% if invitations|length > 0 %} +
+
+

Invitations envoyees

+
+
+ {% for inv in invitations %} +
+
+

{{ inv.companyName }}

+

{{ inv.firstName }} {{ inv.lastName }} — {{ inv.email }}

+
+ {{ inv.createdAt|date('d/m/Y H:i') }} + {% if inv.status == 'sent' %} + Envoyee + {% elseif inv.status == 'opened' %} + Ouverte + {% elseif inv.status == 'accepted' %} + Acceptee + {% elseif inv.status == 'refused' %} + Refusee + {% endif %} + {% if inv.respondedAt %} + {{ inv.respondedAt|date('d/m/Y H:i') }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/organizers.html.twig b/templates/admin/organizers.html.twig index 66d2188..838c240 100644 --- a/templates/admin/organizers.html.twig +++ b/templates/admin/organizers.html.twig @@ -3,9 +3,14 @@ {% block title %}Organisateurs{% endblock %} {% block body %} -
-

Organisateurs

-

{{ organizers.getTotalItemCount }} organisateur{{ organizers.getTotalItemCount > 1 ? 's' : '' }}.

+
+
+

Organisateurs

+

{{ organizers.getTotalItemCount }} organisateur{{ organizers.getTotalItemCount > 1 ? 's' : '' }}.

+
+ + Inviter un organisateur +
diff --git a/templates/email/organizer_invitation.html.twig b/templates/email/organizer_invitation.html.twig new file mode 100644 index 0000000..1cf6dda --- /dev/null +++ b/templates/email/organizer_invitation.html.twig @@ -0,0 +1,33 @@ +{% extends 'email/base.html.twig' %} + +{% block title %}Invitation organisateur - E-Ticket{% endblock %} + +{% block content %} +

Vous etes invite !

+

Bonjour {{ invitation.firstName }},

+

L'equipe E-Ticket vous invite a rejoindre la plateforme en tant qu'organisateur pour {{ invitation.companyName }}.

+ + {% if invitation.message %} +
+

{{ invitation.message }}

+
+ {% endif %} + +

E-Ticket est une plateforme de billetterie en ligne qui vous permet de :

+
    +
  • Creer et gerer vos evenements
  • +
  • Vendre des billets en ligne avec paiement securise
  • +
  • Suivre vos ventes et statistiques en temps reel
  • +
  • Generer des billets PDF avec QR code
  • +
+ +

+ Accepter l'invitation +

+ +

+ Non merci, refuser l'invitation +

+ +

Cette invitation a ete envoyee a {{ invitation.email }} le {{ invitation.createdAt|date('d/m/Y') }}. Si vous n'etes pas concerne, ignorez cet email.

+{% endblock %} diff --git a/templates/home/invitation_response.html.twig b/templates/home/invitation_response.html.twig new file mode 100644 index 0000000..7b17947 --- /dev/null +++ b/templates/home/invitation_response.html.twig @@ -0,0 +1,28 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ accepted ? 'Invitation acceptee' : 'Invitation refusee' }} - E-Ticket{% endblock %} + +{% block body %} +
+
+
+ {% if accepted %} +
+

Invitation acceptee

+

Merci {{ invitation.firstName }} !

+

Votre compte organisateur pour {{ invitation.companyName }} sera bientot active. Vous recevrez un email de confirmation.

+ + Decouvrir E-Ticket + + {% else %} +
+

Invitation refusee

+

Nous avons bien pris en compte votre decision. Merci d'avoir pris le temps de repondre.

+ + Retour a l'accueil + + {% endif %} +
+
+
+{% endblock %} diff --git a/tests/Entity/OrganizerInvitationTest.php b/tests/Entity/OrganizerInvitationTest.php new file mode 100644 index 0000000..f5e7904 --- /dev/null +++ b/tests/Entity/OrganizerInvitationTest.php @@ -0,0 +1,85 @@ +getId()); + self::assertNull($inv->getCompanyName()); + self::assertNull($inv->getFirstName()); + self::assertNull($inv->getLastName()); + self::assertNull($inv->getEmail()); + self::assertNull($inv->getMessage()); + self::assertSame(OrganizerInvitation::STATUS_SENT, $inv->getStatus()); + self::assertSame(64, \strlen($inv->getToken())); + self::assertNull($inv->getRespondedAt()); + self::assertInstanceOf(\DateTimeImmutable::class, $inv->getCreatedAt()); + } + + public function testSetAndGetCompanyName(): void + { + $inv = new OrganizerInvitation(); + $result = $inv->setCompanyName('Asso Test'); + + self::assertSame('Asso Test', $inv->getCompanyName()); + self::assertSame($inv, $result); + } + + public function testSetAndGetNames(): void + { + $inv = new OrganizerInvitation(); + $inv->setFirstName('Jean'); + $inv->setLastName('Dupont'); + $inv->setEmail('jean@test.fr'); + + self::assertSame('Jean', $inv->getFirstName()); + self::assertSame('Dupont', $inv->getLastName()); + self::assertSame('jean@test.fr', $inv->getEmail()); + } + + public function testSetAndGetMessage(): void + { + $inv = new OrganizerInvitation(); + $result = $inv->setMessage('Bienvenue !'); + + self::assertSame('Bienvenue !', $inv->getMessage()); + self::assertSame($inv, $result); + + $inv->setMessage(null); + self::assertNull($inv->getMessage()); + } + + public function testSetAndGetStatus(): void + { + $inv = new OrganizerInvitation(); + $result = $inv->setStatus(OrganizerInvitation::STATUS_ACCEPTED); + + self::assertSame(OrganizerInvitation::STATUS_ACCEPTED, $inv->getStatus()); + self::assertSame($inv, $result); + } + + public function testSetAndGetRespondedAt(): void + { + $inv = new OrganizerInvitation(); + $date = new \DateTimeImmutable(); + $result = $inv->setRespondedAt($date); + + self::assertSame($date, $inv->getRespondedAt()); + self::assertSame($inv, $result); + } + + public function testUniqueTokens(): void + { + $inv1 = new OrganizerInvitation(); + $inv2 = new OrganizerInvitation(); + + self::assertNotSame($inv1->getToken(), $inv2->getToken()); + } +}