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

@@ -52,6 +52,9 @@ framework:
App\Administration\Domain\Event\MotDePasseChange: async
# CompteBloqueTemporairement: sync (SendLockoutAlertHandler = immediate security alert)
# ConnexionReussie, ConnexionEchouee: sync (audit-only, no email)
# Parent invitation events → async (email sending)
App\Administration\Domain\Event\InvitationParentEnvoyee: async
App\Administration\Domain\Event\InvitationParentActivee: async
# Import élèves/enseignants → async (batch processing, peut être long)
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async

View File

@@ -31,3 +31,10 @@ framework:
limit: 10
interval: '1 hour'
cache_pool: cache.rate_limiter
# Limite les tentatives d'activation par IP (protection contre DoS via bcrypt)
parent_activation_by_ip:
policy: sliding_window
limit: 10
interval: '15 minutes'
cache_pool: cache.rate_limiter

View File

@@ -54,7 +54,7 @@ security:
jwt: ~
provider: super_admin_provider
api_public:
pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|password/(forgot|reset)|docs)(/|$)
pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|password/(forgot|reset)|parent-invitations/activate|docs)(/|$)
stateless: true
security: false
api:
@@ -78,6 +78,7 @@ security:
- { path: ^/api/token/logout, roles: PUBLIC_ACCESS }
- { path: ^/api/password/forgot, roles: PUBLIC_ACCESS }
- { path: ^/api/password/reset, roles: PUBLIC_ACCESS }
- { path: ^/api/parent-invitations/activate, roles: PUBLIC_ACCESS }
- { path: ^/api/import, roles: ROLE_ADMIN }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

View File

@@ -225,6 +225,10 @@ services:
App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSavedTeacherColumnMappingRepository
# Parent Invitation Repository (Story 3.3 - Invitation parents)
App\Administration\Domain\Repository\ParentInvitationRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineParentInvitationRepository
# Student Guardian Repository (Story 2.7 - Liaison parents-enfants)
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
arguments:
@@ -251,6 +255,11 @@ services:
$passwordResetByEmailLimiter: '@limiter.password_reset_by_email'
$passwordResetByIpLimiter: '@limiter.password_reset_by_ip'
# Parent Activation Processor with rate limiter
App\Administration\Infrastructure\Api\Processor\ActivateParentInvitationProcessor:
arguments:
$parentActivationByIpLimiter: '@limiter.parent_activation_by_ip'
# Login handlers
App\Administration\Infrastructure\Security\LoginSuccessHandler:
tags:

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260227162304 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create parent_invitations table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE parent_invitations (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
student_id UUID NOT NULL,
parent_email VARCHAR(255) NOT NULL,
code VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
created_by UUID NOT NULL,
sent_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
activated_user_id UUID
)
SQL);
$this->addSql('CREATE INDEX idx_parent_invitations_tenant ON parent_invitations (tenant_id)');
$this->addSql('CREATE INDEX idx_parent_invitations_code ON parent_invitations (code)');
$this->addSql('CREATE INDEX idx_parent_invitations_status ON parent_invitations (status)');
$this->addSql('CREATE INDEX idx_parent_invitations_student ON parent_invitations (student_id)');
$this->addSql('CREATE INDEX idx_parent_invitations_expires ON parent_invitations (status, expires_at)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS parent_invitations');
}
}

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);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class InvitationParentActivee implements DomainEvent
{
public function __construct(
public ParentInvitationId $invitationId,
public UserId $studentId,
public UserId $parentUserId,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->invitationId->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class InvitationParentEnvoyee implements DomainEvent
{
public function __construct(
public ParentInvitationId $invitationId,
public UserId $studentId,
public Email $parentEmail,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->invitationId->value;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
final class InvitationCodeInvalideException extends DomainException
{
public static function pourCode(string $code): self
{
return new self(
'Code d\'invitation invalide. Un code doit être une chaîne hexadécimale de 32 caractères.',
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use DomainException;
use function sprintf;
final class InvitationDejaActiveeException extends DomainException
{
public static function pourInvitation(ParentInvitationId $id): self
{
return new self(sprintf(
'L\'invitation "%s" a déjà été activée.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use DomainException;
use function sprintf;
final class InvitationExpireeException extends DomainException
{
public static function pourInvitation(ParentInvitationId $id): self
{
return new self(sprintf(
'L\'invitation "%s" a expiré.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use DomainException;
use function sprintf;
final class InvitationNonEnvoyeeException extends DomainException
{
public static function pourActivation(ParentInvitationId $id): self
{
return new self(sprintf(
'L\'invitation "%s" ne peut pas être activée car elle n\'a pas encore été envoyée.',
$id,
));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use DomainException;
use function sprintf;
final class ParentInvitationNotFoundException extends DomainException
{
public static function withId(ParentInvitationId $id): self
{
return new self(sprintf(
'Invitation parent "%s" introuvable.',
$id,
));
}
public static function withCode(InvitationCode $code): self
{
return new self('Invitation parent introuvable pour le code fourni.');
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Import;
use function array_filter;
use function array_values;
/**
* Champs Classeo disponibles pour le mapping d'import d'invitations parents.
*/
enum ParentInvitationImportField: string
{
case STUDENT_NAME = 'studentName';
case EMAIL_1 = 'email1';
case EMAIL_2 = 'email2';
public function estObligatoire(): bool
{
return match ($this) {
self::STUDENT_NAME, self::EMAIL_1 => true,
default => false,
};
}
public function label(): string
{
return match ($this) {
self::STUDENT_NAME => 'Nom élève',
self::EMAIL_1 => 'Email parent 1',
self::EMAIL_2 => 'Email parent 2',
};
}
/**
* @return list<self>
*/
public static function champsObligatoires(): array
{
return array_values(array_filter(
self::cases(),
static fn (self $field): bool => $field->estObligatoire(),
));
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Invitation;
use App\Administration\Domain\Exception\InvitationCodeInvalideException;
use function ctype_xdigit;
use function hash_equals;
use function strlen;
/**
* Value Object representing a cryptographically secure invitation code.
*
* Codes are 32-character hexadecimal strings generated from random_bytes(16).
*/
final readonly class InvitationCode
{
private const int EXPECTED_LENGTH = 32;
/** @var non-empty-string */
public string $value;
public function __construct(string $value)
{
if (strlen($value) !== self::EXPECTED_LENGTH || !ctype_xdigit($value)) {
throw InvitationCodeInvalideException::pourCode($value);
}
$this->value = $value;
}
public function equals(self $other): bool
{
return hash_equals($this->value, $other->value);
}
/**
* @return non-empty-string
*/
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Invitation;
enum InvitationStatus: string
{
case PENDING = 'pending';
case SENT = 'sent';
case EXPIRED = 'expired';
case ACTIVATED = 'activated';
public function peutEnvoyer(): bool
{
return $this === self::PENDING || $this === self::EXPIRED;
}
public function peutActiver(): bool
{
return $this === self::SENT;
}
public function peutExpirer(): bool
{
return $this === self::SENT;
}
public function peutRenvoyer(): bool
{
return $this === self::SENT || $this === self::EXPIRED;
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Invitation;
use App\Administration\Domain\Event\InvitationParentActivee;
use App\Administration\Domain\Event\InvitationParentEnvoyee;
use App\Administration\Domain\Exception\InvitationDejaActiveeException;
use App\Administration\Domain\Exception\InvitationExpireeException;
use App\Administration\Domain\Exception\InvitationNonEnvoyeeException;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use function sprintf;
final class ParentInvitation extends AggregateRoot
{
private const int EXPIRATION_DAYS = 7;
public private(set) ?DateTimeImmutable $sentAt = null;
public private(set) ?DateTimeImmutable $activatedAt = null;
public private(set) ?UserId $activatedUserId = null;
private function __construct(
public private(set) ParentInvitationId $id,
public private(set) TenantId $tenantId,
public private(set) UserId $studentId,
public private(set) Email $parentEmail,
public private(set) InvitationCode $code,
public private(set) InvitationStatus $status,
public private(set) DateTimeImmutable $expiresAt,
public private(set) DateTimeImmutable $createdAt,
public private(set) UserId $createdBy,
) {
}
public static function creer(
TenantId $tenantId,
UserId $studentId,
Email $parentEmail,
InvitationCode $code,
DateTimeImmutable $createdAt,
UserId $createdBy,
): self {
return new self(
id: ParentInvitationId::generate(),
tenantId: $tenantId,
studentId: $studentId,
parentEmail: $parentEmail,
code: $code,
status: InvitationStatus::PENDING,
expiresAt: $createdAt->modify(sprintf('+%d days', self::EXPIRATION_DAYS)),
createdAt: $createdAt,
createdBy: $createdBy,
);
}
/**
* @internal For use by Infrastructure layer only
*/
public static function reconstitute(
ParentInvitationId $id,
TenantId $tenantId,
UserId $studentId,
Email $parentEmail,
InvitationCode $code,
InvitationStatus $status,
DateTimeImmutable $expiresAt,
DateTimeImmutable $createdAt,
UserId $createdBy,
?DateTimeImmutable $sentAt,
?DateTimeImmutable $activatedAt,
?UserId $activatedUserId,
): self {
$invitation = new self(
id: $id,
tenantId: $tenantId,
studentId: $studentId,
parentEmail: $parentEmail,
code: $code,
status: $status,
expiresAt: $expiresAt,
createdAt: $createdAt,
createdBy: $createdBy,
);
$invitation->sentAt = $sentAt;
$invitation->activatedAt = $activatedAt;
$invitation->activatedUserId = $activatedUserId;
return $invitation;
}
public function envoyer(DateTimeImmutable $at): void
{
if (!$this->status->peutEnvoyer()) {
throw InvitationDejaActiveeException::pourInvitation($this->id);
}
$this->status = InvitationStatus::SENT;
$this->sentAt = $at;
$this->recordEvent(new InvitationParentEnvoyee(
invitationId: $this->id,
studentId: $this->studentId,
parentEmail: $this->parentEmail,
tenantId: $this->tenantId,
occurredOn: $at,
));
}
/**
* Validate that the invitation can be activated (not expired, not already activated, has been sent).
* Does NOT change state - use activer() after successful user creation.
*
* @throws InvitationDejaActiveeException if already activated
* @throws InvitationNonEnvoyeeException if not yet sent
* @throws InvitationExpireeException if expired
*/
public function validerPourActivation(DateTimeImmutable $at): void
{
if ($this->status === InvitationStatus::ACTIVATED) {
throw InvitationDejaActiveeException::pourInvitation($this->id);
}
if ($this->status !== InvitationStatus::SENT) {
throw InvitationNonEnvoyeeException::pourActivation($this->id);
}
if ($this->estExpiree($at)) {
throw InvitationExpireeException::pourInvitation($this->id);
}
}
public function activer(UserId $parentUserId, DateTimeImmutable $at): void
{
$this->validerPourActivation($at);
$this->status = InvitationStatus::ACTIVATED;
$this->activatedAt = $at;
$this->activatedUserId = $parentUserId;
$this->recordEvent(new InvitationParentActivee(
invitationId: $this->id,
studentId: $this->studentId,
parentUserId: $parentUserId,
tenantId: $this->tenantId,
occurredOn: $at,
));
}
public function marquerExpiree(): void
{
if ($this->status->peutExpirer()) {
$this->status = InvitationStatus::EXPIRED;
}
}
public function renvoyer(InvitationCode $nouveauCode, DateTimeImmutable $at): void
{
if (!$this->status->peutRenvoyer()) {
throw InvitationDejaActiveeException::pourInvitation($this->id);
}
$this->code = $nouveauCode;
$this->status = InvitationStatus::SENT;
$this->sentAt = $at;
$this->expiresAt = $at->modify(sprintf('+%d days', self::EXPIRATION_DAYS));
$this->recordEvent(new InvitationParentEnvoyee(
invitationId: $this->id,
studentId: $this->studentId,
parentEmail: $this->parentEmail,
tenantId: $this->tenantId,
occurredOn: $at,
));
}
public function estExpiree(DateTimeImmutable $at): bool
{
return $at >= $this->expiresAt;
}
public function estActivee(): bool
{
return $this->status === InvitationStatus::ACTIVATED;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\Invitation;
use App\Shared\Domain\EntityId;
final readonly class ParentInvitationId extends EntityId
{
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
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\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
interface ParentInvitationRepository
{
public function save(ParentInvitation $invitation): void;
/**
* @throws ParentInvitationNotFoundException
*/
public function get(ParentInvitationId $id, TenantId $tenantId): ParentInvitation;
public function findById(ParentInvitationId $id, TenantId $tenantId): ?ParentInvitation;
public function findByCode(InvitationCode $code): ?ParentInvitation;
/**
* @return ParentInvitation[]
*/
public function findAllByTenant(TenantId $tenantId): array;
/**
* @return ParentInvitation[]
*/
public function findByStudent(UserId $studentId, TenantId $tenantId): array;
/**
* @return ParentInvitation[]
*/
public function findByStatus(InvitationStatus $status, TenantId $tenantId): array;
/**
* @return ParentInvitation[]
*/
public function findExpiredSent(DateTimeImmutable $at): array;
public function delete(ParentInvitationId $id, TenantId $tenantId): void;
}

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;
}
}

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invitation Parent - Classeo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
padding: 20px 0;
border-bottom: 2px solid #4f46e5;
}
.header h1 {
color: #4f46e5;
margin: 0;
font-size: 28px;
}
.content {
padding: 30px 0;
}
.info-box {
background-color: #f3f4f6;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.info-box p {
margin: 5px 0;
}
.button {
display: inline-block;
background-color: #4f46e5;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
}
.button:hover {
background-color: #4338ca;
}
.footer {
text-align: center;
padding: 20px 0;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 14px;
}
.warning {
background-color: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px 16px;
margin: 20px 0;
border-radius: 0 8px 8px 0;
}
</style>
</head>
<body>
<div class="header">
<h1>Classeo</h1>
</div>
<div class="content">
<h2>Invitation à rejoindre Classeo</h2>
<p>Bonjour,</p>
<p>Vous êtes invité(e) à rejoindre Classeo en tant que <strong>parent</strong> de <strong>{{ studentName }}</strong>.</p>
<div class="info-box">
<p>Cliquez sur le bouton ci-dessous pour créer votre compte et accéder aux informations scolaires de votre enfant.</p>
</div>
<p style="text-align: center; margin: 30px 0;">
<a href="{{ activationUrl }}" class="button">Créer mon compte</a>
</p>
<div class="warning">
<p><strong>Ce lien expire dans 7 jours.</strong></p>
<p>Si vous ne pouvez pas cliquer sur le bouton, copiez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; font-size: 12px;">{{ activationUrl }}</p>
</div>
</div>
<div class="footer">
<p>Cet email a été envoyé automatiquement par Classeo.</p>
<p>Si vous n'attendiez pas cette invitation, vous pouvez ignorer cet email.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ActivateParentInvitation;
use App\Administration\Application\Command\ActivateParentInvitation\ActivateParentInvitationCommand;
use App\Administration\Application\Command\ActivateParentInvitation\ActivateParentInvitationHandler;
use App\Administration\Application\Port\PasswordHasher;
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\InvitationCode;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ActivateParentInvitationHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string CODE = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
private InMemoryParentInvitationRepository $repository;
private ActivateParentInvitationHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryParentInvitationRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-08 10:00:00');
}
};
$passwordHasher = new class implements PasswordHasher {
public function hash(string $plainPassword): string
{
return 'hashed_' . $plainPassword;
}
public function verify(string $hashedPassword, string $plainPassword): bool
{
return $hashedPassword === 'hashed_' . $plainPassword;
}
};
$this->handler = new ActivateParentInvitationHandler(
$this->repository,
$passwordHasher,
$clock,
);
}
#[Test]
public function itValidatesAndReturnsActivationResult(): void
{
$invitation = $this->createSentInvitation();
$result = ($this->handler)(new ActivateParentInvitationCommand(
code: self::CODE,
firstName: 'Jean',
lastName: 'Parent',
password: 'SecurePass123!',
));
self::assertSame((string) $invitation->id, $result->invitationId);
self::assertSame((string) $invitation->studentId, $result->studentId);
self::assertSame('parent@example.com', $result->parentEmail);
self::assertSame('hashed_SecurePass123!', $result->hashedPassword);
self::assertSame('Jean', $result->firstName);
self::assertSame('Parent', $result->lastName);
}
#[Test]
public function itThrowsWhenCodeNotFound(): void
{
$this->expectException(ParentInvitationNotFoundException::class);
($this->handler)(new ActivateParentInvitationCommand(
code: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
firstName: 'Jean',
lastName: 'Parent',
password: 'SecurePass123!',
));
}
#[Test]
public function itThrowsWhenInvitationNotSent(): void
{
$this->createPendingInvitation();
$this->expectException(InvitationNonEnvoyeeException::class);
($this->handler)(new ActivateParentInvitationCommand(
code: self::CODE,
firstName: 'Jean',
lastName: 'Parent',
password: 'SecurePass123!',
));
}
#[Test]
public function itThrowsWhenInvitationExpired(): void
{
$this->createExpiredInvitation();
$this->expectException(InvitationExpireeException::class);
($this->handler)(new ActivateParentInvitationCommand(
code: self::CODE,
firstName: 'Jean',
lastName: 'Parent',
password: 'SecurePass123!',
));
}
#[Test]
public function itThrowsWhenInvitationAlreadyActivated(): void
{
$this->createActivatedInvitation();
$this->expectException(InvitationDejaActiveeException::class);
($this->handler)(new ActivateParentInvitationCommand(
code: self::CODE,
firstName: 'Jean',
lastName: 'Parent',
password: 'SecurePass123!',
));
}
private function createSentInvitation(): ParentInvitation
{
$invitation = ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::generate(),
parentEmail: new Email('parent@example.com'),
code: new InvitationCode(self::CODE),
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
createdBy: UserId::generate(),
);
$invitation->envoyer(new DateTimeImmutable('2026-02-07 10:00:00'));
$invitation->pullDomainEvents();
$this->repository->save($invitation);
return $invitation;
}
private function createPendingInvitation(): ParentInvitation
{
$invitation = ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::generate(),
parentEmail: new Email('parent@example.com'),
code: new InvitationCode(self::CODE),
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
createdBy: UserId::generate(),
);
$invitation->pullDomainEvents();
$this->repository->save($invitation);
return $invitation;
}
private function createExpiredInvitation(): ParentInvitation
{
$invitation = ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::generate(),
parentEmail: new Email('parent@example.com'),
code: new InvitationCode(self::CODE),
createdAt: new DateTimeImmutable('2026-01-01 10:00:00'),
createdBy: UserId::generate(),
);
$invitation->envoyer(new DateTimeImmutable('2026-01-01 10:00:00'));
$invitation->pullDomainEvents();
$this->repository->save($invitation);
return $invitation;
}
private function createActivatedInvitation(): ParentInvitation
{
$invitation = ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::generate(),
parentEmail: new Email('parent@example.com'),
code: new InvitationCode(self::CODE),
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
createdBy: UserId::generate(),
);
$invitation->envoyer(new DateTimeImmutable('2026-02-07 10:00:00'));
$invitation->activer(UserId::generate(), new DateTimeImmutable('2026-02-07 12:00:00'));
$invitation->pullDomainEvents();
$this->repository->save($invitation);
return $invitation;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ResendParentInvitation;
use App\Administration\Application\Command\ResendParentInvitation\ResendParentInvitationCommand;
use App\Administration\Application\Command\ResendParentInvitation\ResendParentInvitationHandler;
use App\Administration\Application\Service\InvitationCodeGenerator;
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\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ResendParentInvitationHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryParentInvitationRepository $repository;
private ResendParentInvitationHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryParentInvitationRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-14 10:00:00');
}
};
$this->handler = new ResendParentInvitationHandler(
$this->repository,
new InvitationCodeGenerator(),
$clock,
);
}
#[Test]
public function itResendsInvitationWithNewCode(): void
{
$invitation = $this->createSentInvitation();
$oldCode = (string) $invitation->code;
$result = ($this->handler)(new ResendParentInvitationCommand(
invitationId: (string) $invitation->id,
tenantId: self::TENANT_ID,
));
self::assertSame(InvitationStatus::SENT, $result->status);
self::assertNotSame($oldCode, (string) $result->code);
}
#[Test]
public function itUpdatesExpirationDate(): void
{
$invitation = $this->createSentInvitation();
$oldExpiresAt = $invitation->expiresAt;
$result = ($this->handler)(new ResendParentInvitationCommand(
invitationId: (string) $invitation->id,
tenantId: self::TENANT_ID,
));
self::assertGreaterThan($oldExpiresAt, $result->expiresAt);
}
private function createSentInvitation(): ParentInvitation
{
$invitation = ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::generate(),
parentEmail: new Email('parent@example.com'),
code: new InvitationCode(str_repeat('a', 32)),
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
createdBy: UserId::generate(),
);
$invitation->envoyer(new DateTimeImmutable('2026-02-07 10:00:00'));
$invitation->pullDomainEvents();
$this->repository->save($invitation);
return $invitation;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\SendParentInvitation;
use App\Administration\Application\Command\SendParentInvitation\SendParentInvitationCommand;
use App\Administration\Application\Command\SendParentInvitation\SendParentInvitationHandler;
use App\Administration\Application\Service\InvitationCodeGenerator;
use App\Administration\Domain\Event\InvitationParentEnvoyee;
use App\Administration\Domain\Model\Invitation\InvitationStatus;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SendParentInvitationHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryParentInvitationRepository $repository;
private InMemoryUserRepository $userRepository;
private SendParentInvitationHandler $handler;
private string $studentId;
protected function setUp(): void
{
$this->repository = new InMemoryParentInvitationRepository();
$this->userRepository = new InMemoryUserRepository();
$this->studentId = (string) UserId::generate();
$student = User::reconstitute(
id: UserId::fromString($this->studentId),
email: null,
roles: [Role::ELEVE],
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'Test School',
statut: StatutCompte::INSCRIT,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-01'),
hashedPassword: null,
activatedAt: null,
consentementParental: null,
firstName: 'Camille',
lastName: 'Test',
);
$this->userRepository->save($student);
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-07 10:00:00');
}
};
$this->handler = new SendParentInvitationHandler(
$this->repository,
$this->userRepository,
new InvitationCodeGenerator(),
$clock,
);
}
#[Test]
public function itCreatesAndSendsInvitation(): void
{
$createdBy = (string) UserId::generate();
$invitation = ($this->handler)(new SendParentInvitationCommand(
tenantId: self::TENANT_ID,
studentId: $this->studentId,
parentEmail: 'parent@example.com',
createdBy: $createdBy,
));
self::assertSame(InvitationStatus::SENT, $invitation->status);
self::assertSame('parent@example.com', (string) $invitation->parentEmail);
self::assertSame($this->studentId, (string) $invitation->studentId);
self::assertNotNull($invitation->sentAt);
}
#[Test]
public function itPersistsTheInvitation(): void
{
$createdBy = (string) UserId::generate();
$invitation = ($this->handler)(new SendParentInvitationCommand(
tenantId: self::TENANT_ID,
studentId: $this->studentId,
parentEmail: 'parent@example.com',
createdBy: $createdBy,
));
$found = $this->repository->findById($invitation->id, TenantId::fromString(self::TENANT_ID));
self::assertNotNull($found);
self::assertSame((string) $invitation->id, (string) $found->id);
}
#[Test]
public function itRecordsInvitationSentEvent(): void
{
$createdBy = (string) UserId::generate();
$invitation = ($this->handler)(new SendParentInvitationCommand(
tenantId: self::TENANT_ID,
studentId: $this->studentId,
parentEmail: 'parent@example.com',
createdBy: $createdBy,
));
$events = $invitation->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(InvitationParentEnvoyee::class, $events[0]);
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetParentInvitations;
use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsHandler;
use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsQuery;
use App\Administration\Domain\Model\Invitation\InvitationCode;
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\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetParentInvitationsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
private InMemoryParentInvitationRepository $invitationRepository;
private InMemoryUserRepository $userRepository;
private GetParentInvitationsHandler $handler;
protected function setUp(): void
{
$this->invitationRepository = new InMemoryParentInvitationRepository();
$this->userRepository = new InMemoryUserRepository();
$this->handler = new GetParentInvitationsHandler(
$this->invitationRepository,
$this->userRepository,
);
}
#[Test]
public function itReturnsAllInvitationsForTenant(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
$this->createAndSaveInvitation($student->id, 'parent1@example.com');
$this->createAndSaveInvitation($student->id, 'parent2@example.com');
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
));
self::assertSame(2, $result->total);
self::assertCount(2, $result->items);
}
#[Test]
public function itFiltersInvitationsByStatus(): void
{
$student = $this->createAndSaveStudent('Bob', 'Martin');
$invitation = $this->createAndSaveInvitation($student->id, 'parent@example.com');
$this->createPendingInvitation($student->id, 'parent2@example.com');
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
status: 'sent',
));
self::assertSame(1, $result->total);
self::assertSame('parent@example.com', $result->items[0]->parentEmail);
}
#[Test]
public function itFiltersInvitationsByStudentId(): void
{
$student1 = $this->createAndSaveStudent('Alice', 'Dupont');
$student2 = $this->createAndSaveStudent('Bob', 'Martin');
$this->createAndSaveInvitation($student1->id, 'parent1@example.com');
$this->createAndSaveInvitation($student2->id, 'parent2@example.com');
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
studentId: (string) $student1->id,
));
self::assertSame(1, $result->total);
self::assertSame('parent1@example.com', $result->items[0]->parentEmail);
}
#[Test]
public function itSearchesByParentEmailOrStudentName(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
$this->createAndSaveInvitation($student->id, 'parent@example.com');
$this->createAndSaveInvitation($student->id, 'other@example.com');
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
search: 'Alice',
));
self::assertSame(2, $result->total);
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
search: 'parent@',
));
self::assertSame(1, $result->total);
}
#[Test]
public function itPaginatesResults(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
for ($i = 0; $i < 5; ++$i) {
$this->createAndSaveInvitation($student->id, "parent{$i}@example.com");
}
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
page: 1,
limit: 2,
));
self::assertSame(5, $result->total);
self::assertCount(2, $result->items);
}
#[Test]
public function itEnrichesResultsWithStudentNames(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
$this->createAndSaveInvitation($student->id, 'parent@example.com');
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
));
self::assertSame('Alice', $result->items[0]->studentFirstName);
self::assertSame('Dupont', $result->items[0]->studentLastName);
}
#[Test]
public function itIsolatesByTenant(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
$this->createAndSaveInvitation($student->id, 'parent@example.com');
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::OTHER_TENANT_ID,
));
self::assertSame(0, $result->total);
}
private function createAndSaveStudent(string $firstName, string $lastName): User
{
$student = User::inviter(
email: new Email($firstName . '@example.com'),
role: Role::ELEVE,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Alpha',
firstName: $firstName,
lastName: $lastName,
invitedAt: new DateTimeImmutable('2026-02-07 10:00:00'),
);
$student->pullDomainEvents();
$this->userRepository->save($student);
return $student;
}
private function createAndSaveInvitation(UserId $studentId, string $parentEmail): ParentInvitation
{
$code = bin2hex(random_bytes(16));
$invitation = ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: $studentId,
parentEmail: new Email($parentEmail),
code: new InvitationCode($code),
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
createdBy: UserId::generate(),
);
$invitation->envoyer(new DateTimeImmutable('2026-02-07 10:00:00'));
$invitation->pullDomainEvents();
$this->invitationRepository->save($invitation);
return $invitation;
}
private function createPendingInvitation(UserId $studentId, string $parentEmail): ParentInvitation
{
$code = bin2hex(random_bytes(16));
$invitation = ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: $studentId,
parentEmail: new Email($parentEmail),
code: new InvitationCode($code),
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
createdBy: UserId::generate(),
);
$invitation->pullDomainEvents();
$this->invitationRepository->save($invitation);
return $invitation;
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Import;
use App\Administration\Application\Service\Import\CsvParser;
use App\Administration\Domain\Model\Import\ParentInvitationImportField;
use function mb_strtolower;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use function str_contains;
/**
* Test d'intégration de la chaîne d'import invitations parents avec de vrais fichiers CSV.
*
* Parse → Mapping suggestion → Validation des champs
*/
final class ParentImportIntegrationTest extends TestCase
{
#[Test]
public function parseSimpleParentCsv(): void
{
$parser = new CsvParser();
$parseResult = $parser->parse($this->fixture('parents_simple.csv'));
self::assertSame(['Nom élève', 'Email parent 1', 'Email parent 2'], $parseResult->columns);
self::assertSame(3, $parseResult->totalRows());
self::assertSame('Dupont Alice', $parseResult->rows[0]['Nom élève']);
self::assertSame('alice.parent1@email.com', $parseResult->rows[0]['Email parent 1']);
self::assertSame('alice.parent2@email.com', $parseResult->rows[0]['Email parent 2']);
}
#[Test]
public function parseCommaSeparatedParentCsv(): void
{
$parser = new CsvParser();
$parseResult = $parser->parse($this->fixture('parents_comma.csv'));
self::assertSame(['Nom élève', 'Email parent 1', 'Email parent 2'], $parseResult->columns);
self::assertSame(2, $parseResult->totalRows());
self::assertSame('Dupont Alice', $parseResult->rows[0]['Nom élève']);
}
#[Test]
public function suggestMappingForParentColumns(): void
{
$parser = new CsvParser();
$parseResult = $parser->parse($this->fixture('parents_simple.csv'));
$mapping = $this->suggestMapping($parseResult->columns);
self::assertSame(ParentInvitationImportField::STUDENT_NAME->value, $mapping['Nom élève']);
self::assertSame(ParentInvitationImportField::EMAIL_1->value, $mapping['Email parent 1']);
self::assertSame(ParentInvitationImportField::EMAIL_2->value, $mapping['Email parent 2']);
}
#[Test]
public function completParentCsvHasExpectedStructure(): void
{
$parser = new CsvParser();
$parseResult = $parser->parse($this->fixture('parents_complet.csv'));
self::assertSame(8, $parseResult->totalRows());
// Ligne 3 : Bernard Pierre — email1 manquant
self::assertSame('Bernard Pierre', $parseResult->rows[2]['Nom élève']);
self::assertSame('', $parseResult->rows[2]['Email parent 1']);
// Ligne 4 : nom élève manquant
self::assertSame('', $parseResult->rows[3]['Nom élève']);
self::assertSame('orphelin@email.com', $parseResult->rows[3]['Email parent 1']);
// Ligne 5 : email invalide
self::assertSame('invalide-email', $parseResult->rows[4]['Email parent 1']);
}
#[Test]
public function requiredFieldsAreCorrect(): void
{
$required = ParentInvitationImportField::champsObligatoires();
self::assertCount(2, $required);
self::assertContains(ParentInvitationImportField::STUDENT_NAME, $required);
self::assertContains(ParentInvitationImportField::EMAIL_1, $required);
}
#[Test]
public function email2IsOptional(): void
{
self::assertFalse(ParentInvitationImportField::EMAIL_2->estObligatoire());
}
/**
* Reproduit la logique de suggestMapping du controller pour pouvoir la tester.
*
* @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';
}
private function fixture(string $filename): string
{
return __DIR__ . '/../../../../../fixtures/import/' . $filename;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service;
use App\Administration\Application\Service\InvitationCodeGenerator;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use function strlen;
final class InvitationCodeGeneratorTest extends TestCase
{
private InvitationCodeGenerator $generator;
protected function setUp(): void
{
$this->generator = new InvitationCodeGenerator();
}
#[Test]
public function generateReturnsInvitationCode(): void
{
$code = $this->generator->generate();
self::assertInstanceOf(InvitationCode::class, $code);
}
#[Test]
public function generateReturns32CharacterHexCode(): void
{
$code = $this->generator->generate();
self::assertSame(32, strlen($code->value));
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $code->value);
}
#[Test]
public function generateProducesUniqueCodesEachTime(): void
{
$code1 = $this->generator->generate();
$code2 = $this->generator->generate();
self::assertFalse($code1->equals($code2));
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Invitation;
use App\Administration\Domain\Exception\InvitationCodeInvalideException;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class InvitationCodeTest extends TestCase
{
#[Test]
public function constructWithValidCodeCreatesInstance(): void
{
$code = new InvitationCode('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4');
self::assertSame('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', $code->value);
}
#[Test]
public function constructWithEmptyStringThrowsException(): void
{
$this->expectException(InvitationCodeInvalideException::class);
new InvitationCode('');
}
#[Test]
public function constructWithTooShortCodeThrowsException(): void
{
$this->expectException(InvitationCodeInvalideException::class);
new InvitationCode('abc123');
}
#[Test]
public function constructWithTooLongCodeThrowsException(): void
{
$this->expectException(InvitationCodeInvalideException::class);
new InvitationCode('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4extra');
}
#[Test]
public function equalsReturnsTrueForSameValue(): void
{
$code1 = new InvitationCode('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4');
$code2 = new InvitationCode('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4');
self::assertTrue($code1->equals($code2));
}
#[Test]
public function equalsReturnsFalseForDifferentValue(): void
{
$code1 = new InvitationCode('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4');
$code2 = new InvitationCode('11111111111111111111111111111111');
self::assertFalse($code1->equals($code2));
}
#[Test]
public function toStringReturnsValue(): void
{
$code = new InvitationCode('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4');
self::assertSame('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', (string) $code);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Invitation;
use App\Administration\Domain\Model\Invitation\InvitationStatus;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class InvitationStatusTest extends TestCase
{
#[Test]
public function pendingPeutEnvoyer(): void
{
self::assertTrue(InvitationStatus::PENDING->peutEnvoyer());
}
#[Test]
public function sentNePeutPasEnvoyer(): void
{
self::assertFalse(InvitationStatus::SENT->peutEnvoyer());
}
#[Test]
public function expiredPeutEnvoyer(): void
{
self::assertTrue(InvitationStatus::EXPIRED->peutEnvoyer());
}
#[Test]
public function activatedNePeutPasEnvoyer(): void
{
self::assertFalse(InvitationStatus::ACTIVATED->peutEnvoyer());
}
#[Test]
public function sentPeutActiver(): void
{
self::assertTrue(InvitationStatus::SENT->peutActiver());
}
#[Test]
public function pendingNePeutPasActiver(): void
{
self::assertFalse(InvitationStatus::PENDING->peutActiver());
}
#[Test]
public function expiredNePeutPasActiver(): void
{
self::assertFalse(InvitationStatus::EXPIRED->peutActiver());
}
#[Test]
public function activatedNePeutPasActiver(): void
{
self::assertFalse(InvitationStatus::ACTIVATED->peutActiver());
}
#[Test]
public function sentPeutExpirer(): void
{
self::assertTrue(InvitationStatus::SENT->peutExpirer());
}
#[Test]
public function pendingNePeutPasExpirer(): void
{
self::assertFalse(InvitationStatus::PENDING->peutExpirer());
}
#[Test]
public function sentPeutRenvoyer(): void
{
self::assertTrue(InvitationStatus::SENT->peutRenvoyer());
}
#[Test]
public function expiredPeutRenvoyer(): void
{
self::assertTrue(InvitationStatus::EXPIRED->peutRenvoyer());
}
#[Test]
public function pendingNePeutPasRenvoyer(): void
{
self::assertFalse(InvitationStatus::PENDING->peutRenvoyer());
}
#[Test]
public function activatedNePeutPasRenvoyer(): void
{
self::assertFalse(InvitationStatus::ACTIVATED->peutRenvoyer());
}
#[Test]
public function backingValuesAreCorrect(): void
{
self::assertSame('pending', InvitationStatus::PENDING->value);
self::assertSame('sent', InvitationStatus::SENT->value);
self::assertSame('expired', InvitationStatus::EXPIRED->value);
self::assertSame('activated', InvitationStatus::ACTIVATED->value);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Invitation;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ParentInvitationIdTest extends TestCase
{
#[Test]
public function generateCreatesUniqueIds(): void
{
$id1 = ParentInvitationId::generate();
$id2 = ParentInvitationId::generate();
self::assertFalse($id1->equals($id2));
}
#[Test]
public function fromStringCreatesIdFromString(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440001';
$id = ParentInvitationId::fromString($uuid);
self::assertSame($uuid, (string) $id);
}
#[Test]
public function equalsReturnsTrueForSameId(): void
{
$uuid = '550e8400-e29b-41d4-a716-446655440001';
$id1 = ParentInvitationId::fromString($uuid);
$id2 = ParentInvitationId::fromString($uuid);
self::assertTrue($id1->equals($id2));
}
}

View File

@@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Invitation;
use App\Administration\Domain\Event\InvitationParentActivee;
use App\Administration\Domain\Event\InvitationParentEnvoyee;
use App\Administration\Domain\Exception\InvitationDejaActiveeException;
use App\Administration\Domain\Exception\InvitationExpireeException;
use App\Administration\Domain\Exception\InvitationNonEnvoyeeException;
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\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ParentInvitationTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string CREATED_BY_ID = '550e8400-e29b-41d4-a716-446655440003';
private const string PARENT_EMAIL = 'parent@example.com';
private const string CODE = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4';
#[Test]
public function creerCreatesInvitationWithCorrectProperties(): void
{
$invitation = $this->creerInvitation();
self::assertInstanceOf(ParentInvitationId::class, $invitation->id);
self::assertTrue(TenantId::fromString(self::TENANT_ID)->equals($invitation->tenantId));
self::assertTrue(UserId::fromString(self::STUDENT_ID)->equals($invitation->studentId));
self::assertSame(self::PARENT_EMAIL, (string) $invitation->parentEmail);
self::assertSame(self::CODE, (string) $invitation->code);
self::assertSame(InvitationStatus::PENDING, $invitation->status);
self::assertNull($invitation->sentAt);
self::assertNull($invitation->activatedAt);
self::assertNull($invitation->activatedUserId);
}
#[Test]
public function creerSetsExpirationTo7DaysAfterCreation(): void
{
$createdAt = new DateTimeImmutable('2026-02-20 10:00:00');
$expectedExpiration = new DateTimeImmutable('2026-02-27 10:00:00');
$invitation = $this->creerInvitation(createdAt: $createdAt);
self::assertEquals($expectedExpiration, $invitation->expiresAt);
}
#[Test]
public function envoyerChangesStatusToSent(): void
{
$invitation = $this->creerInvitation();
$sentAt = new DateTimeImmutable('2026-02-20 11:00:00');
$invitation->envoyer($sentAt);
self::assertSame(InvitationStatus::SENT, $invitation->status);
self::assertEquals($sentAt, $invitation->sentAt);
}
#[Test]
public function envoyerRecordsInvitationParentEnvoyeeEvent(): void
{
$invitation = $this->creerInvitation();
$sentAt = new DateTimeImmutable('2026-02-20 11:00:00');
$invitation->envoyer($sentAt);
$events = $invitation->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(InvitationParentEnvoyee::class, $events[0]);
}
#[Test]
public function envoyerThrowsExceptionWhenAlreadyActivated(): void
{
$invitation = $this->creerInvitationActivee();
$this->expectException(InvitationDejaActiveeException::class);
$invitation->envoyer(new DateTimeImmutable('2026-02-21 10:00:00'));
}
#[Test]
public function activerChangesStatusToActivated(): void
{
$invitation = $this->creerInvitationEnvoyee();
$parentUserId = UserId::generate();
$activatedAt = new DateTimeImmutable('2026-02-21 10:00:00');
$invitation->activer($parentUserId, $activatedAt);
self::assertSame(InvitationStatus::ACTIVATED, $invitation->status);
self::assertEquals($activatedAt, $invitation->activatedAt);
self::assertTrue($parentUserId->equals($invitation->activatedUserId));
}
#[Test]
public function activerRecordsInvitationParentActiveeEvent(): void
{
$invitation = $this->creerInvitationEnvoyee();
$parentUserId = UserId::generate();
$activatedAt = new DateTimeImmutable('2026-02-21 10:00:00');
$invitation->pullDomainEvents();
$invitation->activer($parentUserId, $activatedAt);
$events = $invitation->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(InvitationParentActivee::class, $events[0]);
}
#[Test]
public function activerThrowsExceptionWhenAlreadyActivated(): void
{
$invitation = $this->creerInvitationActivee();
$this->expectException(InvitationDejaActiveeException::class);
$invitation->activer(UserId::generate(), new DateTimeImmutable('2026-02-22 10:00:00'));
}
#[Test]
public function activerThrowsExceptionWhenNotSent(): void
{
$invitation = $this->creerInvitation();
$this->expectException(InvitationNonEnvoyeeException::class);
$invitation->activer(UserId::generate(), new DateTimeImmutable('2026-02-21 10:00:00'));
}
#[Test]
public function activerThrowsExceptionWhenExpired(): void
{
$invitation = $this->creerInvitationEnvoyee(
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$this->expectException(InvitationExpireeException::class);
$invitation->activer(
UserId::generate(),
new DateTimeImmutable('2026-02-20 10:00:00'),
);
}
#[Test]
public function marquerExpireeChangesStatusWhenSent(): void
{
$invitation = $this->creerInvitationEnvoyee();
$invitation->marquerExpiree();
self::assertSame(InvitationStatus::EXPIRED, $invitation->status);
}
#[Test]
public function marquerExpireeDoesNothingWhenPending(): void
{
$invitation = $this->creerInvitation();
$invitation->marquerExpiree();
self::assertSame(InvitationStatus::PENDING, $invitation->status);
}
#[Test]
public function marquerExpireeDoesNothingWhenActivated(): void
{
$invitation = $this->creerInvitationActivee();
$invitation->marquerExpiree();
self::assertSame(InvitationStatus::ACTIVATED, $invitation->status);
}
#[Test]
public function renvoyerResetsCodeAndStatusAndExpiration(): void
{
$invitation = $this->creerInvitationEnvoyee(
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$nouveauCode = new InvitationCode('11111111111111111111111111111111');
$renvoyeAt = new DateTimeImmutable('2026-02-15 10:00:00');
$expectedExpiration = new DateTimeImmutable('2026-02-22 10:00:00');
$invitation->renvoyer($nouveauCode, $renvoyeAt);
self::assertSame(InvitationStatus::SENT, $invitation->status);
self::assertSame('11111111111111111111111111111111', (string) $invitation->code);
self::assertEquals($renvoyeAt, $invitation->sentAt);
self::assertEquals($expectedExpiration, $invitation->expiresAt);
}
#[Test]
public function renvoyerThrowsExceptionWhenActivated(): void
{
$invitation = $this->creerInvitationActivee();
$this->expectException(InvitationDejaActiveeException::class);
$invitation->renvoyer(
new InvitationCode('11111111111111111111111111111111'),
new DateTimeImmutable('2026-02-21 10:00:00'),
);
}
#[Test]
public function renvoyerWorksOnExpiredInvitation(): void
{
$invitation = $this->creerInvitationEnvoyee(
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$invitation->marquerExpiree();
$nouveauCode = new InvitationCode('22222222222222222222222222222222');
$renvoyeAt = new DateTimeImmutable('2026-02-15 10:00:00');
$invitation->renvoyer($nouveauCode, $renvoyeAt);
self::assertSame(InvitationStatus::SENT, $invitation->status);
}
#[Test]
public function estExpireeReturnsFalseBeforeExpiration(): void
{
$invitation = $this->creerInvitation(
createdAt: new DateTimeImmutable('2026-02-20 10:00:00'),
);
self::assertFalse($invitation->estExpiree(new DateTimeImmutable('2026-02-25 10:00:00')));
}
#[Test]
public function estExpireeReturnsTrueAfterExpiration(): void
{
$invitation = $this->creerInvitation(
createdAt: new DateTimeImmutable('2026-02-20 10:00:00'),
);
self::assertTrue($invitation->estExpiree(new DateTimeImmutable('2026-02-28 10:00:00')));
}
#[Test]
public function estExpireeReturnsTrueAtExactExpiration(): void
{
$invitation = $this->creerInvitation(
createdAt: new DateTimeImmutable('2026-02-20 10:00:00'),
);
self::assertTrue($invitation->estExpiree(new DateTimeImmutable('2026-02-27 10:00:00')));
}
#[Test]
public function estActiveeReturnsFalseForNewInvitation(): void
{
$invitation = $this->creerInvitation();
self::assertFalse($invitation->estActivee());
}
#[Test]
public function estActiveeReturnsTrueAfterActivation(): void
{
$invitation = $this->creerInvitationActivee();
self::assertTrue($invitation->estActivee());
}
#[Test]
public function reconstitutePreservesAllProperties(): void
{
$id = ParentInvitationId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$studentId = UserId::fromString(self::STUDENT_ID);
$parentEmail = new Email(self::PARENT_EMAIL);
$code = new InvitationCode(self::CODE);
$createdAt = new DateTimeImmutable('2026-02-20 10:00:00');
$createdBy = UserId::fromString(self::CREATED_BY_ID);
$sentAt = new DateTimeImmutable('2026-02-20 11:00:00');
$activatedAt = new DateTimeImmutable('2026-02-21 10:00:00');
$activatedUserId = UserId::generate();
$invitation = ParentInvitation::reconstitute(
id: $id,
tenantId: $tenantId,
studentId: $studentId,
parentEmail: $parentEmail,
code: $code,
status: InvitationStatus::ACTIVATED,
expiresAt: new DateTimeImmutable('2026-02-27 10:00:00'),
createdAt: $createdAt,
createdBy: $createdBy,
sentAt: $sentAt,
activatedAt: $activatedAt,
activatedUserId: $activatedUserId,
);
self::assertTrue($id->equals($invitation->id));
self::assertTrue($tenantId->equals($invitation->tenantId));
self::assertTrue($studentId->equals($invitation->studentId));
self::assertTrue($parentEmail->equals($invitation->parentEmail));
self::assertTrue($code->equals($invitation->code));
self::assertSame(InvitationStatus::ACTIVATED, $invitation->status);
self::assertEquals($sentAt, $invitation->sentAt);
self::assertEquals($activatedAt, $invitation->activatedAt);
self::assertTrue($activatedUserId->equals($invitation->activatedUserId));
}
private function creerInvitation(?DateTimeImmutable $createdAt = null): ParentInvitation
{
return ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::fromString(self::STUDENT_ID),
parentEmail: new Email(self::PARENT_EMAIL),
code: new InvitationCode(self::CODE),
createdAt: $createdAt ?? new DateTimeImmutable('2026-02-20 10:00:00'),
createdBy: UserId::fromString(self::CREATED_BY_ID),
);
}
private function creerInvitationEnvoyee(?DateTimeImmutable $createdAt = null): ParentInvitation
{
$invitation = $this->creerInvitation($createdAt);
$invitation->envoyer($createdAt ?? new DateTimeImmutable('2026-02-20 10:30:00'));
return $invitation;
}
private function creerInvitationActivee(): ParentInvitation
{
$invitation = $this->creerInvitationEnvoyee();
$invitation->activer(
UserId::generate(),
new DateTimeImmutable('2026-02-21 10:00:00'),
);
return $invitation;
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Console;
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\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Console\ExpireInvitationsCommand;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Console\Tester\CommandTester;
final class ExpireInvitationsCommandTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string CREATED_BY_ID = '550e8400-e29b-41d4-a716-446655440003';
private InMemoryParentInvitationRepository $repository;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryParentInvitationRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-28 10:00:00');
}
};
}
#[Test]
public function itExpiresInvitationsPastExpirationDate(): void
{
$invitation = $this->creerInvitation(
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
code: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
);
$invitation->envoyer(new DateTimeImmutable('2026-02-01 11:00:00'));
$this->repository->save($invitation);
$tester = $this->executeCommand();
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('1 invitation(s) expirée(s) trouvée(s)', $tester->getDisplay());
self::assertStringContainsString('1 invitation(s) marquée(s) comme expirée(s)', $tester->getDisplay());
self::assertSame(InvitationStatus::EXPIRED, $invitation->status);
}
#[Test]
public function itHandlesNoExpiredInvitations(): void
{
$tester = $this->executeCommand();
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('Aucune invitation expirée à traiter.', $tester->getDisplay());
}
#[Test]
public function itDoesNotExpirePendingInvitations(): void
{
$invitation = $this->creerInvitation(
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
code: 'b1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
);
$this->repository->save($invitation);
$tester = $this->executeCommand();
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('Aucune invitation expirée à traiter.', $tester->getDisplay());
self::assertSame(InvitationStatus::PENDING, $invitation->status);
}
#[Test]
public function itDoesNotExpireNonExpiredSentInvitations(): void
{
$invitation = $this->creerInvitation(
createdAt: new DateTimeImmutable('2026-02-25 10:00:00'),
code: 'c1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
);
$invitation->envoyer(new DateTimeImmutable('2026-02-25 11:00:00'));
$this->repository->save($invitation);
$tester = $this->executeCommand();
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('Aucune invitation expirée à traiter.', $tester->getDisplay());
self::assertSame(InvitationStatus::SENT, $invitation->status);
}
private function executeCommand(): CommandTester
{
$command = new ExpireInvitationsCommand(
$this->repository,
$this->clock,
new NullLogger(),
);
$tester = new CommandTester($command);
$tester->execute([]);
return $tester;
}
private function creerInvitation(
DateTimeImmutable $createdAt,
string $code,
): ParentInvitation {
return ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::fromString(self::STUDENT_ID),
parentEmail: new Email('parent@example.com'),
code: new InvitationCode($code),
createdAt: $createdAt,
createdBy: UserId::fromString(self::CREATED_BY_ID),
);
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\InvitationParentEnvoyee;
use App\Administration\Domain\Model\Invitation\InvitationCode;
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\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Messaging\SendParentInvitationEmailHandler;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use App\Shared\Infrastructure\Tenant\TenantUrlBuilder;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email as MimeEmail;
use Twig\Environment;
final class SendParentInvitationEmailHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string FROM_EMAIL = 'noreply@classeo.fr';
private InMemoryParentInvitationRepository $invitationRepository;
private InMemoryUserRepository $userRepository;
private TenantUrlBuilder $tenantUrlBuilder;
protected function setUp(): void
{
$this->invitationRepository = new InMemoryParentInvitationRepository();
$this->userRepository = new InMemoryUserRepository();
$tenantConfig = new TenantConfig(
tenantId: InfraTenantId::fromString(self::TENANT_ID),
subdomain: 'ecole-alpha',
databaseUrl: 'sqlite:///:memory:',
);
$tenantRegistry = $this->createMock(TenantRegistry::class);
$tenantRegistry->method('getConfig')->willReturn($tenantConfig);
$this->tenantUrlBuilder = new TenantUrlBuilder(
$tenantRegistry,
'https://classeo.fr',
'classeo.fr',
);
}
#[Test]
public function itSendsParentInvitationEmailWithStudentName(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
$invitation = $this->createAndSaveInvitation($student->id, 'parent@example.com');
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$twig->expects($this->once())
->method('render')
->with('emails/parent_invitation.html.twig', $this->callback(
static fn (array $params): bool => $params['studentName'] === 'Alice Dupont'
&& str_contains($params['activationUrl'], 'ecole-alpha.classeo.fr/parent-activate/'),
))
->willReturn('<html>parent invitation</html>');
$mailer->expects($this->once())
->method('send')
->with($this->callback(
static fn (MimeEmail $email): bool => $email->getTo()[0]->getAddress() === 'parent@example.com'
&& $email->getSubject() === 'Invitation à rejoindre Classeo'
&& $email->getHtmlBody() === '<html>parent invitation</html>',
));
$handler = new SendParentInvitationEmailHandler(
$mailer,
$twig,
$this->invitationRepository,
$this->userRepository,
$this->tenantUrlBuilder,
self::FROM_EMAIL,
);
$event = new InvitationParentEnvoyee(
invitationId: $invitation->id,
studentId: $student->id,
parentEmail: $invitation->parentEmail,
tenantId: $invitation->tenantId,
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
);
($handler)($event);
}
#[Test]
public function itSendsFromConfiguredEmailAddress(): void
{
$student = $this->createAndSaveStudent('Bob', 'Martin');
$invitation = $this->createAndSaveInvitation($student->id, 'parent2@example.com');
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$twig->method('render')->willReturn('<html>invitation</html>');
$customFrom = 'custom@school.fr';
$mailer->expects($this->once())
->method('send')
->with($this->callback(
static fn (MimeEmail $email): bool => $email->getFrom()[0]->getAddress() === $customFrom,
));
$handler = new SendParentInvitationEmailHandler(
$mailer,
$twig,
$this->invitationRepository,
$this->userRepository,
$this->tenantUrlBuilder,
$customFrom,
);
$event = new InvitationParentEnvoyee(
invitationId: $invitation->id,
studentId: $student->id,
parentEmail: $invitation->parentEmail,
tenantId: $invitation->tenantId,
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
);
($handler)($event);
}
#[Test]
public function itDoesNothingWhenInvitationNotFound(): void
{
$student = $this->createAndSaveStudent('Charlie', 'Durand');
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$mailer->expects($this->never())->method('send');
$handler = new SendParentInvitationEmailHandler(
$mailer,
$twig,
$this->invitationRepository,
$this->userRepository,
$this->tenantUrlBuilder,
self::FROM_EMAIL,
);
// Event with a non-existent invitation ID
$event = new InvitationParentEnvoyee(
invitationId: \App\Administration\Domain\Model\Invitation\ParentInvitationId::generate(),
studentId: $student->id,
parentEmail: new Email('ghost@example.com'),
tenantId: TenantId::fromString(self::TENANT_ID),
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
);
($handler)($event);
}
private function createAndSaveStudent(string $firstName, string $lastName): User
{
$student = User::inviter(
email: new Email($firstName . '@example.com'),
role: Role::ELEVE,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Alpha',
firstName: $firstName,
lastName: $lastName,
invitedAt: new DateTimeImmutable('2026-02-07 10:00:00'),
);
$student->pullDomainEvents();
$this->userRepository->save($student);
return $student;
}
private function createAndSaveInvitation(UserId $studentId, string $parentEmail): ParentInvitation
{
$invitation = ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: $studentId,
parentEmail: new Email($parentEmail),
code: new InvitationCode(str_repeat('a', 32)),
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
createdBy: UserId::generate(),
);
$invitation->envoyer(new DateTimeImmutable('2026-02-07 10:00:00'));
$invitation->pullDomainEvents();
$this->invitationRepository->save($invitation);
return $invitation;
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\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\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class InMemoryParentInvitationRepositoryTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string CREATED_BY_ID = '550e8400-e29b-41d4-a716-446655440003';
private const string CODE = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4';
private InMemoryParentInvitationRepository $repository;
protected function setUp(): void
{
$this->repository = new InMemoryParentInvitationRepository();
}
#[Test]
public function saveAndGetReturnsInvitation(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$found = $this->repository->get($invitation->id, TenantId::fromString(self::TENANT_ID));
self::assertTrue($found->id->equals($invitation->id));
}
#[Test]
public function getThrowsExceptionWhenNotFound(): void
{
$this->expectException(ParentInvitationNotFoundException::class);
$invitation = $this->creerInvitation();
$this->repository->get($invitation->id, TenantId::fromString(self::TENANT_ID));
}
#[Test]
public function getThrowsExceptionForWrongTenant(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$this->expectException(ParentInvitationNotFoundException::class);
$this->repository->get($invitation->id, TenantId::fromString(self::OTHER_TENANT_ID));
}
#[Test]
public function findByIdReturnsNullWhenNotFound(): void
{
$invitation = $this->creerInvitation();
self::assertNull($this->repository->findById($invitation->id, TenantId::fromString(self::TENANT_ID)));
}
#[Test]
public function findByCodeReturnsInvitation(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$found = $this->repository->findByCode(new InvitationCode(self::CODE));
self::assertNotNull($found);
self::assertTrue($found->id->equals($invitation->id));
}
#[Test]
public function findByCodeReturnsNullWhenNotFound(): void
{
self::assertNull($this->repository->findByCode(new InvitationCode('11111111111111111111111111111111')));
}
#[Test]
public function findAllByTenantReturnsOnlyMatchingTenant(): void
{
$invitation1 = $this->creerInvitation();
$invitation2 = $this->creerInvitation(email: 'parent2@example.com', code: '22222222222222222222222222222222');
$this->repository->save($invitation1);
$this->repository->save($invitation2);
$results = $this->repository->findAllByTenant(TenantId::fromString(self::TENANT_ID));
self::assertCount(2, $results);
}
#[Test]
public function findByStudentReturnsInvitationsForStudent(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$results = $this->repository->findByStudent(
UserId::fromString(self::STUDENT_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertCount(1, $results);
}
#[Test]
public function findByStatusReturnsMatchingInvitations(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$pending = $this->repository->findByStatus(InvitationStatus::PENDING, TenantId::fromString(self::TENANT_ID));
$sent = $this->repository->findByStatus(InvitationStatus::SENT, TenantId::fromString(self::TENANT_ID));
self::assertCount(1, $pending);
self::assertCount(0, $sent);
}
#[Test]
public function findExpiredSentReturnsOnlySentAndExpired(): void
{
$invitation = $this->creerInvitation(createdAt: new DateTimeImmutable('2026-01-01 10:00:00'));
$invitation->envoyer(new DateTimeImmutable('2026-01-01 11:00:00'));
$this->repository->save($invitation);
$results = $this->repository->findExpiredSent(new DateTimeImmutable('2026-02-01 10:00:00'));
self::assertCount(1, $results);
}
#[Test]
public function findExpiredSentDoesNotReturnNonExpired(): void
{
$invitation = $this->creerInvitation();
$invitation->envoyer(new DateTimeImmutable('2026-02-20 11:00:00'));
$this->repository->save($invitation);
$results = $this->repository->findExpiredSent(new DateTimeImmutable('2026-02-21 10:00:00'));
self::assertCount(0, $results);
}
#[Test]
public function deleteRemovesInvitation(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$this->repository->delete($invitation->id, TenantId::fromString(self::TENANT_ID));
self::assertNull($this->repository->findById($invitation->id, TenantId::fromString(self::TENANT_ID)));
self::assertNull($this->repository->findByCode(new InvitationCode(self::CODE)));
}
#[Test]
public function saveUpdatesCodeIndexOnResend(): void
{
$invitation = $this->creerInvitation();
$invitation->envoyer(new DateTimeImmutable('2026-02-20 11:00:00'));
$this->repository->save($invitation);
$newCode = new InvitationCode('33333333333333333333333333333333');
$invitation->renvoyer($newCode, new DateTimeImmutable('2026-02-25 10:00:00'));
$this->repository->save($invitation);
self::assertNull($this->repository->findByCode(new InvitationCode(self::CODE)));
self::assertNotNull($this->repository->findByCode($newCode));
}
private function creerInvitation(
string $email = 'parent@example.com',
string $code = self::CODE,
?DateTimeImmutable $createdAt = null,
): ParentInvitation {
return ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::fromString(self::STUDENT_ID),
parentEmail: new Email($email),
code: new InvitationCode($code),
createdAt: $createdAt ?? new DateTimeImmutable('2026-02-20 10:00:00'),
createdBy: UserId::fromString(self::CREATED_BY_ID),
);
}
}

View File

@@ -0,0 +1,3 @@
Nom élève,Email parent 1,Email parent 2
Dupont Alice,alice.parent1@email.com,alice.parent2@email.com
Martin Bob,bob.parent@email.com,
1 Nom élève Email parent 1 Email parent 2
2 Dupont Alice alice.parent1@email.com alice.parent2@email.com
3 Martin Bob bob.parent@email.com

View File

@@ -0,0 +1,9 @@
Nom élève;Email parent 1;Email parent 2
Dupont Alice;alice.parent1@email.com;alice.parent2@email.com
Martin Bob;bob.parent@email.com;
Bernard Pierre;;pierre.parent2@email.com
;orphelin@email.com;
Leroy Sophie;invalide-email;sophie.parent2@email.com
Moreau Lucas;lucas.parent@email.com;aussi-invalide
Garcia Julie;julie.parent@email.com;julie.parent2@email.com
Roux Thomas;thomas.parent@email.com;
1 Nom élève Email parent 1 Email parent 2
2 Dupont Alice alice.parent1@email.com alice.parent2@email.com
3 Martin Bob bob.parent@email.com
4 Bernard Pierre pierre.parent2@email.com
5 orphelin@email.com
6 Leroy Sophie invalide-email sophie.parent2@email.com
7 Moreau Lucas lucas.parent@email.com aussi-invalide
8 Garcia Julie julie.parent@email.com julie.parent2@email.com
9 Roux Thomas thomas.parent@email.com

View File

@@ -0,0 +1,4 @@
Nom élève;Email parent 1;Email parent 2
Dupont Alice;alice.parent1@email.com;alice.parent2@email.com
Martin Bob;bob.parent@email.com;
Bernard Pierre;pierre.parent@email.com;pierre.parent2@email.com
1 Nom élève Email parent 1 Email parent 2
2 Dupont Alice alice.parent1@email.com alice.parent2@email.com
3 Martin Bob bob.parent@email.com
4 Bernard Pierre pierre.parent@email.com pierre.parent2@email.com