feat: Désignation de remplaçants temporaires avec corrections sécurité

Permet aux administrateurs de désigner un enseignant remplaçant pour
un autre enseignant absent, sur des classes et matières précises, pour
une période donnée. Le dashboard enseignant affiche les remplacements
actifs avec les noms de classes/matières au lieu des identifiants bruts.

Inclut les corrections de la code review :
- Requête findActiveByTenant qui excluait les remplacements en cours
  mais incluait les futurs (manquait start_date <= :at)
- Validation tenant et rôle enseignant dans le handler de désignation
  pour empêcher l'affectation cross-tenant ou de non-enseignants
- Validation structurée du payload classes (Assert\Collection + UUID)
  pour éviter les erreurs serveur sur payloads malformés
- API replaced-classes enrichie avec les noms classe/matière
This commit is contained in:
2026-02-16 14:32:37 +01:00
parent fdc26eb334
commit c856dfdcda
63 changed files with 7694 additions and 236 deletions

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\ReplacedClassDto;
use App\Scolarite\Infrastructure\Api\Provider\ReplacedClassesProvider;
/**
* Classes/matières pour lesquelles l'enseignant connecté est remplaçant.
*/
#[ApiResource(
shortName: 'ReplacedClass',
operations: [
new GetCollection(
uriTemplate: '/me/replaced-classes',
provider: ReplacedClassesProvider::class,
name: 'get_replaced_classes',
),
],
)]
final class ReplacedClassResource
{
#[ApiProperty(identifier: true)]
public string $id;
public string $replacementId;
public string $replacedTeacherId;
public string $classId;
public string $subjectId;
public string $className;
public string $subjectName;
public string $startDate;
public string $endDate;
public static function fromDto(ReplacedClassDto $dto): self
{
$resource = new self();
$resource->id = $dto->replacementId . '_' . $dto->classId . '_' . $dto->subjectId;
$resource->replacementId = $dto->replacementId;
$resource->replacedTeacherId = $dto->replacedTeacherId;
$resource->classId = $dto->classId;
$resource->subjectId = $dto->subjectId;
$resource->className = $dto->className;
$resource->subjectName = $dto->subjectName;
$resource->startDate = $dto->startDate->format('Y-m-d');
$resource->endDate = $dto->endDate->format('Y-m-d');
return $resource;
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Scolarite\Application\Query\GetActiveReplacements\ReplacementDto;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Infrastructure\Api\Processor\CreateTeacherReplacementProcessor;
use App\Scolarite\Infrastructure\Api\Processor\EndTeacherReplacementProcessor;
use App\Scolarite\Infrastructure\Api\Provider\TeacherReplacementItemProvider;
use App\Scolarite\Infrastructure\Api\Provider\TeacherReplacementsCollectionProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la gestion des remplacements enseignants.
*
* @see Story 2.9 - Désignation Remplaçants Temporaires
* @see FR9 - Désigner remplaçant temporaire
*/
#[ApiResource(
shortName: 'TeacherReplacement',
operations: [
new GetCollection(
uriTemplate: '/teacher-replacements',
provider: TeacherReplacementsCollectionProvider::class,
name: 'get_teacher_replacements',
),
new Post(
uriTemplate: '/teacher-replacements',
processor: CreateTeacherReplacementProcessor::class,
validationContext: ['groups' => ['Default', 'create']],
name: 'create_teacher_replacement',
),
new Delete(
uriTemplate: '/teacher-replacements/{id}',
provider: TeacherReplacementItemProvider::class,
processor: EndTeacherReplacementProcessor::class,
name: 'end_teacher_replacement',
),
],
)]
final class TeacherReplacementResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
#[Assert\NotBlank(message: 'L\'identifiant de l\'enseignant remplacé est requis.', groups: ['create'])]
public ?string $replacedTeacherId = null;
#[Assert\NotBlank(message: 'L\'identifiant du remplaçant est requis.', groups: ['create'])]
public ?string $replacementTeacherId = null;
#[Assert\NotBlank(message: 'La date de début est requise.', groups: ['create'])]
public ?string $startDate = null;
#[Assert\NotBlank(message: 'La date de fin est requise.', groups: ['create'])]
public ?string $endDate = null;
/** @var array<array{classId: string, subjectId: string}>|null */
#[Assert\NotBlank(message: 'Au moins une classe/matière est requise.', groups: ['create'])]
#[Assert\All(
constraints: [
new Assert\Collection(
fields: [
'classId' => [
new Assert\NotBlank(message: 'L\'identifiant de la classe est requis.', groups: ['create']),
new Assert\Uuid(message: 'L\'identifiant de la classe doit être un UUID valide.', groups: ['create']),
],
'subjectId' => [
new Assert\NotBlank(message: 'L\'identifiant de la matière est requis.', groups: ['create']),
new Assert\Uuid(message: 'L\'identifiant de la matière doit être un UUID valide.', groups: ['create']),
],
],
allowExtraFields: false,
allowMissingFields: false,
groups: ['create'],
),
],
groups: ['create'],
)]
public ?array $classes = null;
public ?string $reason = null;
public ?string $status = null;
public ?DateTimeImmutable $createdAt = null;
public ?DateTimeImmutable $endedAt = null;
public static function fromDomain(TeacherReplacement $replacement): self
{
$resource = new self();
$resource->id = (string) $replacement->id;
$resource->replacedTeacherId = (string) $replacement->replacedTeacherId;
$resource->replacementTeacherId = (string) $replacement->replacementTeacherId;
$resource->startDate = $replacement->startDate->format('Y-m-d');
$resource->endDate = $replacement->endDate->format('Y-m-d');
$resource->classes = array_map(
static fn ($pair) => [
'classId' => (string) $pair->classId,
'subjectId' => (string) $pair->subjectId,
],
$replacement->classes,
);
$resource->reason = $replacement->reason;
$resource->status = $replacement->status->value;
$resource->createdAt = $replacement->createdAt;
$resource->endedAt = $replacement->endedAt;
return $resource;
}
public static function fromDto(ReplacementDto $dto): self
{
$resource = new self();
$resource->id = $dto->id;
$resource->replacedTeacherId = $dto->replacedTeacherId;
$resource->replacementTeacherId = $dto->replacementTeacherId;
$resource->startDate = $dto->startDate->format('Y-m-d');
$resource->endDate = $dto->endDate->format('Y-m-d');
$resource->classes = $dto->classes;
$resource->reason = $dto->reason;
$resource->status = $dto->status;
return $resource;
}
}