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:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user