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

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class EleveAffecteAClasse implements DomainEvent
{
public function __construct(
public ClassAssignmentId $assignmentId,
public UserId $studentId,
public ClassId $classId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->assignmentId->value;
}
}

View File

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

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class AffectationEleveNonTrouveeException extends DomainException
{
public static function pourEleve(UserId $studentId): self
{
return new self(sprintf(
'Aucune affectation trouvée pour l\'élève "%s" cette année scolaire.',
$studentId,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class EleveDejaAffecteException extends DomainException
{
public static function pourAnneeScolaire(UserId $studentId): self
{
return new self(sprintf(
'L\'élève "%s" est déjà affecté à une classe pour cette année scolaire.',
$studentId,
));
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\ClassAssignment;
use App\Administration\Domain\Event\EleveAffecteAClasse;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root représentant l'affectation d'un élève à une classe.
*
* Un élève ne peut être affecté qu'à une seule classe par année scolaire.
* Contrainte : UNIQUE(user_id, academic_year_id).
*
* @see FR76: Affectation élève↔classe
*/
final class ClassAssignment extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) ClassAssignmentId $id,
public private(set) TenantId $tenantId,
public private(set) UserId $studentId,
public private(set) ClassId $classId,
public private(set) AcademicYearId $academicYearId,
public private(set) DateTimeImmutable $assignedAt,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
public static function affecter(
TenantId $tenantId,
UserId $studentId,
ClassId $classId,
AcademicYearId $academicYearId,
DateTimeImmutable $assignedAt,
): self {
$assignment = new self(
id: ClassAssignmentId::generate(),
tenantId: $tenantId,
studentId: $studentId,
classId: $classId,
academicYearId: $academicYearId,
assignedAt: $assignedAt,
createdAt: $assignedAt,
);
$assignment->recordEvent(new EleveAffecteAClasse(
assignmentId: $assignment->id,
studentId: $assignment->studentId,
classId: $assignment->classId,
occurredOn: $assignedAt,
));
return $assignment;
}
public function changerClasse(ClassId $newClassId, DateTimeImmutable $changedAt): void
{
$this->classId = $newClassId;
$this->updatedAt = $changedAt;
$this->recordEvent(new EleveAffecteAClasse(
assignmentId: $this->id,
studentId: $this->studentId,
classId: $newClassId,
occurredOn: $changedAt,
));
}
/**
* Reconstitue une ClassAssignment depuis le stockage.
*
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
ClassAssignmentId $id,
TenantId $tenantId,
UserId $studentId,
ClassId $classId,
AcademicYearId $academicYearId,
DateTimeImmutable $assignedAt,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$assignment = new self(
id: $id,
tenantId: $tenantId,
studentId: $studentId,
classId: $classId,
academicYearId: $academicYearId,
assignedAt: $assignedAt,
createdAt: $createdAt,
);
$assignment->updatedAt = $updatedAt;
return $assignment;
}
}

View File

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

View File

@@ -13,6 +13,7 @@ enum StatutCompte: string
case CONSENTEMENT_REQUIS = 'consent'; // Minor < 15 years, awaiting parental consent
case ACTIF = 'active'; // Account activated and usable
case SUSPENDU = 'suspended'; // Account temporarily disabled
case INSCRIT = 'inscrit'; // Student enrolled without email, no account access
case ARCHIVE = 'archived'; // Account archived (end of schooling)
/**

View File

@@ -7,6 +7,7 @@ namespace App\Administration\Domain\Model\User;
use App\Administration\Domain\Event\CompteActive;
use App\Administration\Domain\Event\CompteCreated;
use App\Administration\Domain\Event\DroitImageModifie;
use App\Administration\Domain\Event\EleveInscrit;
use App\Administration\Domain\Event\InvitationRenvoyee;
use App\Administration\Domain\Event\MotDePasseChange;
use App\Administration\Domain\Event\RoleAttribue;
@@ -57,12 +58,14 @@ final class User extends AggregateRoot
/** @var Role[] */
public private(set) array $roles;
public private(set) ?string $studentNumber = null;
/**
* @param Role[] $roles
*/
private function __construct(
public private(set) UserId $id,
public private(set) Email $email,
public private(set) ?Email $email,
array $roles,
public private(set) TenantId $tenantId,
public private(set) string $schoolName,
@@ -263,6 +266,7 @@ final class User extends AggregateRoot
?DateTimeImmutable $dateNaissance = null,
?string $studentId = null,
?string $relationshipType = null,
?string $studentNumber = null,
): self {
$user = new self(
id: UserId::generate(),
@@ -278,6 +282,7 @@ final class User extends AggregateRoot
);
$user->invitedAt = $invitedAt;
$user->studentNumber = $studentNumber;
$user->recordEvent(new UtilisateurInvite(
userId: $user->id,
@@ -294,6 +299,49 @@ final class User extends AggregateRoot
return $user;
}
/**
* Enrolls a student without email (no invitation sent).
*
* Used when an admin creates a student who does not have an email address.
* The student is enrolled with status INSCRIT and cannot log in until
* an email is provided later.
*/
public static function inscrire(
Role $role,
TenantId $tenantId,
string $schoolName,
string $firstName,
string $lastName,
DateTimeImmutable $inscritAt,
?DateTimeImmutable $dateNaissance = null,
?string $studentNumber = null,
): self {
$user = new self(
id: UserId::generate(),
email: null,
roles: [$role],
tenantId: $tenantId,
schoolName: $schoolName,
statut: StatutCompte::INSCRIT,
dateNaissance: $dateNaissance,
createdAt: $inscritAt,
firstName: $firstName,
lastName: $lastName,
);
$user->studentNumber = $studentNumber;
$user->recordEvent(new EleveInscrit(
userId: $user->id,
firstName: $firstName,
lastName: $lastName,
tenantId: $user->tenantId,
occurredOn: $inscritAt,
));
return $user;
}
/**
* Resends the invitation for a user still awaiting activation.
*
@@ -426,7 +474,7 @@ final class User extends AggregateRoot
*/
public static function reconstitute(
UserId $id,
Email $email,
?Email $email,
array $roles,
TenantId $tenantId,
string $schoolName,
@@ -444,6 +492,7 @@ final class User extends AggregateRoot
ImageRightsStatus $imageRightsStatus = ImageRightsStatus::NOT_SPECIFIED,
?DateTimeImmutable $imageRightsUpdatedAt = null,
?UserId $imageRightsUpdatedBy = null,
?string $studentNumber = null,
): self {
if ($roles === []) {
throw new InvalidArgumentException('Un utilisateur doit avoir au moins un rôle.');
@@ -471,6 +520,7 @@ final class User extends AggregateRoot
$user->imageRightsStatus = $imageRightsStatus;
$user->imageRightsUpdatedAt = $imageRightsUpdatedAt;
$user->imageRightsUpdatedBy = $imageRightsUpdatedBy;
$user->studentNumber = $studentNumber;
return $user;
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
interface ClassAssignmentRepository
{
public function save(ClassAssignment $assignment): void;
public function findById(ClassAssignmentId $id, TenantId $tenantId): ?ClassAssignment;
public function findByStudent(UserId $studentId, AcademicYearId $academicYearId, TenantId $tenantId): ?ClassAssignment;
public function countByClass(ClassId $classId): int;
/** @return ClassAssignment[] */
public function findByClass(ClassId $classId, TenantId $tenantId): array;
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Infrastructure\Api\Resource\StudentResource;
use App\Administration\Infrastructure\Security\StudentVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<StudentResource, StudentResource>
*/
final readonly class ChangeStudentClassProcessor implements ProcessorInterface
{
public function __construct(
private ChangeStudentClassHandler $handler,
private ClassRepository $classRepository,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
/**
* @param StudentResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): StudentResource
{
if (!$this->authorizationChecker->isGranted(StudentVoter::MANAGE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à changer la classe d\'un élève.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var string $studentId */
$studentId = $uriVariables['id'] ?? '';
$academicYearId = $this->academicYearResolver->resolve('current')
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
try {
$command = new ChangeStudentClassCommand(
tenantId: $tenantId,
studentId: $studentId,
newClassId: $data->classId ?? '',
academicYearId: $academicYearId,
);
$assignment = ($this->handler)($command);
// Dispatch domain events
foreach ($assignment->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
// Update the resource with new class info
$newClass = $this->classRepository->get($assignment->classId);
$data->classId = (string) $assignment->classId;
$data->className = (string) $newClass->name;
$data->classLevel = $newClass->level?->value;
return $data;
} catch (AffectationEleveNonTrouveeException) {
throw new NotFoundHttpException('Élève non trouvé.');
} catch (ClasseNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\CreateStudent\CreateStudentCommand;
use App\Administration\Application\Command\CreateStudent\CreateStudentHandler;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Infrastructure\Api\Resource\StudentResource;
use App\Administration\Infrastructure\Security\StudentVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<StudentResource, StudentResource>
*/
final readonly class CreateStudentProcessor implements ProcessorInterface
{
public function __construct(
private CreateStudentHandler $handler,
private ClassRepository $classRepository,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
/**
* @param StudentResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): StudentResource
{
if (!$this->authorizationChecker->isGranted(StudentVoter::CREATE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer un élève.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$tenantConfig = $this->tenantContext->getCurrentTenantConfig();
$academicYearId = $this->academicYearResolver->resolve('current')
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
try {
$command = new CreateStudentCommand(
tenantId: $tenantId,
schoolName: $tenantConfig->subdomain,
firstName: $data->firstName ?? '',
lastName: $data->lastName ?? '',
classId: $data->classId ?? '',
academicYearId: $academicYearId,
email: $data->email,
dateNaissance: $data->dateNaissance,
studentNumber: $data->studentNumber,
);
$user = ($this->handler)($command);
// Dispatch domain events
foreach ($user->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
// Build the response from the created user and class info
$class = $this->classRepository->get(ClassId::fromString($data->classId ?? ''));
$resource = new StudentResource();
$resource->id = (string) $user->id;
$resource->firstName = $user->firstName;
$resource->lastName = $user->lastName;
$resource->email = $user->email !== null ? (string) $user->email : null;
$resource->statut = $user->statut->value;
$resource->classId = $data->classId;
$resource->className = (string) $class->name;
$resource->classLevel = $class->level?->value;
$resource->studentNumber = $user->studentNumber;
$resource->dateNaissance = $user->dateNaissance?->format('Y-m-d');
return $resource;
} catch (EmailDejaUtiliseeException $e) {
throw new ConflictHttpException($e->getMessage());
} catch (ClasseNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetStudentsWithClass\GetStudentsWithClassHandler;
use App\Administration\Application\Query\GetStudentsWithClass\GetStudentsWithClassQuery;
use App\Administration\Infrastructure\Api\Resource\StudentResource;
use App\Administration\Infrastructure\Security\StudentVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<StudentResource>
*/
final readonly class StudentCollectionProvider implements ProviderInterface
{
public function __construct(
private GetStudentsWithClassHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
if (!$this->authorizationChecker->isGranted(StudentVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter les élèves.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$academicYearId = $this->academicYearResolver->resolve('current')
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
/** @var array<string, mixed> $filters */
$filters = $context['filters'] ?? [];
/** @var int|string $rawPage */
$rawPage = $filters['page'] ?? 1;
$page = (int) $rawPage;
/** @var int|string $rawItemsPerPage */
$rawItemsPerPage = $filters['itemsPerPage'] ?? 30;
$itemsPerPage = (int) $rawItemsPerPage;
/** @var string|null $search */
$search = isset($filters['search']) ? $filters['search'] : null;
/** @var string|null $classId */
$classId = isset($filters['classId']) ? $filters['classId'] : null;
$result = ($this->handler)(new GetStudentsWithClassQuery(
tenantId: $tenantId,
academicYearId: $academicYearId,
classId: $classId,
page: $page,
limit: $itemsPerPage,
search: $search,
));
$resources = array_map(StudentResource::fromDto(...), $result->items);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\User\Role;
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\Administration\Infrastructure\Api\Resource\StudentResource;
use App\Administration\Infrastructure\Security\StudentVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<StudentResource>
*/
final readonly class StudentItemProvider implements ProviderInterface
{
public function __construct(
private UserRepository $userRepository,
private ClassAssignmentRepository $classAssignmentRepository,
private ClassRepository $classRepository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): StudentResource
{
if (!$this->authorizationChecker->isGranted(StudentVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter les élèves.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $studentId */
$studentId = $uriVariables['id'] ?? '';
$tenantId = $this->tenantContext->getCurrentTenantId();
$user = $this->userRepository->findById(UserId::fromString($studentId));
if ($user === null || (string) $user->tenantId !== (string) $tenantId || !$user->aLeRole(Role::ELEVE)) {
throw new NotFoundHttpException('Élève non trouvé.');
}
$resource = new StudentResource();
$resource->id = (string) $user->id;
$resource->firstName = $user->firstName;
$resource->lastName = $user->lastName;
$resource->email = $user->email !== null ? (string) $user->email : null;
$resource->statut = $user->statut->value;
$resource->studentNumber = $user->studentNumber;
$resource->dateNaissance = $user->dateNaissance?->format('Y-m-d');
// Look up class assignment
$academicYearId = $this->academicYearResolver->resolve('current');
if ($academicYearId !== null) {
$assignment = $this->classAssignmentRepository->findByStudent(
$user->id,
AcademicYearId::fromString($academicYearId),
TenantId::fromString((string) $tenantId),
);
if ($assignment !== null) {
$resource->classId = (string) $assignment->classId;
$class = $this->classRepository->findById($assignment->classId);
if ($class !== null) {
$resource->className = (string) $class->name;
$resource->classLevel = $class->level?->value;
}
}
}
return $resource;
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Administration\Application\Query\GetStudentsWithClass\StudentWithClassDto;
use App\Administration\Infrastructure\Api\Processor\ChangeStudentClassProcessor;
use App\Administration\Infrastructure\Api\Processor\CreateStudentProcessor;
use App\Administration\Infrastructure\Api\Provider\StudentCollectionProvider;
use App\Administration\Infrastructure\Api\Provider\StudentItemProvider;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'Student',
operations: [
new GetCollection(
uriTemplate: '/students',
provider: StudentCollectionProvider::class,
name: 'get_students',
),
new Get(
uriTemplate: '/students/{id}',
provider: StudentItemProvider::class,
name: 'get_student',
),
new Post(
uriTemplate: '/students',
processor: CreateStudentProcessor::class,
validationContext: ['groups' => ['Default', 'create']],
name: 'create_student',
),
new Patch(
uriTemplate: '/students/{id}/class',
provider: StudentItemProvider::class,
processor: ChangeStudentClassProcessor::class,
validationContext: ['groups' => ['Default', 'change_class']],
name: 'change_student_class',
),
],
)]
final class StudentResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
#[Assert\NotBlank(message: 'Le prénom est requis.', groups: ['create'])]
#[Assert\Length(max: 100, maxMessage: 'Le prénom ne doit pas dépasser {{ limit }} caractères.')]
public ?string $firstName = null;
#[Assert\NotBlank(message: 'Le nom est requis.', groups: ['create'])]
#[Assert\Length(max: 100, maxMessage: 'Le nom ne doit pas dépasser {{ limit }} caractères.')]
public ?string $lastName = null;
#[Assert\Email(message: 'L\'email n\'est pas valide.')]
public ?string $email = null;
#[Assert\NotBlank(message: 'La classe est requise.', groups: ['create', 'change_class'])]
public ?string $classId = null;
public ?string $className = null;
public ?string $classLevel = null;
public ?string $statut = null;
public ?string $dateNaissance = null;
#[Assert\Regex(pattern: '/^[A-Za-z0-9]{11}$/', message: 'L\'INE doit contenir exactement 11 caractères alphanumériques.')]
public ?string $studentNumber = null;
public static function fromDto(StudentWithClassDto $dto): self
{
$resource = new self();
$resource->id = $dto->id;
$resource->firstName = $dto->firstName;
$resource->lastName = $dto->lastName;
$resource->email = $dto->email;
$resource->statut = $dto->statut;
$resource->studentNumber = $dto->studentNumber;
$resource->dateNaissance = $dto->dateNaissance;
$resource->classId = $dto->classId;
$resource->className = $dto->className;
$resource->classLevel = $dto->classLevel;
return $resource;
}
}

View File

@@ -50,10 +50,12 @@ final readonly class CacheUserRepository implements UserRepository
$this->usersCache->save($item);
// Save email index for lookup (scoped to tenant)
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
if ($user->email !== null) {
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
}
// Save tenant index for listing users
$tenantKey = self::TENANT_INDEX_PREFIX . $user->tenantId;
@@ -77,7 +79,7 @@ final readonly class CacheUserRepository implements UserRepository
return null;
}
/** @var array{id: string, email: string, roles?: string[], role?: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
/** @var array{id: string, email: string|null, roles?: string[], role?: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, student_number?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
$data = $item->get();
return $this->deserialize($data);
@@ -150,7 +152,7 @@ final readonly class CacheUserRepository implements UserRepository
return [
'id' => (string) $user->id,
'email' => (string) $user->email,
'email' => $user->email !== null ? (string) $user->email : null,
'roles' => array_map(static fn (Role $r) => $r->value, $user->roles),
'tenant_id' => (string) $user->tenantId,
'school_name' => $user->schoolName,
@@ -167,6 +169,7 @@ final readonly class CacheUserRepository implements UserRepository
'image_rights_status' => $user->imageRightsStatus->value,
'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format('c'),
'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null,
'student_number' => $user->studentNumber,
'consentement_parental' => $consentement !== null ? [
'parent_id' => $consentement->parentId,
'eleve_id' => $consentement->eleveId,
@@ -179,7 +182,7 @@ final readonly class CacheUserRepository implements UserRepository
/**
* @param array{
* id: string,
* email: string,
* email: string|null,
* roles?: string[],
* role?: string,
* tenant_id: string,
@@ -227,7 +230,7 @@ final readonly class CacheUserRepository implements UserRepository
return User::reconstitute(
id: UserId::fromString($data['id']),
email: new Email($data['email']),
email: $data['email'] !== null ? new Email($data['email']) : null,
roles: $roles,
tenantId: TenantId::fromString($data['tenant_id']),
schoolName: $data['school_name'],
@@ -245,6 +248,7 @@ final readonly class CacheUserRepository implements UserRepository
imageRightsStatus: isset($data['image_rights_status']) ? ImageRightsStatus::from($data['image_rights_status']) : ImageRightsStatus::NOT_SPECIFIED,
imageRightsUpdatedAt: ($data['image_rights_updated_at'] ?? null) !== null ? new DateTimeImmutable($data['image_rights_updated_at']) : null,
imageRightsUpdatedBy: ($data['image_rights_updated_by'] ?? null) !== null ? UserId::fromString($data['image_rights_updated_by']) : null,
studentNumber: $data['student_number'] ?? null,
);
}

View File

@@ -15,6 +15,9 @@ use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use function is_string;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Throwable;
@@ -51,8 +54,10 @@ final readonly class CachedUserRepository implements UserRepository
/** @var array<string, mixed> $oldData */
$oldData = $existingItem->get();
/** @var string $oldEmail */
$oldEmail = $oldData['email'] ?? '';
if ($oldEmail !== '' && $oldEmail !== (string) $user->email) {
/** @var string|null $oldEmail */
$oldEmail = $oldData['email'] ?? null;
$currentEmail = $user->email !== null ? (string) $user->email : null;
if ($oldEmail !== null && $oldEmail !== '' && $oldEmail !== $currentEmail) {
/** @var string $oldTenantId */
$oldTenantId = $oldData['tenant_id'] ?? (string) $user->tenantId;
$oldEmailKey = $this->emailIndexKey(
@@ -67,10 +72,12 @@ final readonly class CachedUserRepository implements UserRepository
$this->usersCache->save($existingItem);
// Email index
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
if ($user->email !== null) {
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
}
} catch (Throwable) {
// Redis unavailable — PostgreSQL write succeeded, data is safe
}
@@ -172,10 +179,12 @@ final readonly class CachedUserRepository implements UserRepository
$this->usersCache->save($item);
// Email index
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
if ($user->email !== null) {
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
}
} catch (Throwable) {
// Redis unavailable
}
@@ -190,7 +199,7 @@ final readonly class CachedUserRepository implements UserRepository
return [
'id' => (string) $user->id,
'email' => (string) $user->email,
'email' => $user->email !== null ? (string) $user->email : null,
'roles' => array_map(static fn (Role $r) => $r->value, $user->roles),
'tenant_id' => (string) $user->tenantId,
'school_name' => $user->schoolName,
@@ -207,6 +216,7 @@ final readonly class CachedUserRepository implements UserRepository
'image_rights_status' => $user->imageRightsStatus->value,
'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format('c'),
'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null,
'student_number' => $user->studentNumber,
'consentement_parental' => $consentement !== null ? [
'parent_id' => $consentement->parentId,
'eleve_id' => $consentement->eleveId,
@@ -223,7 +233,7 @@ final readonly class CachedUserRepository implements UserRepository
{
/** @var string $id */
$id = $data['id'];
/** @var string $email */
/** @var string|null $email */
$email = $data['email'];
// Support both legacy single role ('role') and multi-role ('roles') format
/** @var string[] $roleStrings */
@@ -275,7 +285,7 @@ final readonly class CachedUserRepository implements UserRepository
return User::reconstitute(
id: UserId::fromString($id),
email: new Email($email),
email: $email !== null ? new Email($email) : null,
roles: $roles,
tenantId: TenantId::fromString($tenantId),
schoolName: $schoolName,
@@ -293,6 +303,7 @@ final readonly class CachedUserRepository implements UserRepository
imageRightsStatus: $imageRightsStatusValue !== null ? ImageRightsStatus::from($imageRightsStatusValue) : ImageRightsStatus::NOT_SPECIFIED,
imageRightsUpdatedAt: $imageRightsUpdatedAt !== null ? new DateTimeImmutable($imageRightsUpdatedAt) : null,
imageRightsUpdatedBy: $imageRightsUpdatedBy !== null ? UserId::fromString($imageRightsUpdatedBy) : null,
studentNumber: isset($data['student_number']) && is_string($data['student_number']) ? $data['student_number'] : null,
);
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\EleveDejaAffecteException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId;
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\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Override;
final readonly class DoctrineClassAssignmentRepository implements ClassAssignmentRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(ClassAssignment $assignment): void
{
try {
$this->connection->executeStatement(
'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at)
VALUES (:id, :tenant_id, :user_id, :school_class_id, :academic_year_id, :assigned_at, :created_at, :updated_at)
ON CONFLICT (id) DO UPDATE SET
school_class_id = EXCLUDED.school_class_id,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $assignment->id,
'tenant_id' => (string) $assignment->tenantId,
'user_id' => (string) $assignment->studentId,
'school_class_id' => (string) $assignment->classId,
'academic_year_id' => (string) $assignment->academicYearId,
'assigned_at' => $assignment->assignedAt->format(DateTimeImmutable::ATOM),
'created_at' => $assignment->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $assignment->updatedAt->format(DateTimeImmutable::ATOM),
],
);
} catch (UniqueConstraintViolationException) {
throw EleveDejaAffecteException::pourAnneeScolaire(
$assignment->studentId,
);
}
}
#[Override]
public function findById(ClassAssignmentId $id, TenantId $tenantId): ?ClassAssignment
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM class_assignments 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 findByStudent(UserId $studentId, AcademicYearId $academicYearId, TenantId $tenantId): ?ClassAssignment
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM class_assignments
WHERE user_id = :user_id
AND academic_year_id = :academic_year_id
AND tenant_id = :tenant_id',
[
'user_id' => (string) $studentId,
'academic_year_id' => (string) $academicYearId,
'tenant_id' => (string) $tenantId,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function countByClass(ClassId $classId): int
{
/** @var int|string|false $count */
$count = $this->connection->fetchOne(
'SELECT COUNT(*) FROM class_assignments WHERE school_class_id = :school_class_id',
['school_class_id' => (string) $classId],
);
return (int) $count;
}
#[Override]
public function findByClass(ClassId $classId, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM class_assignments
WHERE school_class_id = :school_class_id
AND tenant_id = :tenant_id
ORDER BY created_at ASC',
[
'school_class_id' => (string) $classId,
'tenant_id' => (string) $tenantId,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): ClassAssignment
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $studentId */
$studentId = $row['user_id'];
/** @var string $classId */
$classId = $row['school_class_id'];
/** @var string $academicYearId */
$academicYearId = $row['academic_year_id'];
/** @var string $assignedAt */
$assignedAt = $row['assigned_at'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
return ClassAssignment::reconstitute(
id: ClassAssignmentId::fromString($id),
tenantId: TenantId::fromString($tenantId),
studentId: UserId::fromString($studentId),
classId: ClassId::fromString($classId),
academicYearId: AcademicYearId::fromString($academicYearId),
assignedAt: new DateTimeImmutable($assignedAt),
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -44,7 +44,7 @@ final readonly class DoctrineUserRepository implements UserRepository
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,
updated_at
student_number, updated_at
)
VALUES (
:id, :tenant_id, :email, :first_name, :last_name, :roles,
@@ -52,7 +52,7 @@ final readonly class DoctrineUserRepository implements UserRepository
: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,
NOW()
:student_number, NOW()
)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
@@ -74,12 +74,13 @@ final readonly class DoctrineUserRepository implements UserRepository
image_rights_status = EXCLUDED.image_rights_status,
image_rights_updated_at = EXCLUDED.image_rights_updated_at,
image_rights_updated_by = EXCLUDED.image_rights_updated_by,
student_number = EXCLUDED.student_number,
updated_at = NOW()
SQL,
[
'id' => (string) $user->id,
'tenant_id' => (string) $user->tenantId,
'email' => (string) $user->email,
'email' => $user->email !== null ? (string) $user->email : null,
'first_name' => $user->firstName,
'last_name' => $user->lastName,
'roles' => json_encode(array_map(static fn (Role $r) => $r->value, $user->roles)),
@@ -99,6 +100,7 @@ final readonly class DoctrineUserRepository implements UserRepository
'image_rights_status' => $user->imageRightsStatus->value,
'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format(DateTimeImmutable::ATOM),
'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null,
'student_number' => $user->studentNumber,
],
);
}
@@ -182,7 +184,7 @@ final readonly class DoctrineUserRepository implements UserRepository
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $email */
/** @var string|null $email */
$email = $row['email'];
/** @var string $firstName */
$firstName = $row['first_name'];
@@ -222,6 +224,8 @@ final readonly class DoctrineUserRepository implements UserRepository
$imageRightsUpdatedAtValue = $row['image_rights_updated_at'] ?? null;
/** @var string|null $imageRightsUpdatedByValue */
$imageRightsUpdatedByValue = $row['image_rights_updated_by'] ?? null;
/** @var string|null $studentNumber */
$studentNumber = $row['student_number'] ?? null;
/** @var string[]|null $roleValues */
$roleValues = json_decode($rolesJson, true);
@@ -244,7 +248,7 @@ final readonly class DoctrineUserRepository implements UserRepository
return User::reconstitute(
id: UserId::fromString($id),
email: new Email($email),
email: $email !== null ? new Email($email) : null,
roles: $roles,
tenantId: TenantId::fromString($tenantId),
schoolName: $schoolName,
@@ -262,6 +266,7 @@ final readonly class DoctrineUserRepository implements UserRepository
imageRightsStatus: $imageRightsStatusValue !== null ? ImageRightsStatus::from($imageRightsStatusValue) : ImageRightsStatus::NOT_SPECIFIED,
imageRightsUpdatedAt: $imageRightsUpdatedAtValue !== null ? new DateTimeImmutable($imageRightsUpdatedAtValue) : null,
imageRightsUpdatedBy: $imageRightsUpdatedByValue !== null ? UserId::fromString($imageRightsUpdatedByValue) : null,
studentNumber: $studentNumber,
);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId;
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\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemoryClassAssignmentRepository implements ClassAssignmentRepository
{
/** @var array<string, ClassAssignment> */
private array $byId = [];
#[Override]
public function save(ClassAssignment $assignment): void
{
$this->byId[(string) $assignment->id] = $assignment;
}
#[Override]
public function findById(ClassAssignmentId $id, TenantId $tenantId): ?ClassAssignment
{
$assignment = $this->byId[(string) $id] ?? null;
if ($assignment !== null && !$assignment->tenantId->equals($tenantId)) {
return null;
}
return $assignment;
}
#[Override]
public function findByStudent(UserId $studentId, AcademicYearId $academicYearId, TenantId $tenantId): ?ClassAssignment
{
foreach ($this->byId as $assignment) {
if (
$assignment->studentId->equals($studentId)
&& $assignment->academicYearId->equals($academicYearId)
&& $assignment->tenantId->equals($tenantId)
) {
return $assignment;
}
}
return null;
}
#[Override]
public function countByClass(ClassId $classId): int
{
$count = 0;
foreach ($this->byId as $assignment) {
if ($assignment->classId->equals($classId)) {
++$count;
}
}
return $count;
}
#[Override]
public function findByClass(ClassId $classId, TenantId $tenantId): array
{
$result = [];
foreach ($this->byId as $assignment) {
if ($assignment->classId->equals($classId) && $assignment->tenantId->equals($tenantId)) {
$result[] = $assignment;
}
}
return $result;
}
}

View File

@@ -25,7 +25,10 @@ final class InMemoryUserRepository implements UserRepository
public function save(User $user): void
{
$this->byId[(string) $user->id] = $user;
$this->byTenantEmail[$this->emailKey($user->email, $user->tenantId)] = $user;
if ($user->email !== null) {
$this->byTenantEmail[$this->emailKey($user->email, $user->tenantId)] = $user;
}
}
#[Override]

View File

@@ -6,6 +6,7 @@ namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User as DomainUser;
use LogicException;
/**
* Factory pour créer des SecurityUser depuis des Domain Users.
@@ -18,9 +19,16 @@ final readonly class SecurityUserFactory
{
public function fromDomainUser(DomainUser $domainUser): SecurityUser
{
if ($domainUser->email === null) {
throw new LogicException('Cannot create SecurityUser from a domain user without email.');
}
/** @var non-empty-string $email */
$email = (string) $domainUser->email;
return new SecurityUser(
userId: $domainUser->id,
email: (string) $domainUser->email,
email: $email,
hashedPassword: $domainUser->hashedPassword ?? '',
tenantId: $domainUser->tenantId,
roles: array_values(array_map(

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use function in_array;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter pour les autorisations sur la gestion des élèves.
*
* Règles d'accès :
* - ADMIN, SUPER_ADMIN, SECRETARIAT : accès complet (CRUD)
* - Autres rôles : pas d'accès
*
* @extends Voter<string, null>
*/
final class StudentVoter extends Voter
{
public const string VIEW = 'STUDENT_VIEW';
public const string CREATE = 'STUDENT_CREATE';
public const string MANAGE = 'STUDENT_MANAGE';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
self::CREATE,
self::MANAGE,
];
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true) && $subject === null;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof SecurityUser) {
return false;
}
return $this->hasAnyRole($user->getRoles(), [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
Role::SECRETARIAT->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;
}
}