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,125 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Controller;
use App\Administration\Application\Command\SendParentInvitation\SendParentInvitationCommand;
use App\Administration\Application\Command\SendParentInvitation\SendParentInvitationHandler;
use App\Administration\Infrastructure\Api\Resource\ParentInvitationResource;
use App\Administration\Infrastructure\Security\ParentInvitationVoter;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_key_exists;
use function count;
use DomainException;
use function is_array;
use function is_string;
use function sprintf;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Throwable;
#[Route('/api/parent-invitations/bulk', name: 'api_parent_invitations_bulk', methods: ['POST'])]
#[IsGranted(ParentInvitationVoter::CREATE)]
final class BulkParentInvitationController extends AbstractController
{
private const int MAX_BULK_SIZE = 500;
public function __construct(
private readonly SendParentInvitationHandler $handler,
private readonly TenantContext $tenantContext,
private readonly MessageBusInterface $eventBus,
) {
}
public function __invoke(Request $request): JsonResponse
{
$currentUser = $this->getUser();
if (!$currentUser instanceof SecurityUser) {
return new JsonResponse(['detail' => 'Utilisateur non authentifié.'], Response::HTTP_UNAUTHORIZED);
}
if (!$this->tenantContext->hasTenant()) {
return new JsonResponse(['detail' => 'Tenant non défini.'], Response::HTTP_UNAUTHORIZED);
}
$body = json_decode((string) $request->getContent(), true);
if (!is_array($body) || !array_key_exists('invitations', $body)) {
return new JsonResponse(['detail' => 'Le champ "invitations" est requis.'], Response::HTTP_BAD_REQUEST);
}
$items = $body['invitations'];
if (!is_array($items) || count($items) === 0) {
return new JsonResponse(['detail' => 'La liste d\'invitations est vide.'], Response::HTTP_BAD_REQUEST);
}
if (count($items) > self::MAX_BULK_SIZE) {
return new JsonResponse(
['detail' => sprintf('Maximum %d invitations par requête.', self::MAX_BULK_SIZE)],
Response::HTTP_BAD_REQUEST,
);
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$createdBy = $currentUser->userId();
$results = [];
$errors = [];
/** @var mixed $item */
foreach ($items as $index => $item) {
if (!is_array($item)) {
$errors[] = ['line' => $index + 1, 'error' => 'Format invalide.'];
continue;
}
$studentId = $item['studentId'] ?? null;
$parentEmail = $item['parentEmail'] ?? null;
if (!is_string($studentId) || $studentId === '' || !is_string($parentEmail) || $parentEmail === '') {
$errors[] = ['line' => $index + 1, 'error' => 'Les champs studentId et parentEmail sont requis.'];
continue;
}
try {
$command = new SendParentInvitationCommand(
tenantId: $tenantId,
studentId: $studentId,
parentEmail: $parentEmail,
createdBy: $createdBy,
);
$invitation = ($this->handler)($command);
foreach ($invitation->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
$results[] = ParentInvitationResource::fromDomain($invitation);
} catch (DomainException $e) {
$errors[] = ['line' => $index + 1, 'email' => $parentEmail, 'error' => $e->getMessage()];
} catch (Throwable) {
$errors[] = ['line' => $index + 1, 'email' => $parentEmail, 'error' => 'Erreur interne lors de la création de l\'invitation.'];
}
}
return new JsonResponse([
'created' => count($results),
'errors' => $errors,
'total' => count($items),
], count($errors) > 0 && count($results) === 0 ? Response::HTTP_BAD_REQUEST : Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Controller;
use App\Administration\Application\Service\Import\CsvParser;
use App\Administration\Application\Service\Import\FileParseResult;
use App\Administration\Application\Service\Import\XlsxParser;
use App\Administration\Domain\Exception\FichierImportInvalideException;
use App\Administration\Domain\Model\Import\ParentInvitationImportField;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Domain\Tenant\TenantId;
use const FILTER_VALIDATE_EMAIL;
use function filter_var;
use function in_array;
use InvalidArgumentException;
use function is_array;
use function is_string;
use function mb_strtolower;
use function str_contains;
use function strtolower;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function trim;
/**
* Endpoints pour l'import d'invitations parents via fichier CSV/XLSX.
*
* Approche légère sans batch persistant :
* - analyze : parse le fichier, retourne colonnes + données + mapping suggéré
* - validate : valide les données mappées contre les élèves du tenant
*/
#[Route('/api/import/parents')]
#[IsGranted('ROLE_ADMIN')]
final readonly class ParentInvitationImportController
{
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
public function __construct(
private CsvParser $csvParser,
private XlsxParser $xlsxParser,
private UserRepository $userRepository,
) {
}
/**
* Upload et analyse d'un fichier CSV ou XLSX.
*
* Retourne les colonnes détectées, les données brutes et un mapping suggéré.
*/
#[Route('/analyze', methods: ['POST'], name: 'api_import_parents_analyze')]
public function analyze(
Request $request,
#[CurrentUser] UserInterface $user,
): JsonResponse {
if (!$user instanceof SecurityUser) {
throw new AccessDeniedHttpException();
}
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new BadRequestHttpException('Un fichier CSV ou XLSX est requis.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('Le fichier dépasse la taille maximale de 10 Mo.');
}
$extension = strtolower($file->getClientOriginalExtension());
if (!in_array($extension, ['csv', 'txt', 'xlsx', 'xls'], true)) {
throw new BadRequestHttpException('Extension non supportée. Utilisez CSV ou XLSX.');
}
try {
$parseResult = $this->parseFile($file->getPathname(), $extension);
} catch (FichierImportInvalideException|InvalidArgumentException $e) {
throw new BadRequestHttpException($e->getMessage());
}
$suggestedMapping = $this->suggestMapping($parseResult->columns);
return new JsonResponse([
'columns' => $parseResult->columns,
'rows' => $parseResult->rows,
'totalRows' => $parseResult->totalRows(),
'filename' => $file->getClientOriginalName(),
'suggestedMapping' => $suggestedMapping,
]);
}
/**
* Valide les lignes mappées contre les élèves existants du tenant.
*/
#[Route('/validate', methods: ['POST'], name: 'api_import_parents_validate')]
public function validate(
Request $request,
#[CurrentUser] UserInterface $user,
): JsonResponse {
if (!$user instanceof SecurityUser) {
throw new AccessDeniedHttpException();
}
$tenantId = TenantId::fromString($user->tenantId());
$body = json_decode((string) $request->getContent(), true);
if (!is_array($body) || !isset($body['rows']) || !is_array($body['rows'])) {
throw new BadRequestHttpException('Le champ "rows" est requis.');
}
$students = $this->userRepository->findStudentsByTenant($tenantId);
$validatedRows = [];
$validCount = 0;
$errorCount = 0;
/** @var mixed $row */
foreach ($body['rows'] as $row) {
if (!is_array($row)) {
continue;
}
$studentName = is_string($row['studentName'] ?? null) ? trim($row['studentName']) : '';
$email1 = is_string($row['email1'] ?? null) ? trim($row['email1']) : '';
$email2 = is_string($row['email2'] ?? null) ? trim($row['email2']) : '';
$errors = [];
if ($studentName === '') {
$errors[] = 'Nom élève requis';
}
if ($email1 === '') {
$errors[] = 'Email parent 1 requis';
} elseif (filter_var($email1, FILTER_VALIDATE_EMAIL) === false) {
$errors[] = 'Email parent 1 invalide';
}
if ($email2 !== '' && filter_var($email2, FILTER_VALIDATE_EMAIL) === false) {
$errors[] = 'Email parent 2 invalide';
}
$studentId = null;
$studentMatch = null;
if ($studentName !== '' && $errors === []) {
$matched = $this->matchStudent($studentName, $students);
if ($matched !== null) {
$studentId = (string) $matched->id;
$studentMatch = $matched->firstName . ' ' . $matched->lastName;
} else {
$errors[] = 'Élève "' . $studentName . '" non trouvé';
}
}
$hasError = $errors !== [];
if ($hasError) {
++$errorCount;
} else {
++$validCount;
}
$validatedRows[] = [
'studentName' => $studentName,
'email1' => $email1,
'email2' => $email2,
'studentId' => $studentId,
'studentMatch' => $studentMatch,
'error' => $hasError ? implode(', ', $errors) : null,
];
}
return new JsonResponse([
'validatedRows' => $validatedRows,
'validCount' => $validCount,
'errorCount' => $errorCount,
]);
}
private function parseFile(string $filePath, string $extension): FileParseResult
{
return match ($extension) {
'xlsx', 'xls' => $this->xlsxParser->parse($filePath),
default => $this->csvParser->parse($filePath),
};
}
/**
* @param list<string> $columns
*
* @return array<string, string>
*/
private function suggestMapping(array $columns): array
{
$mapping = [];
$email1Found = false;
foreach ($columns as $column) {
$lower = mb_strtolower($column);
if ($this->isStudentNameColumn($lower) && !isset($mapping[$column])) {
$mapping[$column] = ParentInvitationImportField::STUDENT_NAME->value;
} elseif (str_contains($lower, 'email') || str_contains($lower, 'mail') || str_contains($lower, 'courriel')) {
if (str_contains($lower, '2') || str_contains($lower, 'parent 2')) {
$mapping[$column] = ParentInvitationImportField::EMAIL_2->value;
} elseif (!$email1Found) {
$mapping[$column] = ParentInvitationImportField::EMAIL_1->value;
$email1Found = true;
} else {
$mapping[$column] = ParentInvitationImportField::EMAIL_2->value;
}
}
}
return $mapping;
}
private function isStudentNameColumn(string $lower): bool
{
return str_contains($lower, 'élève')
|| str_contains($lower, 'eleve')
|| str_contains($lower, 'étudiant')
|| str_contains($lower, 'etudiant')
|| str_contains($lower, 'student')
|| $lower === 'nom';
}
/**
* @param User[] $students
*/
private function matchStudent(string $name, array $students): ?User
{
$nameLower = mb_strtolower(trim($name));
if ($nameLower === '') {
return null;
}
// Exact match "LastName FirstName" or "FirstName LastName"
foreach ($students as $student) {
if (trim($student->firstName) === '' && trim($student->lastName) === '') {
continue;
}
$full1 = mb_strtolower($student->lastName . ' ' . $student->firstName);
$full2 = mb_strtolower($student->firstName . ' ' . $student->lastName);
if ($nameLower === $full1 || $nameLower === $full2) {
return $student;
}
}
// Partial match (skip students with empty names)
foreach ($students as $student) {
if (trim($student->firstName) === '' && trim($student->lastName) === '') {
continue;
}
$full1 = mb_strtolower($student->lastName . ' ' . $student->firstName);
$full2 = mb_strtolower($student->firstName . ' ' . $student->lastName);
if (str_contains($full1, $nameLower) || str_contains($full2, $nameLower)
|| str_contains($nameLower, $full1) || str_contains($nameLower, $full2)) {
return $student;
}
}
return null;
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ActivateParentInvitation\ActivateParentInvitationCommand;
use App\Administration\Application\Command\ActivateParentInvitation\ActivateParentInvitationHandler;
use App\Administration\Application\Command\LinkParentToStudent\LinkParentToStudentCommand;
use App\Administration\Application\Command\LinkParentToStudent\LinkParentToStudentHandler;
use App\Administration\Domain\Exception\InvitationCodeInvalideException;
use App\Administration\Domain\Exception\InvitationDejaActiveeException;
use App\Administration\Domain\Exception\InvitationExpireeException;
use App\Administration\Domain\Exception\InvitationNonEnvoyeeException;
use App\Administration\Domain\Exception\ParentInvitationNotFoundException;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Api\Resource\ActivateParentInvitationOutput;
use App\Administration\Infrastructure\Api\Resource\ParentInvitationResource;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantId as InfrastructureTenantId;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use Override;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Throwable;
/**
* Handles parent invitation activation (public endpoint).
*
* Creates a parent account, activates it, and links the parent to the student.
*
* @implements ProcessorInterface<ParentInvitationResource, ActivateParentInvitationOutput>
*/
final readonly class ActivateParentInvitationProcessor implements ProcessorInterface
{
public function __construct(
private ActivateParentInvitationHandler $handler,
private UserRepository $userRepository,
private ParentInvitationRepository $invitationRepository,
private ConsentementParentalPolicy $consentementPolicy,
private LinkParentToStudentHandler $linkHandler,
private TenantRegistry $tenantRegistry,
private Clock $clock,
private MessageBusInterface $eventBus,
private LoggerInterface $logger,
private RateLimiterFactory $parentActivationByIpLimiter,
private RequestStack $requestStack,
) {
}
/**
* @param ParentInvitationResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ActivateParentInvitationOutput
{
// Rate limiting (H5: prevent DoS via bcrypt hashing)
$request = $this->requestStack->getCurrentRequest();
if ($request !== null) {
$ip = $request->getClientIp() ?? 'unknown';
$limiter = $this->parentActivationByIpLimiter->create($ip);
$limit = $limiter->consume();
if (!$limit->isAccepted()) {
throw new TooManyRequestsHttpException(
$limit->getRetryAfter()->getTimestamp() - time(),
'Trop de tentatives. Veuillez réessayer plus tard.',
);
}
}
$command = new ActivateParentInvitationCommand(
code: $data->code ?? '',
firstName: $data->firstName ?? '',
lastName: $data->lastName ?? '',
password: $data->password ?? '',
);
try {
$result = ($this->handler)($command);
} catch (ParentInvitationNotFoundException|InvitationCodeInvalideException) {
throw new NotFoundHttpException('Code d\'invitation invalide ou introuvable.');
} catch (InvitationDejaActiveeException) {
throw new HttpException(Response::HTTP_CONFLICT, 'Cette invitation a déjà été activée.');
} catch (InvitationNonEnvoyeeException) {
throw new BadRequestHttpException('Cette invitation n\'a pas encore été envoyée.');
} catch (InvitationExpireeException) {
throw new HttpException(Response::HTTP_GONE, 'Cette invitation a expiré. Veuillez contacter votre établissement.');
}
$tenantConfig = $this->tenantRegistry->getConfig(
InfrastructureTenantId::fromString((string) $result->tenantId),
);
$now = $this->clock->now();
// Check for duplicate email (H3: prevents duplicate accounts, H4: mitigates race condition)
$existingUser = $this->userRepository->findByEmail(
new Email($result->parentEmail),
$result->tenantId,
);
if ($existingUser !== null) {
throw new BadRequestHttpException('Un compte existe déjà avec cette adresse email.');
}
// Create parent user account
$parentUser = User::inviter(
email: new Email($result->parentEmail),
role: Role::PARENT,
tenantId: $result->tenantId,
schoolName: $tenantConfig->subdomain,
firstName: $result->firstName,
lastName: $result->lastName,
invitedAt: $now,
);
// Clear the UtilisateurInvite event (we don't want to trigger the regular invitation email)
$parentUser->pullDomainEvents();
// Activate the account immediately with the provided password
$parentUser->activer(
hashedPassword: $result->hashedPassword,
at: $now,
consentementPolicy: $this->consentementPolicy,
);
$this->userRepository->save($parentUser);
// Dispatch activation events from User
foreach ($parentUser->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
// Mark invitation as activated
$invitation = $this->invitationRepository->get(
ParentInvitationId::fromString($result->invitationId),
$result->tenantId,
);
$invitation->activer($parentUser->id, $now);
$this->invitationRepository->save($invitation);
foreach ($invitation->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
// Auto-link parent to student (non-fatal failure)
try {
$link = ($this->linkHandler)(new LinkParentToStudentCommand(
studentId: $result->studentId,
guardianId: (string) $parentUser->id,
relationshipType: RelationshipType::OTHER->value,
tenantId: (string) $result->tenantId,
));
foreach ($link->pullDomainEvents() as $linkEvent) {
$this->eventBus->dispatch($linkEvent);
}
} catch (Throwable $e) {
$this->logger->warning('Auto-link parent-élève échoué lors de l\'activation invitation : {message}', [
'message' => $e->getMessage(),
'userId' => (string) $parentUser->id,
'studentId' => $result->studentId,
]);
}
return new ActivateParentInvitationOutput(
userId: (string) $parentUser->id,
email: $result->parentEmail,
);
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ResendParentInvitation\ResendParentInvitationCommand;
use App\Administration\Application\Command\ResendParentInvitation\ResendParentInvitationHandler;
use App\Administration\Domain\Exception\InvitationDejaActiveeException;
use App\Administration\Domain\Exception\ParentInvitationNotFoundException;
use App\Administration\Infrastructure\Api\Resource\ParentInvitationResource;
use App\Administration\Infrastructure\Security\ParentInvitationVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<ParentInvitationResource, ParentInvitationResource>
*/
final readonly class ResendParentInvitationProcessor implements ProcessorInterface
{
public function __construct(
private ResendParentInvitationHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @param ParentInvitationResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ParentInvitationResource
{
if (!$this->authorizationChecker->isGranted(ParentInvitationVoter::RESEND)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à renvoyer une invitation parent.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $invitationId */
$invitationId = $uriVariables['id'] ?? '';
try {
$command = new ResendParentInvitationCommand(
invitationId: $invitationId,
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
);
$invitation = ($this->handler)($command);
foreach ($invitation->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return ParentInvitationResource::fromDomain($invitation);
} catch (ParentInvitationNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (InvitationDejaActiveeException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\SendParentInvitation\SendParentInvitationCommand;
use App\Administration\Application\Command\SendParentInvitation\SendParentInvitationHandler;
use App\Administration\Domain\Exception\EmailInvalideException;
use App\Administration\Infrastructure\Api\Resource\ParentInvitationResource;
use App\Administration\Infrastructure\Security\ParentInvitationVoter;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<ParentInvitationResource, ParentInvitationResource>
*/
final readonly class SendParentInvitationProcessor implements ProcessorInterface
{
public function __construct(
private SendParentInvitationHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private Security $security,
) {
}
/**
* @param ParentInvitationResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ParentInvitationResource
{
if (!$this->authorizationChecker->isGranted(ParentInvitationVoter::CREATE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à envoyer une invitation parent.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$currentUser = $this->security->getUser();
if (!$currentUser instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Utilisateur non authentifié.');
}
try {
$command = new SendParentInvitationCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
studentId: $data->studentId ?? '',
parentEmail: $data->parentEmail ?? '',
createdBy: $currentUser->userId(),
);
$invitation = ($this->handler)($command);
foreach ($invitation->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return ParentInvitationResource::fromDomain($invitation);
} catch (EmailInvalideException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsHandler;
use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsQuery;
use App\Administration\Infrastructure\Api\Resource\ParentInvitationResource;
use App\Administration\Infrastructure\Security\ParentInvitationVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<ParentInvitationResource>
*/
final readonly class ParentInvitationCollectionProvider implements ProviderInterface
{
public function __construct(
private GetParentInvitationsHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
if (!$this->authorizationChecker->isGranted(ParentInvitationVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les invitations parents.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetParentInvitationsQuery(
tenantId: $tenantId,
status: isset($filters['status']) ? (string) $filters['status'] : null,
studentId: isset($filters['studentId']) ? (string) $filters['studentId'] : null,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$result = ($this->handler)($query);
$resources = array_map(ParentInvitationResource::fromDto(...), $result->items);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
final readonly class ActivateParentInvitationOutput
{
public function __construct(
public string $userId,
public string $email,
public string $message = 'Compte parent activé avec succès.',
) {
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use App\Administration\Infrastructure\Api\Processor\ActivateParentInvitationProcessor;
use App\Administration\Infrastructure\Api\Processor\ResendParentInvitationProcessor;
use App\Administration\Infrastructure\Api\Processor\SendParentInvitationProcessor;
use App\Administration\Infrastructure\Api\Provider\ParentInvitationCollectionProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'ParentInvitation',
operations: [
new GetCollection(
uriTemplate: '/parent-invitations',
provider: ParentInvitationCollectionProvider::class,
name: 'get_parent_invitations',
),
new Post(
uriTemplate: '/parent-invitations',
processor: SendParentInvitationProcessor::class,
validationContext: ['groups' => ['Default', 'create']],
name: 'send_parent_invitation',
),
new Post(
uriTemplate: '/parent-invitations/{id}/resend',
processor: ResendParentInvitationProcessor::class,
name: 'resend_parent_invitation',
),
new Post(
uriTemplate: '/parent-invitations/activate',
processor: ActivateParentInvitationProcessor::class,
output: ActivateParentInvitationOutput::class,
validationContext: ['groups' => ['Default', 'activate']],
name: 'activate_parent_invitation',
),
],
)]
final class ParentInvitationResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
#[Assert\NotBlank(message: 'L\'identifiant de l\'élève est requis.', groups: ['create'])]
public ?string $studentId = null;
#[Assert\NotBlank(message: 'L\'email du parent est requis.', groups: ['create'])]
#[Assert\Email(message: 'L\'email n\'est pas valide.')]
public ?string $parentEmail = null;
public ?string $status = null;
public ?DateTimeImmutable $createdAt = null;
public ?DateTimeImmutable $expiresAt = null;
public ?DateTimeImmutable $sentAt = null;
public ?DateTimeImmutable $activatedAt = null;
public ?string $activatedUserId = null;
public ?string $studentFirstName = null;
public ?string $studentLastName = null;
#[ApiProperty(readable: false, writable: true)]
#[Assert\NotBlank(message: 'Le code d\'invitation est requis.', groups: ['activate'])]
public ?string $code = null;
#[ApiProperty(readable: false, writable: true)]
#[Assert\NotBlank(message: 'Le prénom est requis.', groups: ['activate'])]
#[Assert\Length(min: 2, max: 100, minMessage: 'Le prénom doit contenir au moins {{ limit }} caractères.', maxMessage: 'Le prénom ne doit pas dépasser {{ limit }} caractères.', groups: ['activate'])]
public ?string $firstName = null;
#[ApiProperty(readable: false, writable: true)]
#[Assert\NotBlank(message: 'Le nom est requis.', groups: ['activate'])]
#[Assert\Length(min: 2, max: 100, minMessage: 'Le nom doit contenir au moins {{ limit }} caractères.', maxMessage: 'Le nom ne doit pas dépasser {{ limit }} caractères.', groups: ['activate'])]
public ?string $lastName = null;
#[ApiProperty(readable: false, writable: true)]
#[Assert\NotBlank(message: 'Le mot de passe est requis.', groups: ['activate'])]
#[Assert\Length(min: 8, minMessage: 'Le mot de passe doit contenir au moins {{ limit }} caractères.', groups: ['activate'])]
#[Assert\Regex(pattern: '/[A-Z]/', message: 'Le mot de passe doit contenir au moins une majuscule.', groups: ['activate'])]
#[Assert\Regex(pattern: '/[a-z]/', message: 'Le mot de passe doit contenir au moins une minuscule.', groups: ['activate'])]
#[Assert\Regex(pattern: '/[0-9]/', message: 'Le mot de passe doit contenir au moins un chiffre.', groups: ['activate'])]
#[Assert\Regex(pattern: '/[^A-Za-z0-9]/', message: 'Le mot de passe doit contenir au moins un caractère spécial.', groups: ['activate'])]
public ?string $password = null;
public static function fromDomain(ParentInvitation $invitation): self
{
$resource = new self();
$resource->id = (string) $invitation->id;
$resource->studentId = (string) $invitation->studentId;
$resource->parentEmail = (string) $invitation->parentEmail;
$resource->status = $invitation->status->value;
$resource->createdAt = $invitation->createdAt;
$resource->expiresAt = $invitation->expiresAt;
$resource->sentAt = $invitation->sentAt;
$resource->activatedAt = $invitation->activatedAt;
$resource->activatedUserId = $invitation->activatedUserId !== null ? (string) $invitation->activatedUserId : null;
return $resource;
}
public static function fromDto(ParentInvitationDto $dto): self
{
$resource = new self();
$resource->id = $dto->id;
$resource->studentId = $dto->studentId;
$resource->parentEmail = $dto->parentEmail;
$resource->status = $dto->status;
$resource->createdAt = $dto->createdAt;
$resource->expiresAt = $dto->expiresAt;
$resource->sentAt = $dto->sentAt;
$resource->activatedAt = $dto->activatedAt;
$resource->activatedUserId = $dto->activatedUserId;
$resource->studentFirstName = $dto->studentFirstName;
$resource->studentLastName = $dto->studentLastName;
return $resource;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Console;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Shared\Domain\Clock;
use function count;
use Override;
use Psr\Log\LoggerInterface;
use function sprintf;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
/**
* Marque comme expirées les invitations parents envoyées dont la date d'expiration est dépassée.
*
* CRON: 0 6 * * * php bin/console app:expire-parent-invitations
*/
#[AsCommand(
name: 'app:expire-parent-invitations',
description: 'Marque comme expirées les invitations parents dont la date limite est dépassée',
)]
final class ExpireInvitationsCommand extends Command
{
public function __construct(
private readonly ParentInvitationRepository $invitationRepository,
private readonly Clock $clock,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Expiration des invitations parents');
$now = $this->clock->now();
$expiredInvitations = $this->invitationRepository->findExpiredSent($now);
if ($expiredInvitations === []) {
$io->success('Aucune invitation expirée à traiter.');
return Command::SUCCESS;
}
$io->info(sprintf('%d invitation(s) expirée(s) trouvée(s)', count($expiredInvitations)));
$expiredCount = 0;
foreach ($expiredInvitations as $invitation) {
try {
$invitation->marquerExpiree();
$this->invitationRepository->save($invitation);
$this->logger->info('Invitation parent marquée expirée', [
'invitation_id' => (string) $invitation->id,
'tenant_id' => (string) $invitation->tenantId,
'parent_email' => (string) $invitation->parentEmail,
]);
++$expiredCount;
} catch (Throwable $e) {
$io->error(sprintf(
'Erreur pour l\'invitation %s : %s',
$invitation->id,
$e->getMessage(),
));
$this->logger->error('Erreur lors de l\'expiration de l\'invitation', [
'invitation_id' => (string) $invitation->id,
'error' => $e->getMessage(),
]);
}
}
$io->success(sprintf('%d invitation(s) marquée(s) comme expirée(s).', $expiredCount));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\InvitationParentEnvoyee;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Infrastructure\Tenant\TenantUrlBuilder;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
use Twig\Environment;
/**
* Sends a parent invitation email when a parent invitation is created/sent.
*
* Listens for InvitationParentEnvoyee events and sends an email
* with the activation link to the parent.
*/
#[AsMessageHandler(bus: 'event.bus')]
final readonly class SendParentInvitationEmailHandler
{
public function __construct(
private MailerInterface $mailer,
private Environment $twig,
private ParentInvitationRepository $invitationRepository,
private UserRepository $userRepository,
private TenantUrlBuilder $tenantUrlBuilder,
private string $fromEmail = 'noreply@classeo.fr',
) {
}
public function __invoke(InvitationParentEnvoyee $event): void
{
$invitation = $this->invitationRepository->findById(
ParentInvitationId::fromString((string) $event->invitationId),
$event->tenantId,
);
if ($invitation === null) {
return;
}
$student = $this->userRepository->get(UserId::fromString((string) $event->studentId));
$studentName = $student->firstName . ' ' . $student->lastName;
$activationUrl = $this->tenantUrlBuilder->build(
$event->tenantId,
'/parent-activate/' . (string) $invitation->code,
);
$html = $this->twig->render('emails/parent_invitation.html.twig', [
'studentName' => $studentName,
'activationUrl' => $activationUrl,
]);
$email = (new Email())
->from($this->fromEmail)
->to((string) $event->parentEmail)
->subject('Invitation à rejoindre Classeo')
->html($html);
$this->mailer->send($email);
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\ParentInvitationNotFoundException;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use App\Administration\Domain\Model\Invitation\InvitationStatus;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineParentInvitationRepository implements ParentInvitationRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(ParentInvitation $invitation): void
{
$this->connection->executeStatement(
<<<'SQL'
INSERT INTO parent_invitations (
id, tenant_id, student_id, parent_email, code, status,
expires_at, created_at, created_by, sent_at,
activated_at, activated_user_id
)
VALUES (
:id, :tenant_id, :student_id, :parent_email, :code, :status,
:expires_at, :created_at, :created_by, :sent_at,
:activated_at, :activated_user_id
)
ON CONFLICT (id) DO UPDATE SET
code = EXCLUDED.code,
status = EXCLUDED.status,
expires_at = EXCLUDED.expires_at,
sent_at = EXCLUDED.sent_at,
activated_at = EXCLUDED.activated_at,
activated_user_id = EXCLUDED.activated_user_id
SQL,
[
'id' => (string) $invitation->id,
'tenant_id' => (string) $invitation->tenantId,
'student_id' => (string) $invitation->studentId,
'parent_email' => (string) $invitation->parentEmail,
'code' => (string) $invitation->code,
'status' => $invitation->status->value,
'expires_at' => $invitation->expiresAt->format(DateTimeImmutable::ATOM),
'created_at' => $invitation->createdAt->format(DateTimeImmutable::ATOM),
'created_by' => (string) $invitation->createdBy,
'sent_at' => $invitation->sentAt?->format(DateTimeImmutable::ATOM),
'activated_at' => $invitation->activatedAt?->format(DateTimeImmutable::ATOM),
'activated_user_id' => $invitation->activatedUserId !== null ? (string) $invitation->activatedUserId : null,
],
);
}
#[Override]
public function get(ParentInvitationId $id, TenantId $tenantId): ParentInvitation
{
$invitation = $this->findById($id, $tenantId);
if ($invitation === null) {
throw ParentInvitationNotFoundException::withId($id);
}
return $invitation;
}
#[Override]
public function findById(ParentInvitationId $id, TenantId $tenantId): ?ParentInvitation
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM parent_invitations WHERE id = :id AND tenant_id = :tenant_id',
[
'id' => (string) $id,
'tenant_id' => (string) $tenantId,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findByCode(InvitationCode $code): ?ParentInvitation
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM parent_invitations WHERE code = :code',
['code' => (string) $code],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findAllByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM parent_invitations WHERE tenant_id = :tenant_id ORDER BY created_at DESC',
['tenant_id' => (string) $tenantId],
);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
#[Override]
public function findByStudent(UserId $studentId, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM parent_invitations WHERE student_id = :student_id AND tenant_id = :tenant_id ORDER BY created_at DESC',
[
'student_id' => (string) $studentId,
'tenant_id' => (string) $tenantId,
],
);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
#[Override]
public function findByStatus(InvitationStatus $status, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM parent_invitations WHERE status = :status AND tenant_id = :tenant_id ORDER BY created_at DESC',
[
'status' => $status->value,
'tenant_id' => (string) $tenantId,
],
);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
#[Override]
public function findExpiredSent(DateTimeImmutable $at): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM parent_invitations WHERE status = :status AND expires_at <= :at',
[
'status' => InvitationStatus::SENT->value,
'at' => $at->format(DateTimeImmutable::ATOM),
],
);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
#[Override]
public function delete(ParentInvitationId $id, TenantId $tenantId): void
{
$this->connection->executeStatement(
'DELETE FROM parent_invitations WHERE id = :id AND tenant_id = :tenant_id',
[
'id' => (string) $id,
'tenant_id' => (string) $tenantId,
],
);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): ParentInvitation
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $studentId */
$studentId = $row['student_id'];
/** @var string $parentEmail */
$parentEmail = $row['parent_email'];
/** @var string $code */
$code = $row['code'];
/** @var string $status */
$status = $row['status'];
/** @var string $expiresAt */
$expiresAt = $row['expires_at'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $createdBy */
$createdBy = $row['created_by'];
/** @var string|null $sentAt */
$sentAt = $row['sent_at'];
/** @var string|null $activatedAt */
$activatedAt = $row['activated_at'];
/** @var string|null $activatedUserId */
$activatedUserId = $row['activated_user_id'];
return ParentInvitation::reconstitute(
id: ParentInvitationId::fromString($id),
tenantId: TenantId::fromString($tenantId),
studentId: UserId::fromString($studentId),
parentEmail: new Email($parentEmail),
code: new InvitationCode($code),
status: InvitationStatus::from($status),
expiresAt: new DateTimeImmutable($expiresAt),
createdAt: new DateTimeImmutable($createdAt),
createdBy: UserId::fromString($createdBy),
sentAt: $sentAt !== null ? new DateTimeImmutable($sentAt) : null,
activatedAt: $activatedAt !== null ? new DateTimeImmutable($activatedAt) : null,
activatedUserId: $activatedUserId !== null ? UserId::fromString($activatedUserId) : null,
);
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\ParentInvitationNotFoundException;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use App\Administration\Domain\Model\Invitation\InvitationStatus;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_values;
use DateTimeImmutable;
use Override;
final class InMemoryParentInvitationRepository implements ParentInvitationRepository
{
/** @var array<string, ParentInvitation> */
private array $byId = [];
/** @var array<string, ParentInvitation> */
private array $byCode = [];
/** @var array<string, string> Maps invitation ID to its last saved code */
private array $codeIndex = [];
#[Override]
public function save(ParentInvitation $invitation): void
{
$id = (string) $invitation->id;
$newCode = (string) $invitation->code;
// Clean up old code index if code changed since last save
if (isset($this->codeIndex[$id]) && $this->codeIndex[$id] !== $newCode) {
unset($this->byCode[$this->codeIndex[$id]]);
}
$this->byId[$id] = $invitation;
$this->byCode[$newCode] = $invitation;
$this->codeIndex[$id] = $newCode;
}
#[Override]
public function get(ParentInvitationId $id, TenantId $tenantId): ParentInvitation
{
$invitation = $this->findById($id, $tenantId);
if ($invitation === null) {
throw ParentInvitationNotFoundException::withId($id);
}
return $invitation;
}
#[Override]
public function findById(ParentInvitationId $id, TenantId $tenantId): ?ParentInvitation
{
$invitation = $this->byId[(string) $id] ?? null;
if ($invitation === null || !$invitation->tenantId->equals($tenantId)) {
return null;
}
return $invitation;
}
#[Override]
public function findByCode(InvitationCode $code): ?ParentInvitation
{
return $this->byCode[(string) $code] ?? null;
}
#[Override]
public function findAllByTenant(TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (ParentInvitation $inv) => $inv->tenantId->equals($tenantId),
));
}
#[Override]
public function findByStudent(UserId $studentId, TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (ParentInvitation $inv) => $inv->studentId->equals($studentId)
&& $inv->tenantId->equals($tenantId),
));
}
#[Override]
public function findByStatus(InvitationStatus $status, TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (ParentInvitation $inv) => $inv->status === $status
&& $inv->tenantId->equals($tenantId),
));
}
#[Override]
public function findExpiredSent(DateTimeImmutable $at): array
{
return array_values(array_filter(
$this->byId,
static fn (ParentInvitation $inv) => $inv->status === InvitationStatus::SENT
&& $inv->estExpiree($at),
));
}
#[Override]
public function delete(ParentInvitationId $id, TenantId $tenantId): void
{
$invitation = $this->byId[(string) $id] ?? null;
if ($invitation !== null && $invitation->tenantId->equals($tenantId)) {
$idStr = (string) $id;
unset($this->byCode[(string) $invitation->code]);
unset($this->byId[$idStr]);
unset($this->codeIndex[$idStr]);
}
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Api\Resource\ParentInvitationResource;
use function in_array;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Voter pour les autorisations sur la gestion des invitations parents.
*
* Seuls ADMIN et SUPER_ADMIN peuvent gérer les invitations parents.
*
* @extends Voter<string, ParentInvitationResource|null>
*/
final class ParentInvitationVoter extends Voter
{
public const string VIEW = 'PARENT_INVITATION_VIEW';
public const string CREATE = 'PARENT_INVITATION_CREATE';
public const string RESEND = 'PARENT_INVITATION_RESEND';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
self::CREATE,
self::RESEND,
];
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) {
return false;
}
if ($subject === null) {
return true;
}
return $subject instanceof ParentInvitationResource;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
$roles = $user->getRoles();
return match ($attribute) {
self::VIEW => $this->canView($roles),
self::CREATE, self::RESEND => $this->canManage($roles),
default => false,
};
}
/**
* @param string[] $roles
*/
private function canView(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
Role::SECRETARIAT->value,
]);
}
/**
* @param string[] $roles
*/
private function canManage(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/**
* @param string[] $userRoles
* @param string[] $allowedRoles
*/
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
{
foreach ($userRoles as $role) {
if (in_array($role, $allowedRoles, true)) {
return true;
}
}
return false;
}
}