feat: Permettre la création manuelle d'élèves et leur affectation aux classes

Les administrateurs et secrétaires avaient besoin de pouvoir inscrire un
élève en cours d'année sans passer par un import CSV. Cette fonctionnalité
pose aussi les fondations du modèle élève↔classe (ClassAssignment) qui
sera réutilisé par l'import CSV en masse (Story 3.1).

L'email est désormais optionnel pour les élèves : si fourni, une invitation
est envoyée (User::inviter) ; sinon l'élève est créé avec le statut
INSCRIT sans accès compte (User::inscrire). La création de l'utilisateur
et l'affectation à la classe sont atomiques (transaction DBAL).

Côté frontend, la page /admin/students offre liste paginée, recherche,
filtrage par classe, création via modale (avec détection de doublons
côté serveur), et changement de classe avec optimistic update.
This commit is contained in:
2026-02-23 19:12:21 +01:00
parent e5203097ef
commit 560b941821
49 changed files with 5184 additions and 65 deletions

View File

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

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignStudentToClass;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\EleveDejaAffecteException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class AssignStudentToClassHandler
{
public function __construct(
private ClassAssignmentRepository $classAssignmentRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private Clock $clock,
) {
}
public function __invoke(AssignStudentToClassCommand $command): ClassAssignment
{
$tenantId = TenantId::fromString($command->tenantId);
$studentId = UserId::fromString($command->studentId);
$classId = ClassId::fromString($command->classId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
// Valider l'existence des entités référencées et leur tenant
$student = $this->userRepository->get($studentId);
if (!$student->tenantId->equals($tenantId)) {
throw UserNotFoundException::withId($studentId);
}
$class = $this->classRepository->get($classId);
if (!$class->tenantId->equals($tenantId)) {
throw ClasseNotFoundException::withId($classId);
}
if (!$class->status->peutRecevoirEleves()) {
throw ClasseNotFoundException::withId($classId);
}
// Vérifier qu'il n'y a pas déjà une affectation pour cette année scolaire
$existing = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
if ($existing !== null) {
throw EleveDejaAffecteException::pourAnneeScolaire($studentId);
}
$assignment = ClassAssignment::affecter(
tenantId: $tenantId,
studentId: $studentId,
classId: $classId,
academicYearId: $academicYearId,
assignedAt: $this->clock->now(),
);
$this->classAssignmentRepository->save($assignment);
return $assignment;
}
}

View File

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

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ChangeStudentClass;
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ChangeStudentClassHandler
{
public function __construct(
private ClassAssignmentRepository $classAssignmentRepository,
private ClassRepository $classRepository,
private Clock $clock,
) {
}
public function __invoke(ChangeStudentClassCommand $command): ClassAssignment
{
$tenantId = TenantId::fromString($command->tenantId);
$studentId = UserId::fromString($command->studentId);
$newClassId = ClassId::fromString($command->newClassId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
// Valider l'existence de la nouvelle classe, son tenant et son statut
$class = $this->classRepository->get($newClassId);
if (!$class->tenantId->equals($tenantId)) {
throw ClasseNotFoundException::withId($newClassId);
}
if (!$class->status->peutRecevoirEleves()) {
throw ClasseNotFoundException::withId($newClassId);
}
// Trouver l'affectation existante
$assignment = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
if ($assignment === null) {
throw AffectationEleveNonTrouveeException::pourEleve($studentId);
}
$assignment->changerClasse($newClassId, $this->clock->now());
$this->classAssignmentRepository->save($assignment);
return $assignment;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\CreateStudent;
final readonly class CreateStudentCommand
{
public function __construct(
public string $tenantId,
public string $schoolName,
public string $firstName,
public string $lastName,
public string $classId,
public string $academicYearId,
public ?string $email = null,
public ?string $dateNaissance = null,
public ?string $studentNumber = null,
) {
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\CreateStudent;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CreateStudentHandler
{
public function __construct(
private UserRepository $userRepository,
private ClassAssignmentRepository $classAssignmentRepository,
private ClassRepository $classRepository,
private Connection $connection,
private Clock $clock,
) {
}
public function __invoke(CreateStudentCommand $command): User
{
$tenantId = TenantId::fromString($command->tenantId);
$classId = ClassId::fromString($command->classId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
// Valider l'existence de la classe, son tenant et son statut
$class = $this->classRepository->get($classId);
if (!$class->tenantId->equals($tenantId)) {
throw ClasseNotFoundException::withId($classId);
}
if (!$class->status->peutRecevoirEleves()) {
throw ClasseNotFoundException::withId($classId);
}
$now = $this->clock->now();
// Vérifier l'unicité de l'email si fourni
if ($command->email !== null) {
$email = new Email($command->email);
$existingUser = $this->userRepository->findByEmail($email, $tenantId);
if ($existingUser !== null) {
throw EmailDejaUtiliseeException::dansTenant($email, $tenantId);
}
}
$this->connection->beginTransaction();
try {
// Créer l'utilisateur
$user = $command->email !== null
? User::inviter(
email: new Email($command->email),
role: Role::ELEVE,
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $command->firstName,
lastName: $command->lastName,
invitedAt: $now,
dateNaissance: $command->dateNaissance !== null
? new DateTimeImmutable($command->dateNaissance)
: null,
studentNumber: $command->studentNumber,
)
: User::inscrire(
role: Role::ELEVE,
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $command->firstName,
lastName: $command->lastName,
inscritAt: $now,
dateNaissance: $command->dateNaissance !== null
? new DateTimeImmutable($command->dateNaissance)
: null,
studentNumber: $command->studentNumber,
);
$this->userRepository->save($user);
// Affecter à la classe
$assignment = ClassAssignment::affecter(
tenantId: $tenantId,
studentId: $user->id,
classId: $classId,
academicYearId: $academicYearId,
assignedAt: $now,
);
$this->classAssignmentRepository->save($assignment);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
throw $e;
}
return $user;
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsWithClass;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\User\Role;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetStudentsWithClassHandler
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return PaginatedResult<StudentWithClassDto>
*/
public function __invoke(GetStudentsWithClassQuery $query): PaginatedResult
{
$params = [
'tenant_id' => $query->tenantId,
'academic_year_id' => $query->academicYearId,
'role' => json_encode([Role::ELEVE->value]),
];
$whereClause = 'u.tenant_id = :tenant_id AND u.roles::jsonb @> :role::jsonb';
if ($query->classId !== null) {
$whereClause .= ' AND ca.school_class_id = :class_id';
$params['class_id'] = $query->classId;
}
if ($query->search !== null && $query->search !== '') {
$whereClause .= ' AND (LOWER(u.last_name) LIKE :search OR LOWER(u.first_name) LIKE :search)';
$params['search'] = '%' . mb_strtolower($query->search) . '%';
}
// Count total
$countSql = <<<SQL
SELECT COUNT(*)
FROM users u
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
WHERE {$whereClause}
SQL;
/** @var int|string|false $totalRaw */
$totalRaw = $this->connection->fetchOne($countSql, $params);
$total = (int) $totalRaw;
// Fetch paginated results
$offset = ($query->page - 1) * $query->limit;
$selectSql = <<<SQL
SELECT
u.id,
u.first_name,
u.last_name,
u.email,
u.statut,
u.student_number,
u.date_naissance,
ca.school_class_id AS class_id,
sc.name AS class_name,
sc.level AS class_level
FROM users u
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
LEFT JOIN school_classes sc ON sc.id = ca.school_class_id
WHERE {$whereClause}
ORDER BY u.last_name ASC, u.first_name ASC
LIMIT :limit OFFSET :offset
SQL;
$params['limit'] = $query->limit;
$params['offset'] = $offset;
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
$items = array_map(static function (array $row): StudentWithClassDto {
/** @var string $id */
$id = $row['id'];
/** @var string $firstName */
$firstName = $row['first_name'];
/** @var string $lastName */
$lastName = $row['last_name'];
/** @var string|null $email */
$email = $row['email'];
/** @var string $statut */
$statut = $row['statut'];
/** @var string|null $studentNumber */
$studentNumber = $row['student_number'];
/** @var string|null $dateNaissance */
$dateNaissance = $row['date_naissance'];
/** @var string|null $classId */
$classId = $row['class_id'];
/** @var string|null $className */
$className = $row['class_name'];
/** @var string|null $classLevel */
$classLevel = $row['class_level'];
return new StudentWithClassDto(
id: $id,
firstName: $firstName,
lastName: $lastName,
email: $email,
statut: $statut,
studentNumber: $studentNumber,
dateNaissance: $dateNaissance,
classId: $classId,
className: $className,
classLevel: $classLevel,
);
}, $rows);
return new PaginatedResult(
items: $items,
total: $total,
page: $query->page,
limit: $query->limit,
);
}
}

View File

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

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsWithClass;
final readonly class StudentWithClassDto
{
public function __construct(
public string $id,
public string $firstName,
public string $lastName,
public ?string $email,
public string $statut,
public ?string $studentNumber,
public ?string $dateNaissance,
public ?string $classId,
public ?string $className,
public ?string $classLevel,
) {
}
}

View File

@@ -4,21 +4,22 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\HasStudentsInClass;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour vérifier si des élèves sont affectés à une classe.
*
* Note: L'implémentation complète sera ajoutée quand le module Élèves sera disponible.
* Pour l'instant, retourne toujours 0 (aucun élève).
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class HasStudentsInClassHandler
{
public function __construct(
private ClassAssignmentRepository $classAssignmentRepository,
) {
}
public function __invoke(HasStudentsInClassQuery $query): int
{
// TODO: Implémenter la vérification réelle quand le module Élèves sera disponible
// Pour l'instant, retourne 0 (permet l'archivage)
return 0;
return $this->classAssignmentRepository->countByClass(
ClassId::fromString($query->classId),
);
}
}