feat: Gestion des classes scolaires

Permet aux administrateurs de créer, modifier et supprimer des classes
pour organiser les élèves par niveau. L'archivage soft-delete préserve
l'historique tout en masquant les classes obsolètes.

Inclut la validation des noms (2-50 caractères), les niveaux scolaires
du CP à la Terminale, et les contrôles d'accès par rôle.
This commit is contained in:
2026-02-05 15:24:29 +01:00
parent b45ef735db
commit 8e09e0abf1
54 changed files with 5099 additions and 5 deletions

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\CreateClass\CreateClassCommand;
use App\Administration\Application\Command\CreateClass\CreateClassHandler;
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
use App\Administration\Domain\Exception\ClassNameInvalideException;
use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Security\ClassVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Processor API Platform pour créer une classe.
*
* @implements ProcessorInterface<ClassResource, ClassResource>
*/
final readonly class CreateClassProcessor implements ProcessorInterface
{
public function __construct(
private CreateClassHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @param ClassResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClassResource
{
if (!$this->authorizationChecker->isGranted(ClassVoter::CREATE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer une classe.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
// TODO: Récupérer school_id et academic_year_id depuis le contexte utilisateur
// quand les modules Schools et AcademicYears seront implémentés.
// Pour l'instant, on utilise des UUIDs déterministes basés sur le tenant.
$schoolId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "school-{$tenantId}")->toString();
$academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString();
try {
$command = new CreateClassCommand(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: $data->name ?? '',
level: $data->level,
capacity: $data->capacity,
);
$class = ($this->handler)($command);
// Dispatch domain events from the created aggregate
foreach ($class->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
// Return the created resource
$resource = new ClassResource();
$resource->id = (string) $class->id;
$resource->name = (string) $class->name;
$resource->level = $class->level?->value;
$resource->capacity = $class->capacity;
$resource->status = $class->status->value;
return $resource;
} catch (ClassNameInvalideException $e) {
throw new BadRequestHttpException($e->getMessage());
} catch (ClasseDejaExistanteException $e) {
throw new ConflictHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ArchiveClass\ArchiveClassCommand;
use App\Administration\Application\Command\ArchiveClass\ArchiveClassHandler;
use App\Administration\Domain\Exception\ClasseNonSupprimableException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Security\ClassVoter;
use function is_string;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Processor API Platform pour supprimer (archiver) une classe.
*
* @implements ProcessorInterface<ClassResource, null>
*/
final readonly class DeleteClassProcessor implements ProcessorInterface
{
public function __construct(
private ArchiveClassHandler $handler,
private ClassRepository $classRepository,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @param ClassResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
$classId = $uriVariables['id'] ?? null;
if (!is_string($classId)) {
throw new BadRequestHttpException('ID de classe manquant.');
}
// Vérifier les permissions avant toute action
$class = $this->classRepository->findById(ClassId::fromString($classId));
if ($class === null) {
throw new NotFoundHttpException('Classe introuvable.');
}
if (!$this->authorizationChecker->isGranted(ClassVoter::DELETE, $class)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à supprimer cette classe.');
}
try {
$command = new ArchiveClassCommand(classId: $classId);
$archivedClass = ($this->handler)($command);
// Dispatch domain events from the archived aggregate
foreach ($archivedClass->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return null;
} catch (ClasseNotFoundException) {
throw new NotFoundHttpException('Classe introuvable.');
} catch (ClasseNonSupprimableException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\UpdateClass\UpdateClassCommand;
use App\Administration\Application\Command\UpdateClass\UpdateClassHandler;
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\ClassNameInvalideException;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Security\ClassVoter;
use function is_string;
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\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Processor API Platform pour modifier une classe.
*
* @implements ProcessorInterface<ClassResource, ClassResource>
*/
final readonly class UpdateClassProcessor implements ProcessorInterface
{
public function __construct(
private UpdateClassHandler $handler,
private ClassRepository $classRepository,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @param ClassResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClassResource
{
$classId = $uriVariables['id'] ?? null;
if (!is_string($classId)) {
throw new BadRequestHttpException('ID de classe manquant.');
}
// Vérifier les permissions avant toute action
$class = $this->classRepository->findById(ClassId::fromString($classId));
if ($class === null) {
throw new NotFoundHttpException('Classe introuvable.');
}
if (!$this->authorizationChecker->isGranted(ClassVoter::EDIT, $class)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier cette classe.');
}
try {
$command = new UpdateClassCommand(
classId: $classId,
name: $data->name,
level: $data->level,
capacity: $data->capacity,
description: $data->description,
clearLevel: $data->clearLevel ?? false,
clearCapacity: $data->clearCapacity ?? false,
clearDescription: $data->clearDescription ?? false,
);
$updatedClass = ($this->handler)($command);
// Dispatch domain events from the mutated aggregate
foreach ($updatedClass->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
// Return updated resource
$resource = new ClassResource();
$resource->id = (string) $updatedClass->id;
$resource->name = (string) $updatedClass->name;
$resource->level = $updatedClass->level?->value;
$resource->capacity = $updatedClass->capacity;
$resource->description = $updatedClass->description;
$resource->status = $updatedClass->status->value;
$resource->createdAt = $updatedClass->createdAt;
$resource->updatedAt = $updatedClass->updatedAt;
return $resource;
} catch (ClasseNotFoundException) {
throw new NotFoundHttpException('Classe introuvable.');
} catch (ClassNameInvalideException $e) {
throw new BadRequestHttpException($e->getMessage());
} catch (ClasseDejaExistanteException $e) {
throw new ConflictHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetClasses\GetClassesHandler;
use App\Administration\Application\Query\GetClasses\GetClassesQuery;
use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Security\ClassVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la liste des classes.
*
* @implements ProviderInterface<ClassResource>
*/
final readonly class ClassCollectionProvider implements ProviderInterface
{
public function __construct(
private GetClassesHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @return ClassResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
// Vérifier les permissions de lecture (sans sujet spécifique)
if (!$this->authorizationChecker->isGranted(ClassVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les classes.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
// TODO: Récupérer academic_year_id depuis le contexte utilisateur
// quand le module AcademicYears sera implémenté.
$academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString();
$query = new GetClassesQuery(
tenantId: $tenantId,
academicYearId: $academicYearId,
);
$classDtos = ($this->handler)($query);
return array_map(
static function ($dto) {
$resource = new ClassResource();
$resource->id = $dto->id;
$resource->name = $dto->name;
$resource->level = $dto->level;
$resource->capacity = $dto->capacity;
$resource->description = $dto->description;
$resource->status = $dto->status;
$resource->createdAt = $dto->createdAt;
$resource->updatedAt = $dto->updatedAt;
return $resource;
},
$classDtos,
);
}
}

View File

@@ -0,0 +1,85 @@
<?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\ClassId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Security\ClassVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use InvalidArgumentException;
use function is_string;
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;
/**
* State Provider pour récupérer une classe par son ID.
*
* @implements ProviderInterface<ClassResource>
*/
final readonly class ClassItemProvider implements ProviderInterface
{
public function __construct(
private ClassRepository $classRepository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?ClassResource
{
$id = $uriVariables['id'] ?? null;
if (!is_string($id)) {
return null;
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
try {
$classId = ClassId::fromString($id);
} catch (InvalidArgumentException) {
throw new NotFoundHttpException('Classe introuvable.');
}
$class = $this->classRepository->findById($classId);
if ($class === null) {
throw new NotFoundHttpException('Classe introuvable.');
}
// Vérifier que la classe appartient au tenant courant (comparaison par valeur string)
if ((string) $class->tenantId !== (string) $this->tenantContext->getCurrentTenantId()) {
throw new NotFoundHttpException('Classe introuvable.');
}
// Vérifier les permissions de lecture
if (!$this->authorizationChecker->isGranted(ClassVoter::VIEW, $class)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cette classe.');
}
$resource = new ClassResource();
$resource->id = (string) $class->id;
$resource->name = (string) $class->name;
$resource->level = $class->level?->value;
$resource->capacity = $class->capacity;
$resource->description = $class->description;
$resource->status = $class->status->value;
$resource->createdAt = $class->createdAt;
$resource->updatedAt = $class->updatedAt;
return $resource;
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Administration\Domain\Model\SchoolClass\SchoolLevels;
use App\Administration\Infrastructure\Api\Processor\CreateClassProcessor;
use App\Administration\Infrastructure\Api\Processor\DeleteClassProcessor;
use App\Administration\Infrastructure\Api\Processor\UpdateClassProcessor;
use App\Administration\Infrastructure\Api\Provider\ClassCollectionProvider;
use App\Administration\Infrastructure\Api\Provider\ClassItemProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la gestion des classes scolaires.
*
* @see Story 2.1 - Création et Gestion des Classes
* @see FR73 - Organiser les élèves par groupes pédagogiques
*/
#[ApiResource(
shortName: 'Class',
operations: [
new GetCollection(
uriTemplate: '/classes',
provider: ClassCollectionProvider::class,
name: 'get_classes',
),
new Get(
uriTemplate: '/classes/{id}',
provider: ClassItemProvider::class,
name: 'get_class',
),
new Post(
uriTemplate: '/classes',
processor: CreateClassProcessor::class,
validationContext: ['groups' => ['Default', 'create']],
name: 'create_class',
),
new Patch(
uriTemplate: '/classes/{id}',
provider: ClassItemProvider::class,
processor: UpdateClassProcessor::class,
validationContext: ['groups' => ['Default', 'update']],
name: 'update_class',
),
new Delete(
uriTemplate: '/classes/{id}',
provider: ClassItemProvider::class,
processor: DeleteClassProcessor::class,
name: 'delete_class',
),
],
)]
final class ClassResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
#[Assert\NotBlank(message: 'Le nom de la classe est requis.', groups: ['create'])]
#[Assert\Length(
min: 2,
max: 50,
minMessage: 'Le nom de la classe doit contenir au moins {{ limit }} caractères.',
maxMessage: 'Le nom de la classe ne peut pas dépasser {{ limit }} caractères.',
)]
public ?string $name = null;
#[Assert\Choice(
choices: SchoolLevels::ALL,
message: 'Le niveau scolaire doit être un niveau valide.',
)]
public ?string $level = null;
#[Assert\PositiveOrZero(message: 'La capacité doit être un nombre positif.')]
public ?int $capacity = null;
public ?string $description = null;
public ?string $status = null;
public ?DateTimeImmutable $createdAt = null;
public ?DateTimeImmutable $updatedAt = null;
/**
* Permet de supprimer explicitement le niveau lors d'un PATCH.
* Si true, le niveau sera mis à null même si level n'est pas fourni.
*/
#[ApiProperty(readable: false)]
public ?bool $clearLevel = null;
/**
* Permet de supprimer explicitement la capacité lors d'un PATCH.
* Si true, la capacité sera mise à null même si capacity n'est pas fourni.
*/
#[ApiProperty(readable: false)]
public ?bool $clearCapacity = null;
/**
* Permet de supprimer explicitement la description lors d'un PATCH.
* Si true, la description sera mise à null même si description n'est pas fourni.
*/
#[ApiProperty(readable: false)]
public ?bool $clearDescription = null;
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineClassRepository implements ClassRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(SchoolClass $class): void
{
$data = [
'id' => (string) $class->id,
'tenant_id' => (string) $class->tenantId,
'school_id' => (string) $class->schoolId,
'academic_year_id' => (string) $class->academicYearId,
'name' => (string) $class->name,
'level' => $class->level?->value,
'capacity' => $class->capacity,
'status' => $class->status->value,
'description' => $class->description,
'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM),
'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM),
];
$exists = $this->findById($class->id) !== null;
if ($exists) {
$this->connection->update('school_classes', $data, ['id' => (string) $class->id]);
} else {
$this->connection->insert('school_classes', $data);
}
}
#[Override]
public function get(ClassId $id): SchoolClass
{
$class = $this->findById($id);
if ($class === null) {
throw ClasseNotFoundException::withId($id);
}
return $class;
}
#[Override]
public function findById(ClassId $id): ?SchoolClass
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM school_classes WHERE id = :id',
['id' => (string) $id],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findByName(
ClassName $name,
TenantId $tenantId,
AcademicYearId $academicYearId,
): ?SchoolClass {
$row = $this->connection->fetchAssociative(
'SELECT * FROM school_classes
WHERE tenant_id = :tenant_id
AND academic_year_id = :academic_year_id
AND name = :name
AND deleted_at IS NULL',
[
'tenant_id' => (string) $tenantId,
'academic_year_id' => (string) $academicYearId,
'name' => (string) $name,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findActiveByTenantAndYear(
TenantId $tenantId,
AcademicYearId $academicYearId,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM school_classes
WHERE tenant_id = :tenant_id
AND academic_year_id = :academic_year_id
AND status = :status
ORDER BY name ASC',
[
'tenant_id' => (string) $tenantId,
'academic_year_id' => (string) $academicYearId,
'status' => ClassStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function delete(ClassId $id): void
{
$this->connection->delete('school_classes', ['id' => (string) $id]);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): SchoolClass
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $schoolId */
$schoolId = $row['school_id'];
/** @var string $academicYearId */
$academicYearId = $row['academic_year_id'];
/** @var string $name */
$name = $row['name'];
/** @var string|null $level */
$level = $row['level'];
/** @var int|string|null $capacity */
$capacity = $row['capacity'];
/** @var string $status */
$status = $row['status'];
/** @var string|null $description */
$description = $row['description'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
/** @var string|null $deletedAt */
$deletedAt = $row['deleted_at'];
return SchoolClass::reconstitute(
id: ClassId::fromString($id),
tenantId: TenantId::fromString($tenantId),
schoolId: SchoolId::fromString($schoolId),
academicYearId: AcademicYearId::fromString($academicYearId),
name: new ClassName($name),
level: $level !== null ? SchoolLevel::from($level) : null,
capacity: $capacity !== null ? (int) $capacity : null,
status: ClassStatus::from($status),
description: $description,
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
deletedAt: $deletedAt !== null ? new DateTimeImmutable($deletedAt) : null,
);
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemoryClassRepository implements ClassRepository
{
/** @var array<string, SchoolClass> Indexed by ID */
private array $byId = [];
/** @var array<string, SchoolClass> Indexed by tenant:year:name */
private array $byTenantYearName = [];
#[Override]
public function save(SchoolClass $class): void
{
// If class already exists, remove the old name key (handles renames)
$existingClass = $this->byId[(string) $class->id] ?? null;
if ($existingClass !== null) {
$oldKey = $this->nameKey($existingClass->name, $existingClass->tenantId, $existingClass->academicYearId);
unset($this->byTenantYearName[$oldKey]);
}
$this->byId[(string) $class->id] = $class;
$this->byTenantYearName[$this->nameKey($class->name, $class->tenantId, $class->academicYearId)] = $class;
}
#[Override]
public function get(ClassId $id): SchoolClass
{
$class = $this->findById($id);
if ($class === null) {
throw ClasseNotFoundException::withId($id);
}
return $class;
}
#[Override]
public function findById(ClassId $id): ?SchoolClass
{
return $this->byId[(string) $id] ?? null;
}
#[Override]
public function findByName(
ClassName $name,
TenantId $tenantId,
AcademicYearId $academicYearId,
): ?SchoolClass {
return $this->byTenantYearName[$this->nameKey($name, $tenantId, $academicYearId)] ?? null;
}
#[Override]
public function findActiveByTenantAndYear(
TenantId $tenantId,
AcademicYearId $academicYearId,
): array {
$result = [];
foreach ($this->byId as $class) {
if ($class->tenantId->equals($tenantId)
&& $class->academicYearId->equals($academicYearId)
&& $class->status === ClassStatus::ACTIVE
) {
$result[] = $class;
}
}
return $result;
}
#[Override]
public function delete(ClassId $id): void
{
$class = $this->byId[(string) $id] ?? null;
if ($class !== null) {
unset($this->byId[(string) $id]);
unset($this->byTenantYearName[$this->nameKey($class->name, $class->tenantId, $class->academicYearId)]);
}
}
private function nameKey(ClassName $name, TenantId $tenantId, AcademicYearId $academicYearId): string
{
return $tenantId . ':' . $academicYearId . ':' . mb_strtolower((string) $name, 'UTF-8');
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Api\Resource\ClassResource;
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;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Voter pour les autorisations sur les classes scolaires.
*
* Règles d'accès :
* - ADMIN et SUPER_ADMIN : accès complet (CRUD)
* - ENSEIGNANT : lecture seule (via affectations)
* - ELEVE et PARENT : lecture de leur classe uniquement
*
* @extends Voter<string, SchoolClass|ClassResource>
*/
final class ClassVoter extends Voter
{
public const string VIEW = 'CLASS_VIEW';
public const string CREATE = 'CLASS_CREATE';
public const string EDIT = 'CLASS_EDIT';
public const string DELETE = 'CLASS_DELETE';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
self::CREATE,
self::EDIT,
self::DELETE,
];
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) {
return false;
}
// CREATE and VIEW (for collections) don't require a subject
if ($attribute === self::CREATE || ($attribute === self::VIEW && $subject === null)) {
return true;
}
return $subject instanceof SchoolClass || $subject instanceof ClassResource;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
// Récupérer le rôle depuis les rôles Symfony
$roles = $user->getRoles();
return match ($attribute) {
self::VIEW => $this->canView($roles),
self::CREATE => $this->canCreate($roles),
self::EDIT => $this->canEdit($roles),
self::DELETE => $this->canDelete($roles),
default => false,
};
}
/**
* @param string[] $roles
*/
private function canView(array $roles): bool
{
// Personnel de l'établissement uniquement
// ELEVE et PARENT sont exclus car ils ne doivent voir que leur propre classe
// via un endpoint dédié (non implémenté - nécessite le module Affectations)
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
Role::PROF->value,
Role::VIE_SCOLAIRE->value,
Role::SECRETARIAT->value,
]);
}
/**
* @param string[] $roles
*/
private function canCreate(array $roles): bool
{
// Seuls ADMIN et SUPER_ADMIN peuvent créer des classes
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/**
* @param string[] $roles
*/
private function canEdit(array $roles): bool
{
// Seuls ADMIN et SUPER_ADMIN peuvent modifier des classes
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/**
* @param string[] $roles
*/
private function canDelete(array $roles): bool
{
// Seuls ADMIN et SUPER_ADMIN peuvent supprimer des classes
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/**
* @param string[] $userRoles
* @param string[] $allowedRoles
*/
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
{
foreach ($userRoles as $role) {
if (in_array($role, $allowedRoles, true)) {
return true;
}
}
return false;
}
}