Add organizer invitation system: invite, accept, refuse

- OrganizerInvitation entity: companyName, firstName, lastName, email,
  message, status (sent/opened/accepted/refused), unique token (64 hex chars)
- Admin route /admin/organisateurs/inviter: form + invitation list with status
- Button "Inviter un organisateur" on admin organizers page
- Email with accept/refuse links using unique token
- Public route /invitation/{token}/{action}: accept or refuse without auth
- Response page: confirmation message for accept/refuse
- Migration, PHPStan config, 7 entity tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-22 17:41:31 +01:00
parent 233f3d5067
commit cca5575274
11 changed files with 531 additions and 4 deletions

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260322100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create organizer_invitation table';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@@ -6,7 +6,7 @@ parameters:
- src/Kernel.php - src/Kernel.php
ignoreErrors: 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 reportUnmatched: false
paths: paths:
- src/Entity/EmailTracking.php - src/Entity/EmailTracking.php
@@ -20,6 +20,7 @@ parameters:
- src/Entity/BilletBuyer.php - src/Entity/BilletBuyer.php
- src/Entity/BilletBuyerItem.php - src/Entity/BilletBuyerItem.php
- src/Entity/BilletOrder.php - src/Entity/BilletOrder.php
- src/Entity/OrganizerInvitation.php
- -
message: '#Parameter \#1 \$params of method Stripe\\Service\\.*::create\(\) expects#' message: '#Parameter \#1 \$params of method Stripe\\Service\\.*::create\(\) expects#'
path: src/Controller/OrderController.php path: src/Controller/OrderController.php

View File

@@ -2,6 +2,7 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\OrganizerInvitation;
use App\Entity\User; use App\Entity\User;
use App\Service\MailerService; use App\Service\MailerService;
use App\Service\MeilisearchService; use App\Service\MeilisearchService;
@@ -441,4 +442,68 @@ class AdminController extends AbstractController
'searchQuery' => $searchQuery, '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 <contact@e-cosplay.fr>',
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,
]);
}
} }

View File

@@ -6,6 +6,7 @@ use App\Entity\Billet;
use App\Entity\BilletBuyer; use App\Entity\BilletBuyer;
use App\Entity\BilletOrder; use App\Entity\BilletOrder;
use App\Entity\Category; use App\Entity\Category;
use App\Entity\OrganizerInvitation;
use App\Entity\Event; use App\Entity\Event;
use App\Entity\User; use App\Entity\User;
use App\Service\EventIndexService; use App\Service\EventIndexService;
@@ -236,4 +237,27 @@ class HomeController extends AbstractController
{ {
return $this->render('home/offline.html.twig'); 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,
]);
}
} }

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Entity;
use App\Repository\OrganizerInvitationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: OrganizerInvitationRepository::class)]
class OrganizerInvitation
{
public const STATUS_SENT = 'sent';
public const STATUS_OPENED = 'opened';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_REFUSED = 'refused';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $companyName = null;
#[ORM\Column(length: 255)]
private ?string $firstName = null;
#[ORM\Column(length: 255)]
private ?string $lastName = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $message = null;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_SENT;
#[ORM\Column(length: 64, unique: true)]
private string $token;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $respondedAt = null;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\OrganizerInvitation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<OrganizerInvitation>
*/
class OrganizerInvitationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, OrganizerInvitation::class);
}
}

View File

@@ -0,0 +1,89 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Inviter un organisateur - Admin{% endblock %}
{% block body %}
<div class="w-full md:w-[80%] mx-auto py-12 px-4">
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Inviter un organisateur</h1>
<p class="font-bold text-gray-600 italic mb-8">Envoyer une invitation par email a un futur organisateur.</p>
{% for message in app.flashes('success') %}
<div class="flash-success"><p class="font-black text-sm">{{ message }}</p></div>
{% endfor %}
{% for message in app.flashes('error') %}
<div class="flash-error"><p class="font-black text-sm">{{ message }}</p></div>
{% endfor %}
<div class="card-brutal overflow-hidden mb-8">
<div class="section-header">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Nouvelle invitation</h2>
</div>
<div class="p-6">
<form method="post" action="{{ path('app_admin_invite_organizer') }}" class="form-col">
<div>
<label for="inv_company" class="text-xs font-black uppercase tracking-widest form-label">Raison sociale</label>
<input type="text" id="inv_company" name="company_name" required class="form-input focus:border-indigo-600" placeholder="Association / Entreprise">
</div>
<div class="form-row">
<div class="form-group">
<label for="inv_last_name" class="text-xs font-black uppercase tracking-widest form-label">Nom</label>
<input type="text" id="inv_last_name" name="last_name" required class="form-input focus:border-indigo-600" placeholder="Dupont">
</div>
<div class="form-group">
<label for="inv_first_name" class="text-xs font-black uppercase tracking-widest form-label">Prenom</label>
<input type="text" id="inv_first_name" name="first_name" required class="form-input focus:border-indigo-600" placeholder="Jean">
</div>
</div>
<div>
<label for="inv_email" class="text-xs font-black uppercase tracking-widest form-label">Email</label>
<input type="email" id="inv_email" name="email" required class="form-input focus:border-indigo-600" placeholder="contact@association.fr">
</div>
<div>
<label for="inv_message" class="text-xs font-black uppercase tracking-widest form-label">Message personnalise (optionnel)</label>
<textarea id="inv_message" name="message" rows="4" class="form-input focus:border-indigo-600" placeholder="Bonjour, nous vous invitons a rejoindre E-Ticket..."></textarea>
</div>
<div>
<button type="submit" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Envoyer l'invitation
</button>
</div>
</form>
</div>
</div>
{% if invitations|length > 0 %}
<div class="card-brutal overflow-hidden">
<div class="section-header">
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Invitations envoyees</h2>
</div>
<div class="p-6">
{% for inv in invitations %}
<div class="flex flex-wrap items-center gap-4 py-3 {{ not loop.last ? 'border-b border-gray-200' : '' }}">
<div class="flex-1 min-w-0">
<p class="font-black text-sm">{{ inv.companyName }}</p>
<p class="text-xs font-bold text-gray-500">{{ inv.firstName }} {{ inv.lastName }}{{ inv.email }}</p>
</div>
<span class="text-xs font-bold text-gray-400">{{ inv.createdAt|date('d/m/Y H:i') }}</span>
{% if inv.status == 'sent' %}
<span class="badge-yellow text-[10px] font-black uppercase">Envoyee</span>
{% elseif inv.status == 'opened' %}
<span class="badge-yellow text-[10px] font-black uppercase">Ouverte</span>
{% elseif inv.status == 'accepted' %}
<span class="badge-green text-[10px] font-black uppercase">Acceptee</span>
{% elseif inv.status == 'refused' %}
<span class="badge-red text-[10px] font-black uppercase">Refusee</span>
{% endif %}
{% if inv.respondedAt %}
<span class="text-[10px] font-bold text-gray-400">{{ inv.respondedAt|date('d/m/Y H:i') }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -3,9 +3,14 @@
{% block title %}Organisateurs{% endblock %} {% block title %}Organisateurs{% endblock %}
{% block body %} {% block body %}
<div class="mb-8"> <div class="flex flex-wrap items-center justify-between gap-4 mb-8">
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Organisateurs</h1> <div>
<p class="font-bold text-gray-500 italic">{{ organizers.getTotalItemCount }} organisateur{{ organizers.getTotalItemCount > 1 ? 's' : '' }}.</p> <h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Organisateurs</h1>
<p class="font-bold text-gray-500 italic">{{ organizers.getTotalItemCount }} organisateur{{ organizers.getTotalItemCount > 1 ? 's' : '' }}.</p>
</div>
<a href="{{ path('app_admin_invite_organizer') }}" class="btn-brutal font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Inviter un organisateur
</a>
</div> </div>
<div class="admin-card mb-8"> <div class="admin-card mb-8">

View File

@@ -0,0 +1,33 @@
{% extends 'email/base.html.twig' %}
{% block title %}Invitation organisateur - E-Ticket{% endblock %}
{% block content %}
<h2>Vous etes invite !</h2>
<p>Bonjour {{ invitation.firstName }},</p>
<p>L'equipe E-Ticket vous invite a rejoindre la plateforme en tant qu'organisateur pour <strong>{{ invitation.companyName }}</strong>.</p>
{% if invitation.message %}
<div style="padding: 16px; background: #f9fafb; border-left: 4px solid #fabf04; margin: 20px 0;">
<p style="margin: 0; font-style: italic; color: #374151;">{{ invitation.message }}</p>
</div>
{% endif %}
<p>E-Ticket est une plateforme de billetterie en ligne qui vous permet de :</p>
<ul style="margin: 16px 0; padding-left: 20px;">
<li style="margin-bottom: 8px; font-weight: 700;">Creer et gerer vos evenements</li>
<li style="margin-bottom: 8px; font-weight: 700;">Vendre des billets en ligne avec paiement securise</li>
<li style="margin-bottom: 8px; font-weight: 700;">Suivre vos ventes et statistiques en temps reel</li>
<li style="margin-bottom: 8px; font-weight: 700;">Generer des billets PDF avec QR code</li>
</ul>
<p style="text-align: center; margin: 24px 0;">
<a href="{{ acceptUrl }}" class="btn" style="margin-right: 8px;">Accepter l'invitation</a>
</p>
<p style="text-align: center; font-size: 13px; color: #6b7280;">
<a href="{{ refuseUrl }}" style="color: #6b7280; text-decoration: underline;">Non merci, refuser l'invitation</a>
</p>
<p style="font-size: 12px; color: #9ca3af; margin-top: 24px;">Cette invitation a ete envoyee a {{ invitation.email }} le {{ invitation.createdAt|date('d/m/Y') }}. Si vous n'etes pas concerne, ignorez cet email.</p>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'base.html.twig' %}
{% block title %}{{ accepted ? 'Invitation acceptee' : 'Invitation refusee' }} - E-Ticket{% endblock %}
{% block body %}
<div class="page-container">
<div class="max-w-xl mx-auto text-center">
<div class="card-brutal p-8">
{% if accepted %}
<div class="text-6xl mb-4 text-green-600">&#10003;</div>
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page mb-4">Invitation acceptee</h1>
<p class="font-bold text-gray-600 mb-2">Merci {{ invitation.firstName }} !</p>
<p class="text-sm font-bold text-gray-500 mb-6">Votre compte organisateur pour <strong>{{ invitation.companyName }}</strong> sera bientot active. Vous recevrez un email de confirmation.</p>
<a href="{{ path('app_home') }}" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Decouvrir E-Ticket
</a>
{% else %}
<div class="text-6xl mb-4 text-gray-400">&#10005;</div>
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page mb-4">Invitation refusee</h1>
<p class="font-bold text-gray-600 mb-6">Nous avons bien pris en compte votre decision. Merci d'avoir pris le temps de repondre.</p>
<a href="{{ path('app_home') }}" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-gray-100 transition-all">
Retour a l'accueil
</a>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Tests\Entity;
use App\Entity\OrganizerInvitation;
use PHPUnit\Framework\TestCase;
class OrganizerInvitationTest extends TestCase
{
public function testDefaults(): void
{
$inv = new OrganizerInvitation();
self::assertNull($inv->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());
}
}