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