feat: Permettre la génération et l'envoi de codes d'invitation aux parents

Les administrateurs ont besoin d'un moyen simple pour inviter les parents
à rejoindre la plateforme. Cette fonctionnalité permet de générer des codes
d'invitation uniques (8 caractères alphanumériques) avec une validité de
48h, de les envoyer par email, et de les activer via une page publique
dédiée qui crée automatiquement le compte parent.

L'interface d'administration offre l'envoi unitaire et en masse, le renvoi,
le filtrage par statut, ainsi que la visualisation de l'état de chaque
invitation (en attente, activée, expirée).
This commit is contained in:
2026-02-28 00:08:56 +01:00
parent de5880e25e
commit be1b0b60a6
68 changed files with 8787 additions and 1 deletions

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ActivateParentInvitation;
final readonly class ActivateParentInvitationCommand
{
public function __construct(
public string $code,
public string $firstName,
public string $lastName,
public string $password,
) {
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ActivateParentInvitation;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Exception\ParentInvitationNotFoundException;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Shared\Domain\Clock;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ActivateParentInvitationHandler
{
public function __construct(
private ParentInvitationRepository $invitationRepository,
private PasswordHasher $passwordHasher,
private Clock $clock,
) {
}
/**
* Validates the invitation code and prepares activation data.
* Actual user creation, activation, and linking is done in the Processor.
*
* @throws ParentInvitationNotFoundException if code is invalid
*/
public function __invoke(ActivateParentInvitationCommand $command): ActivateParentInvitationResult
{
$code = new InvitationCode($command->code);
$invitation = $this->invitationRepository->findByCode($code);
if ($invitation === null) {
throw ParentInvitationNotFoundException::withCode($code);
}
$now = $this->clock->now();
// Validate only - does not change state
$invitation->validerPourActivation($now);
$hashedPassword = $this->passwordHasher->hash($command->password);
return new ActivateParentInvitationResult(
invitationId: (string) $invitation->id,
studentId: (string) $invitation->studentId,
parentEmail: (string) $invitation->parentEmail,
tenantId: $invitation->tenantId,
hashedPassword: $hashedPassword,
firstName: $command->firstName,
lastName: $command->lastName,
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ActivateParentInvitation;
use App\Shared\Domain\Tenant\TenantId;
final readonly class ActivateParentInvitationResult
{
public function __construct(
public string $invitationId,
public string $studentId,
public string $parentEmail,
public TenantId $tenantId,
public string $hashedPassword,
public string $firstName,
public string $lastName,
) {
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ResendParentInvitation;
final readonly class ResendParentInvitationCommand
{
public function __construct(
public string $invitationId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ResendParentInvitation;
use App\Administration\Application\Service\InvitationCodeGenerator;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ResendParentInvitationHandler
{
public function __construct(
private ParentInvitationRepository $invitationRepository,
private InvitationCodeGenerator $codeGenerator,
private Clock $clock,
) {
}
public function __invoke(ResendParentInvitationCommand $command): ParentInvitation
{
$tenantId = TenantId::fromString($command->tenantId);
$invitationId = ParentInvitationId::fromString($command->invitationId);
$invitation = $this->invitationRepository->get($invitationId, $tenantId);
$newCode = $this->codeGenerator->generate();
$invitation->renvoyer($newCode, $this->clock->now());
$this->invitationRepository->save($invitation);
return $invitation;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\SendParentInvitation;
final readonly class SendParentInvitationCommand
{
public function __construct(
public string $tenantId,
public string $studentId,
public string $parentEmail,
public string $createdBy,
) {
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\SendParentInvitation;
use App\Administration\Application\Service\InvitationCodeGenerator;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DomainException;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class SendParentInvitationHandler
{
public function __construct(
private ParentInvitationRepository $invitationRepository,
private UserRepository $userRepository,
private InvitationCodeGenerator $codeGenerator,
private Clock $clock,
) {
}
public function __invoke(SendParentInvitationCommand $command): ParentInvitation
{
$tenantId = TenantId::fromString($command->tenantId);
$studentId = UserId::fromString($command->studentId);
$parentEmail = new Email($command->parentEmail);
$createdBy = UserId::fromString($command->createdBy);
$now = $this->clock->now();
// Verify student exists and is actually a student
$student = $this->userRepository->findById($studentId);
if ($student === null || !$student->aLeRole(Role::ELEVE)) {
throw new DomainException('L\'élève spécifié n\'existe pas.');
}
$code = $this->codeGenerator->generate();
$invitation = ParentInvitation::creer(
tenantId: $tenantId,
studentId: $studentId,
parentEmail: $parentEmail,
code: $code,
createdAt: $now,
createdBy: $createdBy,
);
$invitation->envoyer($now);
$this->invitationRepository->save($invitation);
return $invitation;
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentInvitations;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\Invitation\InvitationStatus;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_map;
use function array_slice;
use function array_values;
use function count;
use function mb_strtolower;
use function str_contains;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetParentInvitationsHandler
{
public function __construct(
private ParentInvitationRepository $invitationRepository,
private UserRepository $userRepository,
) {
}
/**
* @return PaginatedResult<ParentInvitationDto>
*/
public function __invoke(GetParentInvitationsQuery $query): PaginatedResult
{
$tenantId = TenantId::fromString($query->tenantId);
$invitations = $this->invitationRepository->findAllByTenant($tenantId);
if ($query->status !== null) {
$filterStatus = InvitationStatus::tryFrom($query->status);
if ($filterStatus !== null) {
$invitations = array_filter(
$invitations,
static fn ($inv) => $inv->status === $filterStatus,
);
}
}
if ($query->studentId !== null) {
$filterStudentId = UserId::fromString($query->studentId);
$invitations = array_filter(
$invitations,
static fn ($inv) => $inv->studentId->equals($filterStudentId),
);
}
// Build a student name cache for search and DTO enrichment
$studentNames = $this->loadStudentNames($invitations);
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$invitations = array_filter(
$invitations,
static function ($inv) use ($searchLower, $studentNames) {
$studentId = (string) $inv->studentId;
$firstName = $studentNames[$studentId]['firstName'] ?? '';
$lastName = $studentNames[$studentId]['lastName'] ?? '';
return str_contains(mb_strtolower((string) $inv->parentEmail), $searchLower)
|| str_contains(mb_strtolower($firstName), $searchLower)
|| str_contains(mb_strtolower($lastName), $searchLower);
},
);
}
$invitations = array_values($invitations);
$total = count($invitations);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($invitations, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
static function ($inv) use ($studentNames) {
$studentId = (string) $inv->studentId;
return ParentInvitationDto::fromDomain(
$inv,
$studentNames[$studentId]['firstName'] ?? null,
$studentNames[$studentId]['lastName'] ?? null,
);
},
$items,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
/**
* @param iterable<\App\Administration\Domain\Model\Invitation\ParentInvitation> $invitations
*
* @return array<string, array{firstName: string, lastName: string}>
*/
private function loadStudentNames(iterable $invitations): array
{
$studentIds = [];
foreach ($invitations as $inv) {
$studentIds[(string) $inv->studentId] = true;
}
$names = [];
foreach ($studentIds as $id => $_) {
try {
$student = $this->userRepository->get(UserId::fromString($id));
$names[$id] = [
'firstName' => $student->firstName,
'lastName' => $student->lastName,
];
} catch (Throwable) {
$names[$id] = ['firstName' => '', 'lastName' => ''];
}
}
return $names;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentInvitations;
use App\Administration\Application\Dto\PaginatedResult;
final readonly class GetParentInvitationsQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
public ?string $status = null,
public ?string $studentId = null,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentInvitations;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use DateTimeImmutable;
final readonly class ParentInvitationDto
{
public function __construct(
public string $id,
public string $studentId,
public string $parentEmail,
public string $status,
public DateTimeImmutable $createdAt,
public DateTimeImmutable $expiresAt,
public ?DateTimeImmutable $sentAt,
public ?DateTimeImmutable $activatedAt,
public ?string $activatedUserId,
public ?string $studentFirstName = null,
public ?string $studentLastName = null,
) {
}
public static function fromDomain(ParentInvitation $invitation, ?string $studentFirstName = null, ?string $studentLastName = null): self
{
return new self(
id: (string) $invitation->id,
studentId: (string) $invitation->studentId,
parentEmail: (string) $invitation->parentEmail,
status: $invitation->status->value,
createdAt: $invitation->createdAt,
expiresAt: $invitation->expiresAt,
sentAt: $invitation->sentAt,
activatedAt: $invitation->activatedAt,
activatedUserId: $invitation->activatedUserId !== null ? (string) $invitation->activatedUserId : null,
studentFirstName: $studentFirstName,
studentLastName: $studentLastName,
);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use function bin2hex;
use function random_bytes;
final readonly class InvitationCodeGenerator
{
/**
* Generates a cryptographically secure 32-character hexadecimal invitation code.
*/
public function generate(): InvitationCode
{
$code = bin2hex(random_bytes(16));
return new InvitationCode($code);
}
}