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,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DesignateReplacement;
final readonly class DesignateReplacementCommand
{
/**
* @param array<array{classId: string, subjectId: string}> $classes
*/
public function __construct(
public string $tenantId,
public string $replacedTeacherId,
public string $replacementTeacherId,
public string $startDate,
public string $endDate,
public array $classes,
public ?string $reason,
public string $createdBy,
) {
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DesignateReplacement;
use App\Administration\Domain\Exception\TenantMismatchException;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Scolarite\Domain\Exception\UtilisateurNonEnseignantException;
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class DesignateReplacementHandler
{
public function __construct(
private TeacherReplacementRepository $replacementRepository,
private UserRepository $userRepository,
private Clock $clock,
) {
}
public function __invoke(DesignateReplacementCommand $command): TeacherReplacement
{
$tenantId = TenantId::fromString($command->tenantId);
$replacedTeacherId = UserId::fromString($command->replacedTeacherId);
$replacementTeacherId = UserId::fromString($command->replacementTeacherId);
// Valider l'existence, le tenant et le rôle des enseignants
$replacedTeacher = $this->userRepository->get($replacedTeacherId);
if (!$replacedTeacher->tenantId->equals($tenantId)) {
throw TenantMismatchException::pourUtilisateur($replacedTeacherId, $tenantId);
}
if (!$replacedTeacher->aLeRole(Role::PROF)) {
throw UtilisateurNonEnseignantException::pourUtilisateur($replacedTeacherId);
}
$replacementTeacher = $this->userRepository->get($replacementTeacherId);
if (!$replacementTeacher->tenantId->equals($tenantId)) {
throw TenantMismatchException::pourUtilisateur($replacementTeacherId, $tenantId);
}
if (!$replacementTeacher->aLeRole(Role::PROF)) {
throw UtilisateurNonEnseignantException::pourUtilisateur($replacementTeacherId);
}
$classes = array_map(
static fn (array $pair) => new ClassSubjectPair(
ClassId::fromString($pair['classId']),
SubjectId::fromString($pair['subjectId']),
),
$command->classes,
);
$replacement = TeacherReplacement::designer(
tenantId: $tenantId,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: new DateTimeImmutable($command->startDate),
endDate: new DateTimeImmutable($command->endDate),
classes: $classes,
reason: $command->reason,
createdBy: UserId::fromString($command->createdBy),
now: $this->clock->now(),
);
$this->replacementRepository->save($replacement);
return $replacement;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\EndReplacement;
final readonly class EndReplacementCommand
{
public function __construct(
public string $replacementId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\EndReplacement;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class EndReplacementHandler
{
public function __construct(
private TeacherReplacementRepository $replacementRepository,
private Clock $clock,
) {
}
public function __invoke(EndReplacementCommand $command): void
{
$replacement = $this->replacementRepository->get(
TeacherReplacementId::fromString($command->replacementId),
TenantId::fromString($command->tenantId),
);
$replacement->terminer($this->clock->now());
$this->replacementRepository->save($replacement);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetActiveReplacements;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetActiveReplacementsHandler
{
public function __construct(
private TeacherReplacementRepository $replacementRepository,
private Clock $clock,
) {
}
/** @return array<ReplacementDto> */
public function __invoke(GetActiveReplacementsQuery $query): array
{
$replacements = $this->replacementRepository->findActiveByTenant(
TenantId::fromString($query->tenantId),
$this->clock->now(),
);
return array_map(
static fn ($r) => ReplacementDto::fromDomain($r),
$replacements,
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetActiveReplacements;
final readonly class GetActiveReplacementsQuery
{
public function __construct(
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetActiveReplacements;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use DateTimeImmutable;
final readonly class ReplacementDto
{
/** @param array<array{classId: string, subjectId: string}> $classes */
public function __construct(
public string $id,
public string $replacedTeacherId,
public string $replacementTeacherId,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
public string $status,
public array $classes,
public ?string $reason,
) {
}
public static function fromDomain(TeacherReplacement $replacement): self
{
return new self(
id: (string) $replacement->id,
replacedTeacherId: (string) $replacement->replacedTeacherId,
replacementTeacherId: (string) $replacement->replacementTeacherId,
startDate: $replacement->startDate,
endDate: $replacement->endDate,
status: $replacement->status->value,
classes: array_map(
static fn ($pair) => [
'classId' => (string) $pair->classId,
'subjectId' => (string) $pair->subjectId,
],
$replacement->classes,
),
reason: $replacement->reason,
);
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetReplacedClassesForTeacher;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetReplacedClassesForTeacherHandler
{
public function __construct(
private TeacherReplacementRepository $replacementRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private Clock $clock,
) {
}
/** @return array<ReplacedClassDto> */
public function __invoke(GetReplacedClassesForTeacherQuery $query): array
{
$tenantId = TenantId::fromString($query->tenantId);
$replacements = $this->replacementRepository->findActiveByReplacementTeacher(
UserId::fromString($query->replacementTeacherId),
$tenantId,
$this->clock->now(),
);
if ($replacements === []) {
return [];
}
$classNames = [];
foreach ($this->classRepository->findAllActiveByTenant($tenantId) as $class) {
$classNames[(string) $class->id] = (string) $class->name;
}
$subjectNames = [];
foreach ($this->subjectRepository->findAllActiveByTenant($tenantId) as $subject) {
$subjectNames[(string) $subject->id] = (string) $subject->name;
}
$result = [];
foreach ($replacements as $replacement) {
foreach ($replacement->classes as $pair) {
$classId = (string) $pair->classId;
$subjectId = (string) $pair->subjectId;
$result[] = new ReplacedClassDto(
replacementId: (string) $replacement->id,
replacedTeacherId: (string) $replacement->replacedTeacherId,
classId: $classId,
subjectId: $subjectId,
className: $classNames[$classId] ?? $classId,
subjectName: $subjectNames[$subjectId] ?? $subjectId,
startDate: $replacement->startDate,
endDate: $replacement->endDate,
);
}
}
return $result;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetReplacedClassesForTeacher;
final readonly class GetReplacedClassesForTeacherQuery
{
public function __construct(
public string $replacementTeacherId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetReplacedClassesForTeacher;
use DateTimeImmutable;
final readonly class ReplacedClassDto
{
public function __construct(
public string $replacementId,
public string $replacedTeacherId,
public string $classId,
public string $subjectId,
public string $className,
public string $subjectName,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
) {
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class RemplacementDesigne implements DomainEvent
{
public function __construct(
public TeacherReplacementId $replacementId,
public UserId $replacedTeacherId,
public UserId $replacementTeacherId,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->replacementId->value;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class RemplacementTermine implements DomainEvent
{
public function __construct(
public TeacherReplacementId $replacementId,
public UserId $replacedTeacherId,
public UserId $replacementTeacherId,
public DateTimeImmutable $startDate,
public DateTimeImmutable $endDate,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->replacementId->value;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DateTimeImmutable;
use DomainException;
use function sprintf;
final class DatesRemplacementInvalidesException extends DomainException
{
public static function finAvantDebut(DateTimeImmutable $startDate, DateTimeImmutable $endDate): self
{
return new self(sprintf(
'La date de début (%s) doit être antérieure à la date de fin (%s).',
$startDate->format('Y-m-d'),
$endDate->format('Y-m-d'),
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use DomainException;
use function sprintf;
final class RemplacementDejaTermineException extends DomainException
{
public static function withId(TeacherReplacementId $id): self
{
return new self(sprintf(
'Le remplacement "%s" est déjà terminé.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use DomainException;
use function sprintf;
final class RemplacementNotFoundException extends DomainException
{
public static function withId(TeacherReplacementId $id): self
{
return new self(sprintf(
'Le remplacement avec l\'ID "%s" n\'a pas été trouvé.',
$id,
));
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
final class RemplacementSameTeacherException extends DomainException
{
public static function create(): self
{
return new self('L\'enseignant remplacé et le remplaçant ne peuvent pas être la même personne.');
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class UtilisateurNonEnseignantException extends DomainException
{
public static function pourUtilisateur(UserId $userId): self
{
return new self(sprintf(
'L\'utilisateur « %s » n\'a pas le rôle enseignant.',
$userId,
));
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\TeacherReplacement;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
final readonly class ClassSubjectPair
{
public function __construct(
public ClassId $classId,
public SubjectId $subjectId,
) {
}
public function equals(self $other): bool
{
return $this->classId->equals($other->classId)
&& $this->subjectId->equals($other->subjectId);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\TeacherReplacement;
enum ReplacementStatus: string
{
case ACTIVE = 'active';
case ENDED = 'ended';
public function estActive(): bool
{
return $this === self::ACTIVE;
}
public function label(): string
{
return match ($this) {
self::ACTIVE => 'Actif',
self::ENDED => 'Terminé',
};
}
}

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\TeacherReplacement;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\RemplacementDesigne;
use App\Scolarite\Domain\Event\RemplacementTermine;
use App\Scolarite\Domain\Exception\DatesRemplacementInvalidesException;
use App\Scolarite\Domain\Exception\RemplacementDejaTermineException;
use App\Scolarite\Domain\Exception\RemplacementSameTeacherException;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Aggregate Root représentant le remplacement temporaire d'un enseignant.
*
* Un administrateur désigne un remplaçant pour un enseignant absent, avec des dates
* de début/fin et un périmètre de classes/matières. Le remplaçant peut saisir de
* nouvelles notes et lire les notes passées (lecture seule).
*
* @see FR9: Désigner remplaçant temporaire
*/
final class TeacherReplacement extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
/** @param array<ClassSubjectPair> $classes */
private function __construct(
public private(set) TeacherReplacementId $id,
public private(set) TenantId $tenantId,
public private(set) UserId $replacedTeacherId,
public private(set) UserId $replacementTeacherId,
public private(set) DateTimeImmutable $startDate,
public private(set) DateTimeImmutable $endDate,
public private(set) ReplacementStatus $status,
public private(set) array $classes,
public private(set) ?string $reason,
public private(set) UserId $createdBy,
public private(set) DateTimeImmutable $createdAt,
public private(set) ?DateTimeImmutable $endedAt,
) {
$this->updatedAt = $createdAt;
}
/** @param array<ClassSubjectPair> $classes */
public static function designer(
TenantId $tenantId,
UserId $replacedTeacherId,
UserId $replacementTeacherId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
array $classes,
?string $reason,
UserId $createdBy,
DateTimeImmutable $now,
): self {
if ($replacedTeacherId->equals($replacementTeacherId)) {
throw RemplacementSameTeacherException::create();
}
if ($endDate < $startDate) {
throw DatesRemplacementInvalidesException::finAvantDebut($startDate, $endDate);
}
if ($classes === []) {
throw new InvalidArgumentException('Au moins une paire classe/matière est requise.');
}
$replacement = new self(
id: TeacherReplacementId::generate(),
tenantId: $tenantId,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
status: ReplacementStatus::ACTIVE,
classes: $classes,
reason: $reason,
createdBy: $createdBy,
createdAt: $now,
endedAt: null,
);
$replacement->recordEvent(new RemplacementDesigne(
replacementId: $replacement->id,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
occurredOn: $now,
));
return $replacement;
}
public function isActive(DateTimeImmutable $now): bool
{
return $this->status === ReplacementStatus::ACTIVE
&& $now >= $this->startDate
&& $now <= $this->endDate;
}
public function isExpired(DateTimeImmutable $now): bool
{
return $this->status === ReplacementStatus::ACTIVE
&& $now > $this->endDate;
}
public function terminer(DateTimeImmutable $at): void
{
if ($this->status === ReplacementStatus::ENDED) {
throw RemplacementDejaTermineException::withId($this->id);
}
$this->status = ReplacementStatus::ENDED;
$this->endedAt = $at;
$this->updatedAt = $at;
$this->recordEvent(new RemplacementTermine(
replacementId: $this->id,
replacedTeacherId: $this->replacedTeacherId,
replacementTeacherId: $this->replacementTeacherId,
startDate: $this->startDate,
endDate: $this->endDate,
occurredOn: $at,
));
}
public function couvreClasseMatiere(ClassSubjectPair $pair): bool
{
foreach ($this->classes as $classSubjectPair) {
if ($classSubjectPair->equals($pair)) {
return true;
}
}
return false;
}
/**
* Reconstitue un TeacherReplacement depuis le stockage.
*
* @internal Pour usage Infrastructure uniquement
*
* @param array<ClassSubjectPair> $classes
*/
public static function reconstitute(
TeacherReplacementId $id,
TenantId $tenantId,
UserId $replacedTeacherId,
UserId $replacementTeacherId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
ReplacementStatus $status,
array $classes,
?string $reason,
UserId $createdBy,
DateTimeImmutable $createdAt,
?DateTimeImmutable $endedAt,
DateTimeImmutable $updatedAt,
): self {
$replacement = new self(
id: $id,
tenantId: $tenantId,
replacedTeacherId: $replacedTeacherId,
replacementTeacherId: $replacementTeacherId,
startDate: $startDate,
endDate: $endDate,
status: $status,
classes: $classes,
reason: $reason,
createdBy: $createdBy,
createdAt: $createdAt,
endedAt: $endedAt,
);
$replacement->updatedAt = $updatedAt;
return $replacement;
}
}

View File

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

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\RemplacementNotFoundException;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
interface TeacherReplacementRepository
{
public function save(TeacherReplacement $replacement): void;
/** @throws RemplacementNotFoundException */
public function get(TeacherReplacementId $id, TenantId $tenantId): TeacherReplacement;
public function findById(TeacherReplacementId $id, TenantId $tenantId): ?TeacherReplacement;
public function findActiveReplacement(
UserId $replacementTeacherId,
ClassId $classId,
SubjectId $subjectId,
DateTimeImmutable $at,
TenantId $tenantId,
): ?TeacherReplacement;
/** @return array<TeacherReplacement> */
public function findActiveByTenant(TenantId $tenantId, DateTimeImmutable $at): array;
/** @return array<TeacherReplacement> */
public function findActiveByReplacementTeacher(
UserId $replacementTeacherId,
TenantId $tenantId,
DateTimeImmutable $at,
): array;
/** @return array<TeacherReplacement> */
public function findExpired(DateTimeImmutable $at): array;
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Domain\Exception\TenantMismatchException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\DesignateReplacement\DesignateReplacementCommand;
use App\Scolarite\Application\Command\DesignateReplacement\DesignateReplacementHandler;
use App\Scolarite\Domain\Exception\DatesRemplacementInvalidesException;
use App\Scolarite\Domain\Exception\RemplacementSameTeacherException;
use App\Scolarite\Domain\Exception\UtilisateurNonEnseignantException;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Bundle\SecurityBundle\Security;
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<TeacherReplacementResource, TeacherReplacementResource>
*/
final readonly class CreateTeacherReplacementProcessor implements ProcessorInterface
{
public function __construct(
private DesignateReplacementHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private Security $security,
) {
}
/**
* @param TeacherReplacementResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TeacherReplacementResource
{
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::CREATE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer un remplacement.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
try {
$command = new DesignateReplacementCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
replacedTeacherId: $data->replacedTeacherId ?? '',
replacementTeacherId: $data->replacementTeacherId ?? '',
startDate: $data->startDate ?? '',
endDate: $data->endDate ?? '',
classes: $data->classes ?? [],
reason: $data->reason,
createdBy: $user->userId(),
);
$replacement = ($this->handler)($command);
foreach ($replacement->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return TeacherReplacementResource::fromDomain($replacement);
} catch (RemplacementSameTeacherException|DatesRemplacementInvalidesException|TenantMismatchException|UtilisateurNonEnseignantException $e) {
throw new BadRequestHttpException($e->getMessage());
} catch (UserNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Scolarite\Application\Command\EndReplacement\EndReplacementCommand;
use App\Scolarite\Application\Command\EndReplacement\EndReplacementHandler;
use App\Scolarite\Domain\Exception\RemplacementDejaTermineException;
use App\Scolarite\Domain\Exception\RemplacementNotFoundException;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
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\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<TeacherReplacementResource, null>
*/
final readonly class EndTeacherReplacementProcessor implements ProcessorInterface
{
public function __construct(
private EndReplacementHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::DELETE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à terminer un remplacement.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $id */
$id = $uriVariables['id'];
try {
($this->handler)(new EndReplacementCommand(
replacementId: $id,
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
));
} catch (RemplacementNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (RemplacementDejaTermineException $e) {
throw new BadRequestHttpException($e->getMessage());
}
return null;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\GetReplacedClassesForTeacherHandler;
use App\Scolarite\Application\Query\GetReplacedClassesForTeacher\GetReplacedClassesForTeacherQuery;
use App\Scolarite\Infrastructure\Api\Resource\ReplacedClassResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* Fournit les classes du remplaçant connecté (GET /api/me/replaced-classes).
*
* @implements ProviderInterface<ReplacedClassResource>
*/
final readonly class ReplacedClassesProvider implements ProviderInterface
{
public function __construct(
private GetReplacedClassesForTeacherHandler $handler,
private TenantContext $tenantContext,
private Security $security,
) {
}
/** @return array<ReplacedClassResource> */
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$query = new GetReplacedClassesForTeacherQuery(
replacementTeacherId: $user->userId(),
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
);
return array_map(
ReplacedClassResource::fromDto(...),
($this->handler)($query),
);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
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<TeacherReplacementResource>
*/
final readonly class TeacherReplacementItemProvider implements ProviderInterface
{
public function __construct(
private TeacherReplacementRepository $replacementRepository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TeacherReplacementResource
{
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::DELETE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à terminer les remplacements.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $id */
$id = $uriVariables['id'];
$tenantId = TenantId::fromString((string) $this->tenantContext->getCurrentTenantId());
$replacement = $this->replacementRepository->findById(
TeacherReplacementId::fromString($id),
$tenantId,
);
if ($replacement === null) {
throw new NotFoundHttpException('Remplacement non trouvé.');
}
return TeacherReplacementResource::fromDomain($replacement);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Scolarite\Application\Query\GetActiveReplacements\GetActiveReplacementsHandler;
use App\Scolarite\Application\Query\GetActiveReplacements\GetActiveReplacementsQuery;
use App\Scolarite\Application\Query\GetActiveReplacements\ReplacementDto;
use App\Scolarite\Infrastructure\Api\Resource\TeacherReplacementResource;
use App\Scolarite\Infrastructure\Security\TeacherReplacementVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<TeacherReplacementResource>
*/
final readonly class TeacherReplacementsCollectionProvider implements ProviderInterface
{
public function __construct(
private GetActiveReplacementsHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/** @return array<TeacherReplacementResource> */
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
if (!$this->authorizationChecker->isGranted(TeacherReplacementVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les remplacements.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$query = new GetActiveReplacementsQuery(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
);
$dtos = ($this->handler)($query);
return array_map(
static fn (ReplacementDto $dto) => TeacherReplacementResource::fromDto($dto),
$dtos,
);
}
}

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

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Console;
use App\Scolarite\Application\Command\EndReplacement\EndReplacementCommand;
use App\Scolarite\Application\Command\EndReplacement\EndReplacementHandler;
use App\Scolarite\Domain\Exception\RemplacementDejaTermineException;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Clock;
use function count;
use Override;
use Psr\Log\LoggerInterface;
use function sprintf;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
/**
* Termine automatiquement les remplacements dont la date de fin est dépassée.
*
* CRON: 0 0 * * * php bin/console app:end-expired-replacements
*
* @see Story 2.9 - AC3: Fin automatique remplacement
*/
#[AsCommand(
name: 'app:end-expired-replacements',
description: 'Termine les remplacements dont la date de fin est dépassée',
)]
final class EndExpiredReplacementsCommand extends Command
{
public function __construct(
private readonly TeacherReplacementRepository $replacementRepository,
private readonly EndReplacementHandler $endReplacementHandler,
private readonly Clock $clock,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
#[Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Fin automatique des remplacements expirés');
$expiredReplacements = $this->replacementRepository->findExpired($this->clock->now());
if ($expiredReplacements === []) {
$io->success('Aucun remplacement expiré à traiter.');
return Command::SUCCESS;
}
$io->info(sprintf('%d remplacement(s) expiré(s) trouvé(s)', count($expiredReplacements)));
$endedCount = 0;
foreach ($expiredReplacements as $replacement) {
try {
($this->endReplacementHandler)(new EndReplacementCommand(
replacementId: (string) $replacement->id,
tenantId: (string) $replacement->tenantId,
));
$io->writeln(sprintf(
' ✅ Remplacement %s terminé (remplaçant: %s)',
$replacement->id,
$replacement->replacementTeacherId,
));
$this->logger->info('Remplacement terminé automatiquement', [
'replacement_id' => (string) $replacement->id,
'tenant_id' => (string) $replacement->tenantId,
'replaced_teacher_id' => (string) $replacement->replacedTeacherId,
'replacement_teacher_id' => (string) $replacement->replacementTeacherId,
]);
++$endedCount;
} catch (RemplacementDejaTermineException $e) {
$io->warning(sprintf(
' ⚠ Remplacement %s déjà terminé, ignoré',
$replacement->id,
));
$this->logger->info('Remplacement déjà terminé, ignoré lors du traitement automatique', [
'replacement_id' => (string) $replacement->id,
]);
} catch (Throwable $e) {
$io->error(sprintf(
'Erreur pour le remplacement %s : %s',
$replacement->id,
$e->getMessage(),
));
$this->logger->error('Erreur lors de la terminaison automatique du remplacement', [
'replacement_id' => (string) $replacement->id,
'error' => $e->getMessage(),
]);
}
}
$io->success(sprintf('%d remplacement(s) terminé(s) avec succès.', $endedCount));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\RemplacementNotFoundException;
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\ReplacementStatus;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineTeacherReplacementRepository implements TeacherReplacementRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(TeacherReplacement $replacement): void
{
$this->connection->executeStatement(
'INSERT INTO teacher_replacements (id, tenant_id, replaced_teacher_id, replacement_teacher_id, start_date, end_date, status, reason, created_by, created_at, ended_at, updated_at)
VALUES (:id, :tenant_id, :replaced_teacher_id, :replacement_teacher_id, :start_date, :end_date, :status, :reason, :created_by, :created_at, :ended_at, :updated_at)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
ended_at = EXCLUDED.ended_at,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $replacement->id,
'tenant_id' => (string) $replacement->tenantId,
'replaced_teacher_id' => (string) $replacement->replacedTeacherId,
'replacement_teacher_id' => (string) $replacement->replacementTeacherId,
'start_date' => $replacement->startDate->format('Y-m-d'),
'end_date' => $replacement->endDate->format('Y-m-d'),
'status' => $replacement->status->value,
'reason' => $replacement->reason,
'created_by' => (string) $replacement->createdBy,
'created_at' => $replacement->createdAt->format(DateTimeImmutable::ATOM),
'ended_at' => $replacement->endedAt?->format(DateTimeImmutable::ATOM),
'updated_at' => $replacement->updatedAt->format(DateTimeImmutable::ATOM),
],
);
// Upsert replacement_classes
$this->connection->executeStatement(
'DELETE FROM replacement_classes WHERE replacement_id = :replacement_id',
['replacement_id' => (string) $replacement->id],
);
foreach ($replacement->classes as $pair) {
$this->connection->executeStatement(
'INSERT INTO replacement_classes (replacement_id, class_id, subject_id) VALUES (:replacement_id, :class_id, :subject_id)',
[
'replacement_id' => (string) $replacement->id,
'class_id' => (string) $pair->classId,
'subject_id' => (string) $pair->subjectId,
],
);
}
}
#[Override]
public function get(TeacherReplacementId $id, TenantId $tenantId): TeacherReplacement
{
$replacement = $this->findById($id, $tenantId);
if ($replacement === null) {
throw RemplacementNotFoundException::withId($id);
}
return $replacement;
}
#[Override]
public function findById(TeacherReplacementId $id, TenantId $tenantId): ?TeacherReplacement
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM teacher_replacements 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 findActiveReplacement(
UserId $replacementTeacherId,
ClassId $classId,
SubjectId $subjectId,
DateTimeImmutable $at,
TenantId $tenantId,
): ?TeacherReplacement {
$row = $this->connection->fetchAssociative(
'SELECT tr.* FROM teacher_replacements tr
JOIN replacement_classes rc ON rc.replacement_id = tr.id
WHERE tr.tenant_id = :tenant_id
AND tr.replacement_teacher_id = :replacement_teacher_id
AND rc.class_id = :class_id
AND rc.subject_id = :subject_id
AND tr.status = :status
AND tr.start_date <= :at
AND tr.end_date >= :at',
[
'tenant_id' => (string) $tenantId,
'replacement_teacher_id' => (string) $replacementTeacherId,
'class_id' => (string) $classId,
'subject_id' => (string) $subjectId,
'status' => ReplacementStatus::ACTIVE->value,
'at' => $at->format('Y-m-d'),
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findActiveByTenant(TenantId $tenantId, DateTimeImmutable $at): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_replacements
WHERE tenant_id = :tenant_id
AND status = :status
AND start_date <= :at
AND end_date >= :at
ORDER BY start_date ASC',
[
'tenant_id' => (string) $tenantId,
'status' => ReplacementStatus::ACTIVE->value,
'at' => $at->format('Y-m-d'),
],
);
return $this->hydrateMany($rows);
}
#[Override]
public function findActiveByReplacementTeacher(
UserId $replacementTeacherId,
TenantId $tenantId,
DateTimeImmutable $at,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_replacements
WHERE replacement_teacher_id = :replacement_teacher_id
AND tenant_id = :tenant_id
AND status = :status
AND start_date <= :at
AND end_date >= :at
ORDER BY created_at ASC',
[
'replacement_teacher_id' => (string) $replacementTeacherId,
'tenant_id' => (string) $tenantId,
'status' => ReplacementStatus::ACTIVE->value,
'at' => $at->format('Y-m-d'),
],
);
return $this->hydrateMany($rows);
}
#[Override]
public function findExpired(DateTimeImmutable $at): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_replacements
WHERE status = :status
AND end_date < :at
ORDER BY end_date ASC',
[
'status' => ReplacementStatus::ACTIVE->value,
'at' => $at->format('Y-m-d'),
],
);
return $this->hydrateMany($rows);
}
/** @return array<ClassSubjectPair> */
private function loadClasses(string $replacementId): array
{
return $this->loadClassesBatch([$replacementId])[$replacementId] ?? [];
}
/**
* @param list<string> $replacementIds
*
* @return array<string, list<ClassSubjectPair>>
*/
private function loadClassesBatch(array $replacementIds): array
{
if ($replacementIds === []) {
return [];
}
$placeholders = implode(', ', array_fill(0, count($replacementIds), '?'));
$rows = $this->connection->fetchAllAssociative(
"SELECT replacement_id, class_id, subject_id FROM replacement_classes WHERE replacement_id IN ($placeholders)",
array_values($replacementIds),
);
$grouped = [];
foreach ($rows as $row) {
/** @var string $replacementId */
$replacementId = $row['replacement_id'];
/** @var string $classId */
$classId = $row['class_id'];
/** @var string $subjectId */
$subjectId = $row['subject_id'];
$grouped[$replacementId][] = new ClassSubjectPair(
ClassId::fromString($classId),
SubjectId::fromString($subjectId),
);
}
return $grouped;
}
/**
* @param list<array<string, mixed>> $rows
*
* @return list<TeacherReplacement>
*/
private function hydrateMany(array $rows): array
{
if ($rows === []) {
return [];
}
$ids = array_map(static function (array $row): string {
/** @var string $id */
$id = $row['id'];
return $id;
}, $rows);
$classesByReplacement = $this->loadClassesBatch($ids);
return array_map(
function (array $row) use ($classesByReplacement) {
/** @var string $id */
$id = $row['id'];
return $this->hydrate($row, $classesByReplacement[$id] ?? []);
},
$rows,
);
}
/**
* @param array<string, mixed> $row
* @param array<ClassSubjectPair>|null $classes Pre-loaded classes (null = load individually)
*/
private function hydrate(array $row, ?array $classes = null): TeacherReplacement
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $replacedTeacherId */
$replacedTeacherId = $row['replaced_teacher_id'];
/** @var string $replacementTeacherId */
$replacementTeacherId = $row['replacement_teacher_id'];
/** @var string $startDate */
$startDate = $row['start_date'];
/** @var string $endDate */
$endDate = $row['end_date'];
/** @var string $status */
$status = $row['status'];
/** @var string|null $reason */
$reason = $row['reason'];
/** @var string $createdBy */
$createdBy = $row['created_by'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string|null $endedAt */
$endedAt = $row['ended_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
return TeacherReplacement::reconstitute(
id: TeacherReplacementId::fromString($id),
tenantId: TenantId::fromString($tenantId),
replacedTeacherId: UserId::fromString($replacedTeacherId),
replacementTeacherId: UserId::fromString($replacementTeacherId),
startDate: new DateTimeImmutable($startDate),
endDate: new DateTimeImmutable($endDate),
status: ReplacementStatus::from($status),
classes: $classes ?? $this->loadClasses($id),
reason: $reason,
createdBy: UserId::fromString($createdBy),
createdAt: new DateTimeImmutable($createdAt),
endedAt: $endedAt !== null ? new DateTimeImmutable($endedAt) : null,
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\RemplacementNotFoundException;
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
use App\Scolarite\Domain\Model\TeacherReplacement\ReplacementStatus;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacementId;
use App\Scolarite\Domain\Repository\TeacherReplacementRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
final class InMemoryTeacherReplacementRepository implements TeacherReplacementRepository
{
/** @var array<string, TeacherReplacement> */
private array $byId = [];
#[Override]
public function save(TeacherReplacement $replacement): void
{
$this->byId[(string) $replacement->id] = $replacement;
}
#[Override]
public function get(TeacherReplacementId $id, TenantId $tenantId): TeacherReplacement
{
$replacement = $this->findById($id, $tenantId);
if ($replacement === null) {
throw RemplacementNotFoundException::withId($id);
}
return $replacement;
}
#[Override]
public function findById(TeacherReplacementId $id, TenantId $tenantId): ?TeacherReplacement
{
$replacement = $this->byId[(string) $id] ?? null;
if ($replacement !== null && !$replacement->tenantId->equals($tenantId)) {
return null;
}
return $replacement;
}
#[Override]
public function findActiveReplacement(
UserId $replacementTeacherId,
ClassId $classId,
SubjectId $subjectId,
DateTimeImmutable $at,
TenantId $tenantId,
): ?TeacherReplacement {
$pair = new ClassSubjectPair($classId, $subjectId);
foreach ($this->byId as $replacement) {
if ($replacement->tenantId->equals($tenantId)
&& $replacement->replacementTeacherId->equals($replacementTeacherId)
&& $replacement->isActive($at)
&& $replacement->couvreClasseMatiere($pair)
) {
return $replacement;
}
}
return null;
}
#[Override]
public function findActiveByTenant(TenantId $tenantId, DateTimeImmutable $at): array
{
return array_values(array_filter(
$this->byId,
static fn (TeacherReplacement $r) => $r->tenantId->equals($tenantId)
&& $r->isActive($at),
));
}
#[Override]
public function findActiveByReplacementTeacher(
UserId $replacementTeacherId,
TenantId $tenantId,
DateTimeImmutable $at,
): array {
return array_values(array_filter(
$this->byId,
static fn (TeacherReplacement $r) => $r->tenantId->equals($tenantId)
&& $r->replacementTeacherId->equals($replacementTeacherId)
&& $r->isActive($at),
));
}
#[Override]
public function findExpired(DateTimeImmutable $at): array
{
return array_values(array_filter(
$this->byId,
static fn (TeacherReplacement $r) => $r->status === ReplacementStatus::ACTIVE
&& $r->isExpired($at),
));
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
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 les remplacements enseignants.
*
* Règles d'accès :
* - ADMIN et SUPER_ADMIN : accès complet (CRUD)
* - ENSEIGNANT : lecture seule (ses propres remplacements actifs/passifs)
* - Autres rôles : pas d'accès
*
* @extends Voter<string, TeacherReplacement|null>
*/
final class TeacherReplacementVoter extends Voter
{
public const string VIEW = 'TEACHER_REPLACEMENT_VIEW';
public const string CREATE = 'TEACHER_REPLACEMENT_CREATE';
public const string DELETE = 'TEACHER_REPLACEMENT_DELETE';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
self::CREATE,
self::DELETE,
];
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) {
return false;
}
if ($subject === null) {
return true;
}
return $subject instanceof TeacherReplacement;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof SecurityUser) {
return false;
}
$roles = $user->getRoles();
return match ($attribute) {
self::VIEW => $this->canView($roles, $user, $subject),
self::CREATE => $this->canCreate($roles),
self::DELETE => $this->canDelete($roles),
default => false,
};
}
/** @param string[] $roles */
private function canView(array $roles, SecurityUser $user, mixed $subject): bool
{
if ($this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
])) {
return true;
}
if ($this->hasAnyRole($roles, [Role::PROF->value])) {
return $this->isInvolvedTeacher($user, $subject);
}
return false;
}
private function isInvolvedTeacher(SecurityUser $user, mixed $subject): bool
{
if (!$subject instanceof TeacherReplacement) {
return false;
}
return (string) $subject->replacedTeacherId === $user->userId()
|| (string) $subject->replacementTeacherId === $user->userId();
}
/** @param string[] $roles */
private function canCreate(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/** @param string[] $roles */
private function canDelete(array $roles): bool
{
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;
}
}