From be1b0b60a60d2240e9b948ec6cf7e04fd4bc4370 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sat, 28 Feb 2026 00:08:56 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20la=20g=C3=A9n=C3=A9ration?= =?UTF-8?q?=20et=20l'envoi=20de=20codes=20d'invitation=20aux=20parents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/config/packages/messenger.yaml | 3 + backend/config/packages/rate_limiter.yaml | 7 + backend/config/packages/security.yaml | 3 +- backend/config/services.yaml | 9 + backend/migrations/Version20260227162304.php | 47 + .../ActivateParentInvitationCommand.php | 16 + .../ActivateParentInvitationHandler.php | 56 + .../ActivateParentInvitationResult.php | 21 + .../ResendParentInvitationCommand.php | 14 + .../ResendParentInvitationHandler.php | 39 + .../SendParentInvitationCommand.php | 16 + .../SendParentInvitationHandler.php | 60 + .../GetParentInvitationsHandler.php | 132 ++ .../GetParentInvitationsQuery.php | 27 + .../ParentInvitationDto.php | 43 + .../Service/InvitationCodeGenerator.php | 23 + .../Domain/Event/InvitationParentActivee.php | 37 + .../Domain/Event/InvitationParentEnvoyee.php | 38 + .../InvitationCodeInvalideException.php | 17 + .../InvitationDejaActiveeException.php | 21 + .../Exception/InvitationExpireeException.php | 21 + .../InvitationNonEnvoyeeException.php | 21 + .../ParentInvitationNotFoundException.php | 27 + .../Import/ParentInvitationImportField.php | 46 + .../Model/Invitation/InvitationCode.php | 46 + .../Model/Invitation/InvitationStatus.php | 33 + .../Model/Invitation/ParentInvitation.php | 192 +++ .../Model/Invitation/ParentInvitationId.php | 11 + .../Repository/ParentInvitationRepository.php | 50 + .../BulkParentInvitationController.php | 125 ++ .../ParentInvitationImportController.php | 280 ++++ .../ActivateParentInvitationProcessor.php | 187 +++ .../ResendParentInvitationProcessor.php | 73 + .../SendParentInvitationProcessor.php | 76 + .../ParentInvitationCollectionProvider.php | 74 + .../ActivateParentInvitationOutput.php | 15 + .../Api/Resource/ParentInvitationResource.php | 132 ++ .../Console/ExpireInvitationsCommand.php | 92 ++ .../SendParentInvitationEmailHandler.php | 69 + .../DoctrineParentInvitationRepository.php | 225 +++ .../InMemoryParentInvitationRepository.php | 130 ++ .../Security/ParentInvitationVoter.php | 106 ++ .../emails/parent_invitation.html.twig | 98 ++ .../ActivateParentInvitationHandlerTest.php | 208 +++ .../ResendParentInvitationHandlerTest.php | 92 ++ .../SendParentInvitationHandlerTest.php | 123 ++ .../GetParentInvitationsHandlerTest.php | 207 +++ .../Import/ParentImportIntegrationTest.php | 143 ++ .../Service/InvitationCodeGeneratorTest.php | 48 + .../Model/Invitation/InvitationCodeTest.php | 71 + .../Model/Invitation/InvitationStatusTest.php | 105 ++ .../Invitation/ParentInvitationIdTest.php | 41 + .../Model/Invitation/ParentInvitationTest.php | 349 ++++ .../Console/ExpireInvitationsCommandTest.php | 129 ++ .../SendParentInvitationEmailHandlerTest.php | 206 +++ ...InMemoryParentInvitationRepositoryTest.php | 196 +++ .../tests/fixtures/import/parents_comma.csv | 3 + .../tests/fixtures/import/parents_complet.csv | 9 + .../tests/fixtures/import/parents_simple.csv | 4 + frontend/e2e/parent-invitation-import.spec.ts | 394 +++++ frontend/e2e/parent-invitations.spec.ts | 386 +++++ frontend/eslint.config.js | 3 + .../organisms/Dashboard/DashboardAdmin.svelte | 5 + .../import/api/parentInvitationImport.ts | 104 ++ frontend/src/routes/admin/+layout.svelte | 2 + .../routes/admin/import/parents/+page.svelte | 1469 +++++++++++++++++ .../admin/parent-invitations/+page.svelte | 1147 +++++++++++++ .../parent-activate/[code]/+page.svelte | 586 +++++++ 68 files changed, 8787 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/Version20260227162304.php create mode 100644 backend/src/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationCommand.php create mode 100644 backend/src/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationHandler.php create mode 100644 backend/src/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationResult.php create mode 100644 backend/src/Administration/Application/Command/ResendParentInvitation/ResendParentInvitationCommand.php create mode 100644 backend/src/Administration/Application/Command/ResendParentInvitation/ResendParentInvitationHandler.php create mode 100644 backend/src/Administration/Application/Command/SendParentInvitation/SendParentInvitationCommand.php create mode 100644 backend/src/Administration/Application/Command/SendParentInvitation/SendParentInvitationHandler.php create mode 100644 backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandler.php create mode 100644 backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsQuery.php create mode 100644 backend/src/Administration/Application/Query/GetParentInvitations/ParentInvitationDto.php create mode 100644 backend/src/Administration/Application/Service/InvitationCodeGenerator.php create mode 100644 backend/src/Administration/Domain/Event/InvitationParentActivee.php create mode 100644 backend/src/Administration/Domain/Event/InvitationParentEnvoyee.php create mode 100644 backend/src/Administration/Domain/Exception/InvitationCodeInvalideException.php create mode 100644 backend/src/Administration/Domain/Exception/InvitationDejaActiveeException.php create mode 100644 backend/src/Administration/Domain/Exception/InvitationExpireeException.php create mode 100644 backend/src/Administration/Domain/Exception/InvitationNonEnvoyeeException.php create mode 100644 backend/src/Administration/Domain/Exception/ParentInvitationNotFoundException.php create mode 100644 backend/src/Administration/Domain/Model/Import/ParentInvitationImportField.php create mode 100644 backend/src/Administration/Domain/Model/Invitation/InvitationCode.php create mode 100644 backend/src/Administration/Domain/Model/Invitation/InvitationStatus.php create mode 100644 backend/src/Administration/Domain/Model/Invitation/ParentInvitation.php create mode 100644 backend/src/Administration/Domain/Model/Invitation/ParentInvitationId.php create mode 100644 backend/src/Administration/Domain/Repository/ParentInvitationRepository.php create mode 100644 backend/src/Administration/Infrastructure/Api/Controller/BulkParentInvitationController.php create mode 100644 backend/src/Administration/Infrastructure/Api/Controller/ParentInvitationImportController.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/ActivateParentInvitationProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/ResendParentInvitationProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/SendParentInvitationProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/ParentInvitationCollectionProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/ActivateParentInvitationOutput.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/ParentInvitationResource.php create mode 100644 backend/src/Administration/Infrastructure/Console/ExpireInvitationsCommand.php create mode 100644 backend/src/Administration/Infrastructure/Messaging/SendParentInvitationEmailHandler.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineParentInvitationRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryParentInvitationRepository.php create mode 100644 backend/src/Administration/Infrastructure/Security/ParentInvitationVoter.php create mode 100644 backend/templates/emails/parent_invitation.html.twig create mode 100644 backend/tests/Unit/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/ResendParentInvitation/ResendParentInvitationHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/SendParentInvitation/SendParentInvitationHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/Import/ParentImportIntegrationTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/InvitationCodeGeneratorTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Invitation/InvitationCodeTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Invitation/InvitationStatusTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Invitation/ParentInvitationIdTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/Invitation/ParentInvitationTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Console/ExpireInvitationsCommandTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Messaging/SendParentInvitationEmailHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryParentInvitationRepositoryTest.php create mode 100644 backend/tests/fixtures/import/parents_comma.csv create mode 100644 backend/tests/fixtures/import/parents_complet.csv create mode 100644 backend/tests/fixtures/import/parents_simple.csv create mode 100644 frontend/e2e/parent-invitation-import.spec.ts create mode 100644 frontend/e2e/parent-invitations.spec.ts create mode 100644 frontend/src/lib/features/import/api/parentInvitationImport.ts create mode 100644 frontend/src/routes/admin/import/parents/+page.svelte create mode 100644 frontend/src/routes/admin/parent-invitations/+page.svelte create mode 100644 frontend/src/routes/parent-activate/[code]/+page.svelte diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index c0f9d43..6a73bca 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -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 diff --git a/backend/config/packages/rate_limiter.yaml b/backend/config/packages/rate_limiter.yaml index cd31682..81c2ce6 100644 --- a/backend/config/packages/rate_limiter.yaml +++ b/backend/config/packages/rate_limiter.yaml @@ -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 diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index bd3351d..344f4ac 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -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 } diff --git a/backend/config/services.yaml b/backend/config/services.yaml index ea81c0d..be4b36a 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -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: diff --git a/backend/migrations/Version20260227162304.php b/backend/migrations/Version20260227162304.php new file mode 100644 index 0000000..7ff6a2b --- /dev/null +++ b/backend/migrations/Version20260227162304.php @@ -0,0 +1,47 @@ +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'); + } +} diff --git a/backend/src/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationCommand.php b/backend/src/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationCommand.php new file mode 100644 index 0000000..5e12137 --- /dev/null +++ b/backend/src/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationCommand.php @@ -0,0 +1,16 @@ +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, + ); + } +} diff --git a/backend/src/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationResult.php b/backend/src/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationResult.php new file mode 100644 index 0000000..00c3ecd --- /dev/null +++ b/backend/src/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationResult.php @@ -0,0 +1,21 @@ +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; + } +} diff --git a/backend/src/Administration/Application/Command/SendParentInvitation/SendParentInvitationCommand.php b/backend/src/Administration/Application/Command/SendParentInvitation/SendParentInvitationCommand.php new file mode 100644 index 0000000..af06aa9 --- /dev/null +++ b/backend/src/Administration/Application/Command/SendParentInvitation/SendParentInvitationCommand.php @@ -0,0 +1,16 @@ +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; + } +} diff --git a/backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandler.php b/backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandler.php new file mode 100644 index 0000000..83c0382 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandler.php @@ -0,0 +1,132 @@ + + */ + 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 + */ + 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; + } +} diff --git a/backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsQuery.php b/backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsQuery.php new file mode 100644 index 0000000..3bc63b8 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsQuery.php @@ -0,0 +1,27 @@ +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; + } +} diff --git a/backend/src/Administration/Application/Query/GetParentInvitations/ParentInvitationDto.php b/backend/src/Administration/Application/Query/GetParentInvitations/ParentInvitationDto.php new file mode 100644 index 0000000..9761e76 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetParentInvitations/ParentInvitationDto.php @@ -0,0 +1,43 @@ +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, + ); + } +} diff --git a/backend/src/Administration/Application/Service/InvitationCodeGenerator.php b/backend/src/Administration/Application/Service/InvitationCodeGenerator.php new file mode 100644 index 0000000..a1804a0 --- /dev/null +++ b/backend/src/Administration/Application/Service/InvitationCodeGenerator.php @@ -0,0 +1,23 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->invitationId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/InvitationParentEnvoyee.php b/backend/src/Administration/Domain/Event/InvitationParentEnvoyee.php new file mode 100644 index 0000000..bb7a055 --- /dev/null +++ b/backend/src/Administration/Domain/Event/InvitationParentEnvoyee.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->invitationId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/InvitationCodeInvalideException.php b/backend/src/Administration/Domain/Exception/InvitationCodeInvalideException.php new file mode 100644 index 0000000..64a27a2 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/InvitationCodeInvalideException.php @@ -0,0 +1,17 @@ + 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 + */ + public static function champsObligatoires(): array + { + return array_values(array_filter( + self::cases(), + static fn (self $field): bool => $field->estObligatoire(), + )); + } +} diff --git a/backend/src/Administration/Domain/Model/Invitation/InvitationCode.php b/backend/src/Administration/Domain/Model/Invitation/InvitationCode.php new file mode 100644 index 0000000..671397f --- /dev/null +++ b/backend/src/Administration/Domain/Model/Invitation/InvitationCode.php @@ -0,0 +1,46 @@ +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; + } +} diff --git a/backend/src/Administration/Domain/Model/Invitation/InvitationStatus.php b/backend/src/Administration/Domain/Model/Invitation/InvitationStatus.php new file mode 100644 index 0000000..656f1bc --- /dev/null +++ b/backend/src/Administration/Domain/Model/Invitation/InvitationStatus.php @@ -0,0 +1,33 @@ +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; + } +} diff --git a/backend/src/Administration/Domain/Model/Invitation/ParentInvitationId.php b/backend/src/Administration/Domain/Model/Invitation/ParentInvitationId.php new file mode 100644 index 0000000..e1ac061 --- /dev/null +++ b/backend/src/Administration/Domain/Model/Invitation/ParentInvitationId.php @@ -0,0 +1,11 @@ +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); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Controller/ParentInvitationImportController.php b/backend/src/Administration/Infrastructure/Api/Controller/ParentInvitationImportController.php new file mode 100644 index 0000000..127a8cf --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Controller/ParentInvitationImportController.php @@ -0,0 +1,280 @@ +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 $columns + * + * @return array + */ + 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; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/ActivateParentInvitationProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/ActivateParentInvitationProcessor.php new file mode 100644 index 0000000..c2ade1c --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/ActivateParentInvitationProcessor.php @@ -0,0 +1,187 @@ + + */ +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, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/ResendParentInvitationProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/ResendParentInvitationProcessor.php new file mode 100644 index 0000000..b5e6550 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/ResendParentInvitationProcessor.php @@ -0,0 +1,73 @@ + + */ +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()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/SendParentInvitationProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/SendParentInvitationProcessor.php new file mode 100644 index 0000000..21c0809 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Processor/SendParentInvitationProcessor.php @@ -0,0 +1,76 @@ + + */ +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()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/ParentInvitationCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/ParentInvitationCollectionProvider.php new file mode 100644 index 0000000..a927e42 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/ParentInvitationCollectionProvider.php @@ -0,0 +1,74 @@ + + */ +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 $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, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/ActivateParentInvitationOutput.php b/backend/src/Administration/Infrastructure/Api/Resource/ActivateParentInvitationOutput.php new file mode 100644 index 0000000..33347b7 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/ActivateParentInvitationOutput.php @@ -0,0 +1,15 @@ + ['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; + } +} diff --git a/backend/src/Administration/Infrastructure/Console/ExpireInvitationsCommand.php b/backend/src/Administration/Infrastructure/Console/ExpireInvitationsCommand.php new file mode 100644 index 0000000..a58784c --- /dev/null +++ b/backend/src/Administration/Infrastructure/Console/ExpireInvitationsCommand.php @@ -0,0 +1,92 @@ +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; + } +} diff --git a/backend/src/Administration/Infrastructure/Messaging/SendParentInvitationEmailHandler.php b/backend/src/Administration/Infrastructure/Messaging/SendParentInvitationEmailHandler.php new file mode 100644 index 0000000..d5a383b --- /dev/null +++ b/backend/src/Administration/Infrastructure/Messaging/SendParentInvitationEmailHandler.php @@ -0,0 +1,69 @@ +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); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineParentInvitationRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineParentInvitationRepository.php new file mode 100644 index 0000000..11dd291 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineParentInvitationRepository.php @@ -0,0 +1,225 @@ +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 $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, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryParentInvitationRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryParentInvitationRepository.php new file mode 100644 index 0000000..a2a4d2a --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryParentInvitationRepository.php @@ -0,0 +1,130 @@ + */ + private array $byId = []; + + /** @var array */ + private array $byCode = []; + + /** @var array 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]); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Security/ParentInvitationVoter.php b/backend/src/Administration/Infrastructure/Security/ParentInvitationVoter.php new file mode 100644 index 0000000..0b4e227 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/ParentInvitationVoter.php @@ -0,0 +1,106 @@ + + */ +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; + } +} diff --git a/backend/templates/emails/parent_invitation.html.twig b/backend/templates/emails/parent_invitation.html.twig new file mode 100644 index 0000000..37c245c --- /dev/null +++ b/backend/templates/emails/parent_invitation.html.twig @@ -0,0 +1,98 @@ + + + + + + Invitation Parent - Classeo + + + +
+

Classeo

+
+ +
+

Invitation à rejoindre Classeo

+ +

Bonjour,

+ +

Vous êtes invité(e) à rejoindre Classeo en tant que parent de {{ studentName }}.

+ +
+

Cliquez sur le bouton ci-dessous pour créer votre compte et accéder aux informations scolaires de votre enfant.

+
+ +

+ Créer mon compte +

+ +
+

Ce lien expire dans 7 jours.

+

Si vous ne pouvez pas cliquer sur le bouton, copiez ce lien dans votre navigateur :

+

{{ activationUrl }}

+
+
+ + + + diff --git a/backend/tests/Unit/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationHandlerTest.php new file mode 100644 index 0000000..4c7bb4a --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ActivateParentInvitation/ActivateParentInvitationHandlerTest.php @@ -0,0 +1,208 @@ +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; + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/ResendParentInvitation/ResendParentInvitationHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ResendParentInvitation/ResendParentInvitationHandlerTest.php new file mode 100644 index 0000000..f290310 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ResendParentInvitation/ResendParentInvitationHandlerTest.php @@ -0,0 +1,92 @@ +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; + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/SendParentInvitation/SendParentInvitationHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/SendParentInvitation/SendParentInvitationHandlerTest.php new file mode 100644 index 0000000..954968c --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/SendParentInvitation/SendParentInvitationHandlerTest.php @@ -0,0 +1,123 @@ +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]); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandlerTest.php new file mode 100644 index 0000000..896f733 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandlerTest.php @@ -0,0 +1,207 @@ +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; + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Import/ParentImportIntegrationTest.php b/backend/tests/Unit/Administration/Application/Service/Import/ParentImportIntegrationTest.php new file mode 100644 index 0000000..42a14ec --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Import/ParentImportIntegrationTest.php @@ -0,0 +1,143 @@ +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 $columns + * + * @return array + */ + 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; + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/InvitationCodeGeneratorTest.php b/backend/tests/Unit/Administration/Application/Service/InvitationCodeGeneratorTest.php new file mode 100644 index 0000000..8e31466 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/InvitationCodeGeneratorTest.php @@ -0,0 +1,48 @@ +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)); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Invitation/InvitationCodeTest.php b/backend/tests/Unit/Administration/Domain/Model/Invitation/InvitationCodeTest.php new file mode 100644 index 0000000..9c822fd --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Invitation/InvitationCodeTest.php @@ -0,0 +1,71 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Invitation/InvitationStatusTest.php b/backend/tests/Unit/Administration/Domain/Model/Invitation/InvitationStatusTest.php new file mode 100644 index 0000000..ca4aeae --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Invitation/InvitationStatusTest.php @@ -0,0 +1,105 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Invitation/ParentInvitationIdTest.php b/backend/tests/Unit/Administration/Domain/Model/Invitation/ParentInvitationIdTest.php new file mode 100644 index 0000000..f239ad9 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Invitation/ParentInvitationIdTest.php @@ -0,0 +1,41 @@ +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)); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/Invitation/ParentInvitationTest.php b/backend/tests/Unit/Administration/Domain/Model/Invitation/ParentInvitationTest.php new file mode 100644 index 0000000..380e8f8 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/Invitation/ParentInvitationTest.php @@ -0,0 +1,349 @@ +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; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Console/ExpireInvitationsCommandTest.php b/backend/tests/Unit/Administration/Infrastructure/Console/ExpireInvitationsCommandTest.php new file mode 100644 index 0000000..0ef701c --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Console/ExpireInvitationsCommandTest.php @@ -0,0 +1,129 @@ +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), + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Messaging/SendParentInvitationEmailHandlerTest.php b/backend/tests/Unit/Administration/Infrastructure/Messaging/SendParentInvitationEmailHandlerTest.php new file mode 100644 index 0000000..e089838 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Messaging/SendParentInvitationEmailHandlerTest.php @@ -0,0 +1,206 @@ +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('parent invitation'); + + $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() === 'parent invitation', + )); + + $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('invitation'); + + $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; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryParentInvitationRepositoryTest.php b/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryParentInvitationRepositoryTest.php new file mode 100644 index 0000000..fa3501c --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryParentInvitationRepositoryTest.php @@ -0,0 +1,196 @@ +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), + ); + } +} diff --git a/backend/tests/fixtures/import/parents_comma.csv b/backend/tests/fixtures/import/parents_comma.csv new file mode 100644 index 0000000..d87450a --- /dev/null +++ b/backend/tests/fixtures/import/parents_comma.csv @@ -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, diff --git a/backend/tests/fixtures/import/parents_complet.csv b/backend/tests/fixtures/import/parents_complet.csv new file mode 100644 index 0000000..d2982d9 --- /dev/null +++ b/backend/tests/fixtures/import/parents_complet.csv @@ -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; diff --git a/backend/tests/fixtures/import/parents_simple.csv b/backend/tests/fixtures/import/parents_simple.csv new file mode 100644 index 0000000..07970c9 --- /dev/null +++ b/backend/tests/fixtures/import/parents_simple.csv @@ -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 diff --git a/frontend/e2e/parent-invitation-import.spec.ts b/frontend/e2e/parent-invitation-import.spec.ts new file mode 100644 index 0000000..f764789 --- /dev/null +++ b/frontend/e2e/parent-invitation-import.spec.ts @@ -0,0 +1,394 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { writeFileSync, mkdirSync, unlinkSync } from 'fs'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-parent-import-admin@example.com'; +const ADMIN_PASSWORD = 'ParentImportTest123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +const UNIQUE_SUFFIX = Date.now().toString().slice(-8); + +// Student IDs — deterministic UUIDs for cleanup +const STUDENT1_ID = `e2e00001-0000-4000-8000-${UNIQUE_SUFFIX}0001`; +const STUDENT2_ID = `e2e00001-0000-4000-8000-${UNIQUE_SUFFIX}0002`; + +// Unique student names to avoid collision with existing data +const STUDENT1_FIRST = `Alice${UNIQUE_SUFFIX}`; +const STUDENT1_LAST = `Dupont${UNIQUE_SUFFIX}`; +const STUDENT2_FIRST = `Bob${UNIQUE_SUFFIX}`; +const STUDENT2_LAST = `Martin${UNIQUE_SUFFIX}`; + +function runCommand(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +function createCsvFixture(filename: string, content: string): string { + const tmpDir = join(__dirname, 'fixtures'); + mkdirSync(tmpDir, { recursive: true }); + const filePath = join(tmpDir, filename); + writeFileSync(filePath, content, 'utf-8'); + return filePath; +} + +test.describe('Parent Invitation Import via CSV', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Create 2 students with unique names for matching + // Note: \\" produces \" in the string, which the shell interprets as literal " inside double quotes + try { + runCommand( + `INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${STUDENT1_ID}', '${TENANT_ID}', NULL, '${STUDENT1_FIRST}', '${STUDENT1_LAST}', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING` + ); + } catch { /* may already exist */ } + + try { + runCommand( + `INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${STUDENT2_ID}', '${TENANT_ID}', NULL, '${STUDENT2_FIRST}', '${STUDENT2_LAST}', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING` + ); + } catch { /* may already exist */ } + + // Clear user cache to ensure students are visible + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { /* ignore */ } + }); + + test('displays the import wizard page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/parents`); + + await expect(page.getByRole('heading', { name: /import d'invitations parents/i })).toBeVisible({ + timeout: 15000 + }); + + // Verify stepper is visible with 4 steps + await expect(page.locator('.stepper .step')).toHaveCount(4); + + // Verify dropzone is visible + await expect(page.locator('.dropzone')).toBeVisible(); + await expect(page.getByText(/glissez votre fichier/i)).toBeVisible(); + }); + + test('uploads a CSV file and shows mapping step', async ({ page }) => { + const csvContent = `Nom élève;Email parent 1;Email parent 2\n${STUDENT1_LAST} ${STUDENT1_FIRST};parent1@test.fr;parent2@test.fr\n`; + const csvPath = createCsvFixture('e2e-parent-import-test.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/parents`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + // Should transition to mapping step + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // File info should be visible + await expect(page.getByText(/e2e-parent-import-test\.csv/i)).toBeVisible(); + await expect(page.getByText(/1 lignes/i)).toBeVisible(); + + // Column names should appear in mapping + await expect(page.locator('.column-name').filter({ hasText: /^Nom élève$/ })).toBeVisible(); + await expect(page.locator('.column-name').filter({ hasText: /^Email parent 1$/ })).toBeVisible(); + await expect(page.locator('.column-name').filter({ hasText: /^Email parent 2$/ })).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('validates required fields in mapping', async ({ page }) => { + const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};alice@test.fr\n`; + const csvPath = createCsvFixture('e2e-parent-import-required.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/parents`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // The mapping should be auto-suggested, so the "Valider le mapping" button should be enabled + const validateButton = page.getByRole('button', { name: /valider le mapping/i }); + await expect(validateButton).toBeVisible(); + await expect(validateButton).toBeEnabled(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('navigates back from mapping to upload', async ({ page }) => { + const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};alice@test.fr\n`; + const csvPath = createCsvFixture('e2e-parent-import-back.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/parents`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // Click back button + await page.getByRole('button', { name: /retour/i }).click(); + + // Should be back on upload step + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 10000 }); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('rejects non-CSV files', async ({ page }) => { + const pdfPath = createCsvFixture('e2e-parent-import-bad.pdf', 'not a csv file'); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/parents`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(pdfPath); + + // Should show error + await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 }); + + try { unlinkSync(pdfPath); } catch { /* ignore */ } + }); + + test('shows preview step with valid/error counts', async ({ page }) => { + // Use only one row with a clearly non-existent student to verify error display + const csvContent = + 'Nom élève;Email parent 1\nZzznotfound99 Xxxxnomatch88;parent.err@test.fr\n'; + const csvPath = createCsvFixture('e2e-parent-import-preview.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/parents`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(csvPath); + + // Wait for mapping step + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // Submit mapping + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Wait for preview step + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + + // Should show 0 valid and 1 error + await expect(page.locator('.summary-card.valid')).toBeVisible(); + await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('0'); + await expect(page.locator('.summary-card.error')).toBeVisible(); + await expect(page.locator('.summary-card.error .summary-number')).toHaveText('1'); + + // Error detail should mention the unknown student + await expect(page.locator('.error-detail').first()).toContainText(/non trouvé/i); + + // Send button should be disabled (no valid rows) + await expect(page.getByRole('button', { name: /envoyer/i })).toBeDisabled(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('[P0] completes full import flow', async ({ page }) => { + const email1 = `parent.import.${UNIQUE_SUFFIX}@test.fr`; + const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};${email1}\n`; + const csvPath = createCsvFixture('e2e-parent-import-full-flow.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/parents`); + + // Step 1: Upload + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Step 2: Mapping + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Step 3: Preview + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('1'); + await page.getByRole('button', { name: /envoyer 1 invitation/i }).click(); + + // Step 4: Result + await expect(page.getByRole('heading', { name: /invitations envoyées/i })).toBeVisible({ timeout: 30000 }); + + // Verify report stats + const stats = page.locator('.report-stats .stat'); + const sentStat = stats.filter({ hasText: /envoyées/ }); + await expect(sentStat.locator('.stat-value')).toHaveText('1'); + const errorStat = stats.filter({ hasText: /erreurs/ }); + await expect(errorStat.locator('.stat-value')).toHaveText('0'); + + // Verify action buttons + await expect(page.getByRole('button', { name: /voir les invitations/i })).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + + // Cleanup: remove the created invitation + try { + runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT1_ID}' AND parent_email = '${email1}'`); + } catch { /* ignore */ } + }); + + test('[P1] handles multiple emails per student', async ({ page }) => { + const email1 = `parent1.multi.${UNIQUE_SUFFIX}@test.fr`; + const email2 = `parent2.multi.${UNIQUE_SUFFIX}@test.fr`; + const csvContent = `Nom élève;Email parent 1;Email parent 2\n${STUDENT2_LAST} ${STUDENT2_FIRST};${email1};${email2}\n`; + const csvPath = createCsvFixture('e2e-parent-import-multi-email.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/parents`); + + // Upload + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Mapping + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Preview — should show 1 valid row + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('1'); + + // Send — 2 invitations (one per email) + await page.getByRole('button', { name: /envoyer 1 invitation/i }).click(); + + // Result + await expect(page.locator('.report-stats')).toBeVisible({ timeout: 30000 }); + + // Should have created 2 invitations (email1 + email2) + const stats = page.locator('.report-stats .stat'); + const sentStat = stats.filter({ hasText: /envoyées/ }); + await expect(sentStat.locator('.stat-value')).toHaveText('2'); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + + // Cleanup + try { + runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT2_ID}' AND parent_email IN ('${email1}', '${email2}')`); + } catch { /* ignore */ } + }); + + test('[P1] shows invalid email errors in preview', async ({ page }) => { + const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};not-an-email\n`; + const csvPath = createCsvFixture('e2e-parent-import-invalid-email.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/parents`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Mapping + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + await page.getByRole('button', { name: /valider le mapping/i }).click(); + + // Preview — should show 0 valid, 1 error + await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('0'); + await expect(page.locator('.summary-card.error .summary-number')).toHaveText('1'); + + // Error detail should mention invalid email + await expect(page.locator('.error-detail').first()).toContainText(/invalide/i); + + // Send button should be disabled (0 valid rows) + await expect(page.getByRole('button', { name: /envoyer/i })).toBeDisabled(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test('[P2] shows preview of first 5 lines in mapping step', async ({ page }) => { + const csvContent = [ + 'Nom élève;Email parent 1', + 'Eleve Un;parent1@test.fr', + 'Eleve Deux;parent2@test.fr', + 'Eleve Trois;parent3@test.fr', + 'Eleve Quatre;parent4@test.fr', + 'Eleve Cinq;parent5@test.fr', + 'Eleve Six;parent6@test.fr', + 'Eleve Sept;parent7@test.fr' + ].join('\n') + '\n'; + const csvPath = createCsvFixture('e2e-parent-import-preview-5.csv', csvContent); + + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/import/parents`); + + await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); + await page.locator('input[type="file"]').setInputFiles(csvPath); + + // Wait for mapping step + await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); + + // Verify preview section exists + await expect(page.locator('.preview-section')).toBeVisible(); + + // Verify exactly 5 rows in the preview table (not 7) + await expect(page.locator('.preview-table tbody tr')).toHaveCount(5); + + // Verify total row count in file info + await expect(page.getByText(/7 lignes/i)).toBeVisible(); + + try { unlinkSync(csvPath); } catch { /* ignore */ } + }); + + test.afterAll(async () => { + // Clean up test students + try { + runCommand(`DELETE FROM parent_invitations WHERE student_id IN ('${STUDENT1_ID}', '${STUDENT2_ID}')`); + runCommand(`DELETE FROM users WHERE id IN ('${STUDENT1_ID}', '${STUDENT2_ID}')`); + } catch { /* ignore */ } + + // Clear cache + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { /* ignore */ } + }); +}); diff --git a/frontend/e2e/parent-invitations.spec.ts b/frontend/e2e/parent-invitations.spec.ts new file mode 100644 index 0000000..987b372 --- /dev/null +++ b/frontend/e2e/parent-invitations.spec.ts @@ -0,0 +1,386 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-parent-inv-admin@example.com'; +const ADMIN_PASSWORD = 'ParentInvTest123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +const UNIQUE_SUFFIX = Date.now().toString().slice(-8); +const STUDENT_ID = `e2e00002-0000-4000-8000-${UNIQUE_SUFFIX}0001`; +const PARENT_EMAIL = `e2e-parent-inv-${UNIQUE_SUFFIX}@test.fr`; + +function runCommand(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +test.describe('Parent Invitations', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Create a student with known name for invite tests + // Note: \\" produces \" in the string, which the shell interprets as literal " inside double quotes + try { + runCommand( + `INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${STUDENT_ID}', '${TENANT_ID}', NULL, 'Camille', 'Testinv', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING` + ); + } catch { /* may already exist */ } + + // Clean up invitations from previous runs + try { + runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT_ID}' OR parent_email = '${PARENT_EMAIL}'`); + } catch { /* ignore */ } + + // Clear user cache + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { /* ignore */ } + }); + + test('admin can navigate to parent invitations page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + // Page should load (empty state or table) + await expect( + page.locator('.data-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Title should be visible + await expect(page.getByRole('heading', { name: /invitations parents/i })).toBeVisible(); + }); + + test('admin sees empty state or data table', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + // Wait for page to load — either empty state or data table + await expect( + page.locator('.data-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Verify whichever state is shown has correct content + const emptyState = page.locator('.empty-state'); + const dataTable = page.locator('.data-table'); + const isEmptyStateVisible = await emptyState.isVisible(); + + if (isEmptyStateVisible) { + await expect(emptyState.getByText(/aucune invitation/i)).toBeVisible(); + } else { + await expect(dataTable).toBeVisible(); + } + }); + + test('admin can open the invite modal', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + await expect( + page.locator('.data-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Click "Inviter les parents" button + await page.getByRole('button', { name: /inviter les parents/i }).first().click(); + + // Modal should appear + await expect(page.locator('#invite-modal-title')).toBeVisible(); + await expect(page.locator('#invite-modal-title')).toHaveText('Inviter les parents'); + + // Form fields should be visible + await expect(page.locator('#invite-student')).toBeVisible(); + await expect(page.locator('#invite-email1')).toBeVisible(); + await expect(page.locator('#invite-email2')).toBeVisible(); + }); + + test('admin can close the invite modal with Escape', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + await expect( + page.locator('.data-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Open modal + await page.getByRole('button', { name: /inviter les parents/i }).first().click(); + await expect(page.locator('#invite-modal-title')).toBeVisible(); + + // Close with Escape + await page.keyboard.press('Escape'); + await expect(page.locator('#invite-modal-title')).not.toBeVisible(); + }); + + test('send invitation requires student and email', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + await expect( + page.locator('.data-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Open modal + await page.getByRole('button', { name: /inviter les parents/i }).first().click(); + await expect(page.locator('#invite-modal-title')).toBeVisible(); + + // Submit button should be disabled when empty + const submitBtn = page.locator('.modal').getByRole('button', { name: /envoyer l'invitation/i }); + await expect(submitBtn).toBeDisabled(); + }); + + test('[P0] admin can create an invitation via modal', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + await expect( + page.locator('.data-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Open modal + await page.getByRole('button', { name: /inviter les parents/i }).first().click(); + await expect(page.locator('#invite-modal-title')).toBeVisible(); + + // Wait for students to load in select (more than just the default empty option) + await expect(page.locator('#invite-student option')).not.toHaveCount(1, { timeout: 10000 }); + + // Select the first available student (not the placeholder) + const firstStudentOption = page.locator('#invite-student option:not([value=""])').first(); + await expect(firstStudentOption).toBeAttached({ timeout: 10000 }); + const studentValue = await firstStudentOption.getAttribute('value'); + await page.locator('#invite-student').selectOption(studentValue!); + + // Fill parent email + await page.locator('#invite-email1').fill(PARENT_EMAIL); + + // Submit button should be enabled + const submitBtn = page.locator('.modal').getByRole('button', { name: /envoyer l'invitation/i }); + await expect(submitBtn).toBeEnabled(); + + // Submit + await submitBtn.click(); + + // Modal should close and success message should appear + await expect(page.locator('#invite-modal-title')).not.toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toContainText(/invitation.*envoyée/i); + }); + + test('[P0] invitation appears in the table after creation', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + // Wait for table to load (should no longer be empty state) + await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 }); + + // The invitation should appear with the parent email + await expect(page.locator('.data-table').getByText(PARENT_EMAIL)).toBeVisible(); + + // Student name should appear (any student name in the row) + const invitationRow = page.locator('tr').filter({ hasText: PARENT_EMAIL }); + await expect(invitationRow).toBeVisible(); + + // Status should be "Envoyée" + await expect(page.locator('.data-table .status-badge').first()).toContainText(/envoyée/i); + }); + + test('[P1] admin can resend an invitation', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 }); + + // Find the row with our invitation and click "Renvoyer" + const row = page.locator('tr').filter({ hasText: PARENT_EMAIL }); + await expect(row).toBeVisible(); + await row.getByRole('button', { name: /renvoyer/i }).click(); + + // Success message should appear + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.alert-success')).toContainText(/renvoyée/i); + }); + + test('admin can navigate to file import page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + await expect( + page.locator('.data-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Click "Importer un fichier" link + await page.getByRole('link', { name: /importer un fichier/i }).click(); + + // Should navigate to the import wizard page + await expect(page).toHaveURL(/\/admin\/import\/parents/); + await expect(page.getByRole('heading', { name: /import d'invitations parents/i })).toBeVisible({ + timeout: 15000 + }); + }); + + test('filter by status changes the URL', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + await expect( + page.locator('.data-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Select a status filter + await page.locator('#filter-status').selectOption('sent'); + await page.getByRole('button', { name: /filtrer/i }).click(); + + // URL should have status param + await expect(page).toHaveURL(/status=sent/); + }); + + test('reset filters clears URL params', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations?status=sent`); + + await expect( + page.locator('.data-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Click reset (exact match to avoid ambiguity with "Réinitialiser les filtres" in empty state) + await page.getByRole('button', { name: 'Réinitialiser', exact: true }).click(); + + // URL should no longer contain status param + await expect(page).not.toHaveURL(/status=/); + }); + + test('[P1] filter by sent status shows the created invitation', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/parent-invitations`); + + await expect( + page.locator('.data-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Filter by "sent" status + await page.locator('#filter-status').selectOption('sent'); + await page.getByRole('button', { name: /filtrer/i }).click(); + + // Our invitation should still be visible + await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.data-table').getByText(PARENT_EMAIL)).toBeVisible(); + }); + + test.afterAll(async () => { + // Clean up invitations (by student or by email) and student + try { + runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT_ID}' OR parent_email = '${PARENT_EMAIL}'`); + runCommand(`DELETE FROM users WHERE id = '${STUDENT_ID}'`); + } catch { /* ignore */ } + + // Clear cache + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { /* ignore */ } + }); +}); + +test.describe('Parent Activation Page', () => { + test('displays form for parent activation page', async ({ page }) => { + // Navigate to the parent activation page with a dummy code + await page.goto('/parent-activate/test-code-that-does-not-exist'); + + // Page should load + await expect(page.getByRole('heading', { name: /activation.*parent/i })).toBeVisible(); + + // Form fields should be visible + await expect(page.locator('#firstName')).toBeVisible(); + await expect(page.locator('#lastName')).toBeVisible(); + await expect(page.locator('#password')).toBeVisible(); + await expect(page.locator('#passwordConfirmation')).toBeVisible(); + }); + + test('validates password requirements in real-time', async ({ page }) => { + await page.goto('/parent-activate/test-code'); + + await expect(page.locator('#password')).toBeVisible({ timeout: 5000 }); + + // Type a weak password + await page.locator('#password').fill('abc'); + + // Check that requirements are shown + const requirements = page.locator('.password-requirements'); + await expect(requirements).toBeVisible(); + + // Min length should NOT be valid + const minLengthItem = requirements.locator('li').filter({ hasText: /8 caractères/ }); + await expect(minLengthItem).not.toHaveClass(/valid/); + + // Type a strong password + await page.locator('#password').fill('StrongP@ss1'); + + // All requirements should be valid + const allItems = requirements.locator('li.valid'); + await expect(allItems).toHaveCount(5); + }); + + test('validates password confirmation match', async ({ page }) => { + await page.goto('/parent-activate/test-code'); + + await expect(page.locator('#password')).toBeVisible({ timeout: 5000 }); + + await page.locator('#password').fill('StrongP@ss1'); + await page.locator('#passwordConfirmation').fill('DifferentPass'); + + // Error should show + await expect(page.getByText(/ne correspondent pas/i)).toBeVisible(); + }); + + test('submit button is disabled until form is valid', async ({ page }) => { + await page.goto('/parent-activate/test-code'); + + await expect(page.locator('#password')).toBeVisible({ timeout: 5000 }); + + // Submit should be disabled initially + const submitBtn = page.getByRole('button', { name: /activer mon compte/i }); + await expect(submitBtn).toBeDisabled(); + + // Fill all fields with valid data + await page.locator('#firstName').fill('Jean'); + await page.locator('#lastName').fill('Parent'); + await page.locator('#password').fill('StrongP@ss1'); + await page.locator('#passwordConfirmation').fill('StrongP@ss1'); + + // Submit should be enabled + await expect(submitBtn).toBeEnabled(); + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 233b196..e174f85 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -18,6 +18,9 @@ export default tseslint.config( 'build/**', 'dist/**', 'node_modules/**', + 'playwright-report/**', + 'test-results/**', + 'test-results-debug/**', '*.config.js', '*.config.ts' ] diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index 5d8032f..fe732f6 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -31,6 +31,11 @@ Gérer les utilisateurs Inviter et gérer + + ✉️ + Invitations parents + Codes d'invitation + 🏫 Configurer les classes diff --git a/frontend/src/lib/features/import/api/parentInvitationImport.ts b/frontend/src/lib/features/import/api/parentInvitationImport.ts new file mode 100644 index 0000000..83b667e --- /dev/null +++ b/frontend/src/lib/features/import/api/parentInvitationImport.ts @@ -0,0 +1,104 @@ +import { getApiBaseUrl } from '$lib/api'; +import { authenticatedFetch } from '$lib/auth'; + +// === Types === + +export interface AnalyzeResult { + columns: string[]; + rows: Record[]; + totalRows: number; + filename: string; + suggestedMapping: Record; +} + +export interface ValidatedRow { + studentName: string; + email1: string; + email2: string; + studentId: string | null; + studentMatch: string | null; + error: string | null; +} + +export interface ValidateResult { + validatedRows: ValidatedRow[]; + validCount: number; + errorCount: number; +} + +export interface BulkResult { + created: number; + errors: { line: number; email?: string; error: string }[]; + total: number; +} + +// === API Functions === + +/** + * Upload et analyse un fichier CSV ou XLSX pour l'import d'invitations parents. + */ +export async function analyzeFile(file: File): Promise { + const apiUrl = getApiBaseUrl(); + const formData = new FormData(); + formData.append('file', file); + + const response = await authenticatedFetch(`${apiUrl}/import/parents/analyze`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error( + data?.['hydra:description'] ?? data?.message ?? data?.detail ?? "Erreur lors de l'analyse du fichier" + ); + } + + return await response.json(); +} + +/** + * Valide les lignes mappées contre les élèves existants. + */ +export async function validateRows( + rows: { studentName: string; email1: string; email2: string }[] +): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/import/parents/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rows }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error( + data?.['hydra:description'] ?? data?.message ?? data?.detail ?? 'Erreur lors de la validation' + ); + } + + return await response.json(); +} + +/** + * Envoie les invitations en masse via l'endpoint bulk existant. + */ +export async function sendBulkInvitations( + invitations: { studentId: string; parentEmail: string }[] +): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/parent-invitations/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invitations }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error( + data?.['hydra:description'] ?? data?.message ?? data?.detail ?? "Erreur lors de l'envoi" + ); + } + + return await response.json(); +} diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index 1b935cc..4791e56 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -25,6 +25,7 @@ const navLinks = [ { href: '/dashboard', label: 'Tableau de bord', isActive: () => false }, { href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive }, + { href: '/admin/parent-invitations', label: 'Invitations parents', isActive: () => isParentInvitationsActive }, { href: '/admin/students', label: 'Élèves', isActive: () => isStudentsActive }, { href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive }, { href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive }, @@ -82,6 +83,7 @@ // Determine which admin section is active const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users')); + const isParentInvitationsActive = $derived(page.url.pathname.startsWith('/admin/parent-invitations')); const isStudentsActive = $derived(page.url.pathname.startsWith('/admin/students')); const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes')); const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects')); diff --git a/frontend/src/routes/admin/import/parents/+page.svelte b/frontend/src/routes/admin/import/parents/+page.svelte new file mode 100644 index 0000000..93aee70 --- /dev/null +++ b/frontend/src/routes/admin/import/parents/+page.svelte @@ -0,0 +1,1469 @@ + + + + Import invitations parents - Classeo + + +
+ + + + + + + + {#if error} + + {/if} + + + {#if currentStep === 'upload'} +
+
fileInput?.click()} + role="button" + tabindex="0" + aria-label="Zone de dépôt de fichier" + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') fileInput?.click(); + }} + > + {#if isUploading} +
+
+

Analyse du fichier en cours...

+
+ {:else} +
+ +

Glissez votre fichier ici

+

ou cliquez pour parcourir

+

CSV, XLSX - Max 10 Mo

+
+ {/if} + + +
+ + +
+

Format attendu

+
+
+ Fichier CSV ou Excel +

+ Colonnes requises : Nom de l'élève, Email parent 1. + Optionnel : Email parent 2. +

+
+
+
+
+ {/if} + + + {#if currentStep === 'mapping'} +
+ {#if uploadResult} +
+
+ {uploadResult.filename} - {uploadResult.totalRows} lignes détectées +
+
+ + + {#if uploadResult.rows.length > 0} +
+

+ Aperçu des données ({Math.min(5, uploadResult.rows.length)} premières lignes) +

+
+ + + + + {#each uploadResult.columns as col} + + {/each} + + + + {#each uploadResult.rows.slice(0, 5) as row, i} + + + {#each uploadResult.columns as col} + + {/each} + + {/each} + +
#{col}
{i + 1}{row[col] ?? ''}
+
+
+ {/if} + + +
+

Association des colonnes

+

+ Glissez-déposez les champs Classeo sur les colonnes de votre fichier, ou utilisez les + menus déroulants. Les champs marqués * sont obligatoires. +

+ + +
+ Champs Classeo : +
+ {#each CLASSEO_FIELDS as field} + handleFieldDragStart(e, field.value)} + ondragend={handleFieldDragEnd} + role="button" + tabindex={isFieldMapped(field.value) ? -1 : 0} + aria-label="{field.label}{field.required ? ' (obligatoire)' : ''}" + > + {field.label}{field.required ? ' *' : ''} + + {/each} +
+
+ +
+ {#each uploadResult.columns as column} +
handleMappingRowDragOver(e, column)} + ondragleave={() => handleMappingRowDragLeave(column)} + ondrop={(e) => handleMappingRowDrop(e, column)} + > +
+ {column} +
+
+ +
+
+ +
+
+ {/each} +
+ + {#if !requiredFieldsMapped} +

+ Tous les champs obligatoires (*) doivent être associés pour continuer. +

+ {/if} +
+ +
+ + +
+ {/if} +
+ {/if} + + + {#if currentStep === 'preview'} +
+ {#if isValidating} +
+
+

Validation des données en cours...

+
+ {:else if validateResult} + +
+
+ {validateResult.validCount} + Lignes valides +
+
+ {validateResult.errorCount} + Lignes en erreur +
+
+ {validateResult.validatedRows.length} + Total +
+
+ + +
+

Détail des données

+
+ + + + + + + + + + + + + {#each validateResult.validatedRows as row, i} + + + + + + + + + {#if row.error} + + + + {/if} + {/each} + +
LigneNom élèveEmail parent 1Email parent 2Élève trouvéStatut
{i + 1}{row.studentName}{row.email1}{row.email2 || '-'}{row.studentMatch ?? '-'} + {#if row.error} + + Erreur + + {:else} + Valide + {/if} +
+ {row.error} +
+
+
+ +
+ + + +
+ {/if} +
+ {/if} + + + {#if currentStep === 'result'} +
+ {#if importResult} +
+ {#if importResult.errors.length === 0} +
+ +

Invitations envoyées

+
+ {:else} +
+ +

Import terminé avec des erreurs

+
+ {/if} + +
+
+ {importResult.created} + invitations envoyées +
+
+ {importResult.errors.length} + erreurs +
+
+ + {#if importResult.errors.length > 0} +
+

Détail des erreurs

+
+ + + + + + + + + + {#each importResult.errors as err} + + + + + + {/each} + +
LigneEmailErreur
{err.line}{err.email ?? '-'}{err.error}
+
+
+ {/if} + +
+ + +
+
+ {/if} +
+ {/if} +
+ + diff --git a/frontend/src/routes/admin/parent-invitations/+page.svelte b/frontend/src/routes/admin/parent-invitations/+page.svelte new file mode 100644 index 0000000..b150263 --- /dev/null +++ b/frontend/src/routes/admin/parent-invitations/+page.svelte @@ -0,0 +1,1147 @@ + + + { + if (e.key === 'Escape' && showInviteModal) closeInviteModal(); + if (showInviteModal) trapFocus(e); + }} +/> + + + Invitations parents - Classeo + + +
+ + + {#if error} + + {/if} + + {#if successMessage} + + {/if} + + +
+
+ + +
+
+ + +
+
+ + + + {#if isLoading} +
+
+

Chargement des invitations...

+
+ {:else if invitations.length === 0} +
+ + {#if searchTerm || filterStatus} +

Aucun résultat

+

Aucune invitation ne correspond à vos critères de recherche

+ + {:else} +

Aucune invitation

+

Commencez par inviter les parents de vos élèves

+ + {/if} +
+ {:else} +
+ + + + + + + + + + + + + {#each invitations as invitation (invitation.id)} + + + + + + + + + {/each} + +
ÉlèveEmail parentStatutDate d'envoiDate d'activationActions
+ + {invitation.studentFirstName ?? ''} {invitation.studentLastName ?? ''} + + + + {getStatusLabel(invitation.status)} + + {formatDate(invitation.sentAt)} + {formatDate(invitation.activatedAt)} + + {#if canResend(invitation)} + + {/if} +
+
+ + {/if} +
+ + +{#if showInviteModal} + + +{/if} + + + diff --git a/frontend/src/routes/parent-activate/[code]/+page.svelte b/frontend/src/routes/parent-activate/[code]/+page.svelte new file mode 100644 index 0000000..6c9396c --- /dev/null +++ b/frontend/src/routes/parent-activate/[code]/+page.svelte @@ -0,0 +1,586 @@ + + + + Activation compte parent | Classeo + + +
+
+ + + + {#if isActivated} + +
+
+
+

Compte activé !

+

Votre compte parent a été créé avec succès.

+

Vous pouvez maintenant vous connecter pour acceder aux informations de votre enfant.

+ +
+
+ {:else} + +
+

Activation de votre compte parent

+ +

+ Vous avez été invité à rejoindre Classeo pour suivre la scolarité de votre enfant. + Complétez les informations ci-dessous pour créer votre compte. +

+ +
+ {#if formError} +
+ ! + {formError} +
+ {/if} + + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ Votre mot de passe doit contenir : +
    +
  • + {hasMinLength ? '✓' : '○'} + Au moins 8 caractères +
  • +
  • + {hasUppercase ? '✓' : '○'} + Une majuscule +
  • +
  • + {hasLowercase ? '✓' : '○'} + Une minuscule +
  • +
  • + {hasDigit ? '✓' : '○'} + Un chiffre +
  • +
  • + {hasSpecial ? '✓' : '○'} + Un caractère spécial +
  • +
+
+ + +
+ +
+ 0 && !passwordsMatch} + /> +
+ {#if passwordConfirmation.length > 0 && !passwordsMatch} + Les mots de passe ne correspondent pas. + {/if} +
+ + + +
+
+ {/if} + + + +
+
+ +