feat: Liaison parents-enfants avec gestion des tuteurs

Les parents doivent pouvoir suivre la scolarité de leurs enfants (notes,
emploi du temps, devoirs). Cela nécessite un lien formalisé entre le
compte parent et le compte élève, géré par les administrateurs.

Le lien est établi soit manuellement via l'interface d'administration,
soit automatiquement lors de l'activation du compte parent lorsque
l'invitation inclut un élève cible. Ce lien conditionne l'accès aux
données scolaires de l'enfant (autorisations vérifiées par un voter
dédié).
This commit is contained in:
2026-02-12 08:38:19 +01:00
parent e930c505df
commit 44ebe5e511
91 changed files with 10071 additions and 39 deletions

View File

@@ -8,11 +8,14 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand;
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
use App\Administration\Application\Command\LinkParentToStudent\LinkParentToStudentCommand;
use App\Administration\Application\Command\LinkParentToStudent\LinkParentToStudentHandler;
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
use App\Administration\Domain\Exception\CompteNonActivableException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Administration\Domain\Repository\ActivationTokenRepository;
@@ -21,9 +24,11 @@ use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
use App\Administration\Infrastructure\Api\Resource\ActivateAccountOutput;
use App\Shared\Domain\Clock;
use Override;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Throwable;
/**
* API Platform processor for account activation.
@@ -39,6 +44,8 @@ final readonly class ActivateAccountProcessor implements ProcessorInterface
private ConsentementParentalPolicy $consentementPolicy,
private Clock $clock,
private MessageBusInterface $eventBus,
private LinkParentToStudentHandler $linkHandler,
private LoggerInterface $logger,
) {
}
@@ -81,6 +88,29 @@ final readonly class ActivateAccountProcessor implements ProcessorInterface
// Delete token only after successful user activation
// This ensures failed activations (e.g., missing parental consent) don't burn the token
$this->tokenRepository->deleteByTokenValue($data->tokenValue);
// Create automatic parent-student link if invitation included a studentId
// Linking failure is non-fatal: the activation is the primary goal
if ($result->studentId !== null) {
try {
$link = ($this->linkHandler)(new LinkParentToStudentCommand(
studentId: $result->studentId,
guardianId: $result->userId,
relationshipType: $result->relationshipType ?? RelationshipType::OTHER->value,
tenantId: (string) $result->tenantId,
));
foreach ($link->pullDomainEvents() as $linkEvent) {
$this->eventBus->dispatch($linkEvent);
}
} catch (Throwable $e) {
$this->logger->warning('Auto-link parent-élève échoué lors de l\'activation : {message}', [
'message' => $e->getMessage(),
'userId' => $result->userId,
'studentId' => $result->studentId,
]);
}
}
} catch (UserNotFoundException) {
throw new NotFoundHttpException('Utilisateur introuvable.');
} catch (CompteNonActivableException $e) {

View File

@@ -75,14 +75,19 @@ final readonly class InviteUserProcessor implements ProcessorInterface
}
try {
$roles = $data->roles ?? [];
$role = $data->role ?? ($roles[0] ?? '');
$command = new InviteUserCommand(
tenantId: $tenantId,
schoolName: $tenantConfig->subdomain,
email: $data->email ?? '',
role: $data->role ?? '',
role: $role,
firstName: $data->firstName ?? '',
lastName: $data->lastName ?? '',
roles: $data->roles ?? [],
roles: $roles,
studentId: $data->studentId,
relationshipType: $data->relationshipType,
);
$user = ($this->handler)($command);

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\LinkParentToStudent\LinkParentToStudentCommand;
use App\Administration\Application\Command\LinkParentToStudent\LinkParentToStudentHandler;
use App\Administration\Domain\Exception\InvalidGuardianRoleException;
use App\Administration\Domain\Exception\InvalidStudentRoleException;
use App\Administration\Domain\Exception\LiaisonDejaExistanteException;
use App\Administration\Domain\Exception\MaxGuardiansReachedException;
use App\Administration\Domain\Exception\TenantMismatchException;
use App\Administration\Infrastructure\Api\Resource\StudentGuardianResource;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Security\StudentGuardianVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use InvalidArgumentException;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
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\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<StudentGuardianResource, StudentGuardianResource>
*/
final readonly class LinkParentToStudentProcessor implements ProcessorInterface
{
public function __construct(
private LinkParentToStudentHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private MessageBusInterface $eventBus,
private Security $security,
) {
}
/**
* @param StudentGuardianResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): StudentGuardianResource
{
if (!$this->authorizationChecker->isGranted(StudentGuardianVoter::MANAGE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à lier un parent à un élève.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $studentId */
$studentId = $uriVariables['studentId'];
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$currentUser = $this->security->getUser();
$createdBy = $currentUser instanceof SecurityUser ? $currentUser->userId() : null;
try {
$command = new LinkParentToStudentCommand(
studentId: $studentId,
guardianId: $data->guardianId ?? '',
relationshipType: $data->relationshipType ?? '',
tenantId: $tenantId,
createdBy: $createdBy,
);
$link = ($this->handler)($command);
foreach ($link->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return StudentGuardianResource::fromDomain($link);
} catch (InvalidArgumentException|InvalidGuardianRoleException|InvalidStudentRoleException|TenantMismatchException $e) {
throw new BadRequestHttpException($e->getMessage());
} catch (LiaisonDejaExistanteException $e) {
throw new ConflictHttpException($e->getMessage());
} catch (MaxGuardiansReachedException $e) {
throw new UnprocessableEntityHttpException($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\UnlinkParentFromStudent\UnlinkParentFromStudentCommand;
use App\Administration\Application\Command\UnlinkParentFromStudent\UnlinkParentFromStudentHandler;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Administration\Infrastructure\Api\Resource\StudentGuardianResource;
use App\Administration\Infrastructure\Security\StudentGuardianVoter;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use InvalidArgumentException;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
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<StudentGuardianResource, null>
*/
final readonly class UnlinkParentFromStudentProcessor implements ProcessorInterface
{
public function __construct(
private UnlinkParentFromStudentHandler $handler,
private StudentGuardianRepository $repository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private MessageBusInterface $eventBus,
) {
}
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
if (!$this->authorizationChecker->isGranted(StudentGuardianVoter::MANAGE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à supprimer une liaison parent-élève.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $studentId */
$studentId = $uriVariables['studentId'];
/** @var string $guardianId */
$guardianId = $uriVariables['guardianId'];
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
$existingLink = $this->repository->findByStudentAndGuardian(
UserId::fromString($studentId),
UserId::fromString($guardianId),
TenantId::fromString($tenantId),
);
} catch (InvalidArgumentException) {
throw new NotFoundHttpException('Liaison parent-élève introuvable.');
}
if ($existingLink === null) {
throw new NotFoundHttpException('Liaison parent-élève introuvable.');
}
$link = ($this->handler)(new UnlinkParentFromStudentCommand(
linkId: (string) $existingLink->id,
tenantId: $tenantId,
));
foreach ($link->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return null;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Administration\Infrastructure\Api\Resource\StudentGuardianResource;
use App\Administration\Infrastructure\Security\StudentGuardianVoter;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use InvalidArgumentException;
use Override;
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 une liaison parent-élève individuelle.
*
* @implements ProviderInterface<StudentGuardianResource>
*/
final readonly class GuardianItemProvider implements ProviderInterface
{
public function __construct(
private StudentGuardianRepository $repository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?StudentGuardianResource
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $studentId */
$studentId = $uriVariables['studentId'];
if (!$this->authorizationChecker->isGranted(StudentGuardianVoter::VIEW_STUDENT, $studentId)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les parents de cet élève.');
}
/** @var string $guardianId */
$guardianId = $uriVariables['guardianId'];
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
$link = $this->repository->findByStudentAndGuardian(
UserId::fromString($studentId),
UserId::fromString($guardianId),
TenantId::fromString($tenantId),
);
} catch (InvalidArgumentException) {
return null;
}
if ($link === null) {
return null;
}
return StudentGuardianResource::fromDomain($link);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetParentsForStudent\GetParentsForStudentHandler;
use App\Administration\Application\Query\GetParentsForStudent\GetParentsForStudentQuery;
use App\Administration\Application\Query\GetParentsForStudent\GuardianForStudentDto;
use App\Administration\Infrastructure\Api\Resource\StudentGuardianResource;
use App\Administration\Infrastructure\Security\StudentGuardianVoter;
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;
/**
* State Provider pour récupérer les parents/tuteurs d'un élève.
*
* @implements ProviderInterface<StudentGuardianResource>
*/
final readonly class GuardiansForStudentProvider implements ProviderInterface
{
public function __construct(
private GetParentsForStudentHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @return StudentGuardianResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $studentId */
$studentId = $uriVariables['studentId'];
if (!$this->authorizationChecker->isGranted(StudentGuardianVoter::VIEW_STUDENT, $studentId)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les parents de cet élève.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$dtos = ($this->handler)(new GetParentsForStudentQuery(
studentId: $studentId,
tenantId: $tenantId,
));
return array_map(static function (GuardianForStudentDto $dto) use ($studentId): StudentGuardianResource {
$resource = StudentGuardianResource::fromDto($dto);
$resource->studentId = $studentId;
return $resource;
}, $dtos);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetStudentsForParent\GetStudentsForParentHandler;
use App\Administration\Application\Query\GetStudentsForParent\GetStudentsForParentQuery;
use App\Administration\Infrastructure\Api\Resource\MyChildrenResource;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* State Provider pour récupérer les enfants du parent connecté.
*
* @implements ProviderInterface<MyChildrenResource>
*/
final readonly class MyChildrenProvider implements ProviderInterface
{
public function __construct(
private GetStudentsForParentHandler $handler,
private Security $security,
private TenantContext $tenantContext,
) {
}
/**
* @return MyChildrenResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$currentUser = $this->security->getUser();
if (!$currentUser instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$dtos = ($this->handler)(new GetStudentsForParentQuery(
guardianId: $currentUser->userId(),
tenantId: $tenantId,
));
return array_map(MyChildrenResource::fromDto(...), $dtos);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Administration\Application\Query\GetStudentsForParent\StudentForParentDto;
use App\Administration\Infrastructure\Api\Provider\MyChildrenProvider;
/**
* API Resource pour les enfants du parent connecté.
*
* @see Story 2.7 - Liaison Parents-Enfants (AC2)
*/
#[ApiResource(
shortName: 'MyChildren',
operations: [
new GetCollection(
uriTemplate: '/me/children',
provider: MyChildrenProvider::class,
name: 'get_my_children',
),
],
)]
final class MyChildrenResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
public ?string $studentId = null;
public ?string $relationshipType = null;
public ?string $relationshipLabel = null;
public ?string $firstName = null;
public ?string $lastName = null;
public static function fromDto(StudentForParentDto $dto): self
{
$resource = new self();
$resource->id = $dto->linkId;
$resource->studentId = $dto->studentId;
$resource->relationshipType = $dto->relationshipType;
$resource->relationshipLabel = $dto->relationshipLabel;
$resource->firstName = $dto->firstName;
$resource->lastName = $dto->lastName;
return $resource;
}
}

View File

@@ -0,0 +1,145 @@
<?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\Link;
use ApiPlatform\Metadata\Post;
use App\Administration\Application\Query\GetParentsForStudent\GuardianForStudentDto;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Infrastructure\Api\Processor\LinkParentToStudentProcessor;
use App\Administration\Infrastructure\Api\Processor\UnlinkParentFromStudentProcessor;
use App\Administration\Infrastructure\Api\Provider\GuardianItemProvider;
use App\Administration\Infrastructure\Api\Provider\GuardiansForStudentProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la gestion des liaisons parent-élève.
*
* @see Story 2.7 - Liaison Parents-Enfants
*/
#[ApiResource(
shortName: 'StudentGuardian',
operations: [
new GetCollection(
uriTemplate: '/students/{studentId}/guardians',
uriVariables: [
'studentId' => new Link(
fromClass: self::class,
identifiers: ['studentId'],
),
],
provider: GuardiansForStudentProvider::class,
name: 'get_student_guardians',
),
new Get(
uriTemplate: '/students/{studentId}/guardians/{guardianId}',
uriVariables: [
'studentId' => new Link(
fromClass: self::class,
identifiers: ['studentId'],
),
'guardianId' => new Link(
fromClass: self::class,
identifiers: ['guardianId'],
),
],
provider: GuardianItemProvider::class,
name: 'get_student_guardian',
),
new Post(
uriTemplate: '/students/{studentId}/guardians',
uriVariables: [
'studentId' => new Link(
fromClass: self::class,
identifiers: ['studentId'],
),
],
processor: LinkParentToStudentProcessor::class,
validationContext: ['groups' => ['Default', 'create']],
name: 'link_parent_to_student',
),
new Delete(
uriTemplate: '/students/{studentId}/guardians/{guardianId}',
uriVariables: [
'studentId' => new Link(
fromClass: self::class,
identifiers: ['studentId'],
),
'guardianId' => new Link(
fromClass: self::class,
identifiers: ['guardianId'],
),
],
provider: GuardianItemProvider::class,
processor: UnlinkParentFromStudentProcessor::class,
name: 'unlink_parent_from_student',
),
],
)]
final class StudentGuardianResource
{
#[ApiProperty(identifier: false)]
public ?string $id = null;
#[ApiProperty(identifier: true)]
public ?string $studentId = null;
#[ApiProperty(identifier: true)]
#[Assert\NotBlank(message: 'L\'identifiant du parent est requis.', groups: ['create'])]
#[Assert\Uuid(message: 'L\'identifiant du parent n\'est pas un UUID valide.', groups: ['create'])]
public ?string $guardianId = null;
#[Assert\NotBlank(message: 'Le type de relation est requis.', groups: ['create'])]
#[Assert\Choice(
choices: ['père', 'mère', 'tuteur', 'tutrice', 'grand-père', 'grand-mère', 'autre'],
message: 'Le type de relation n\'est pas valide.',
groups: ['create'],
)]
public ?string $relationshipType = null;
public ?string $relationshipLabel = null;
public ?DateTimeImmutable $linkedAt = null;
public ?string $firstName = null;
public ?string $lastName = null;
public ?string $email = null;
public static function fromDomain(StudentGuardian $link): self
{
$resource = new self();
$resource->id = (string) $link->id;
$resource->studentId = (string) $link->studentId;
$resource->guardianId = (string) $link->guardianId;
$resource->relationshipType = $link->relationshipType->value;
$resource->relationshipLabel = $link->relationshipType->label();
$resource->linkedAt = $link->createdAt;
return $resource;
}
public static function fromDto(GuardianForStudentDto $dto): self
{
$resource = new self();
$resource->id = $dto->linkId;
$resource->guardianId = $dto->guardianId;
$resource->relationshipType = $dto->relationshipType;
$resource->relationshipLabel = $dto->relationshipLabel;
$resource->linkedAt = $dto->linkedAt;
$resource->firstName = $dto->firstName;
$resource->lastName = $dto->lastName;
$resource->email = $dto->email;
return $resource;
}
}

View File

@@ -82,14 +82,13 @@ final class UserResource
#[Assert\Email(message: 'L\'email n\'est pas valide.')]
public ?string $email = null;
#[Assert\NotBlank(message: 'Le rôle est requis.', groups: ['create'])]
public ?string $role = null;
public ?string $roleLabel = null;
/** @var string[]|null */
#[Assert\NotBlank(message: 'Les rôles sont requis.', groups: ['roles'])]
#[Assert\Count(min: 1, minMessage: 'Au moins un rôle est requis.', groups: ['roles'])]
#[Assert\NotBlank(message: 'Les rôles sont requis.', groups: ['create', 'roles'])]
#[Assert\Count(min: 1, minMessage: 'Au moins un rôle est requis.', groups: ['create', 'roles'])]
public ?array $roles = null;
#[Assert\NotBlank(message: 'Le prénom est requis.', groups: ['create'])]
@@ -116,6 +115,12 @@ final class UserResource
#[Assert\NotBlank(message: 'La raison de blocage est requise.', groups: ['block'])]
public ?string $reason = null;
#[ApiProperty(readable: false, writable: true)]
public ?string $studentId = null;
#[ApiProperty(readable: false, writable: true)]
public ?string $relationshipType = null;
public static function fromDomain(User $user, ?DateTimeImmutable $now = null): self
{
$resource = new self();

View File

@@ -54,7 +54,9 @@ final class CreateTestActivationTokenCommand extends Command
->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test')
->addOption('minor', null, InputOption::VALUE_NONE, 'Create a minor user (requires parental consent)')
->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain (ecole-alpha, ecole-beta)', 'ecole-alpha')
->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5174');
->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5174')
->addOption('student-id', null, InputOption::VALUE_OPTIONAL, 'Student UUID for automatic parent-child linking on activation')
->addOption('relationship-type', null, InputOption::VALUE_OPTIONAL, 'Relationship type for parent-child linking (père, mère, tuteur, autre)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -185,6 +187,11 @@ final class CreateTestActivationTokenCommand extends Command
}
// Create activation token
/** @var string|null $studentId */
$studentId = $input->getOption('student-id');
/** @var string|null $relationshipType */
$relationshipType = $input->getOption('relationship-type');
$token = ActivationToken::generate(
userId: (string) $user->id,
email: $email,
@@ -192,6 +199,8 @@ final class CreateTestActivationTokenCommand extends Command
role: $role->value,
schoolName: $schoolName,
createdAt: $now,
studentId: $studentId,
relationshipType: $relationshipType,
);
$this->activationTokenRepository->save($token);
@@ -209,6 +218,7 @@ final class CreateTestActivationTokenCommand extends Command
['Tenant', $tenantSubdomain],
['School', $schoolName],
['Minor', $isMinor ? 'Yes (requires parental consent)' : 'No'],
['Student ID', $studentId ?? 'N/A'],
['Token', $token->tokenValue],
['Expires', $token->expiresAt->format('Y-m-d H:i:s')],
]

View File

@@ -47,6 +47,8 @@ final readonly class SendInvitationEmailHandler
role: $event->role,
schoolName: $user->schoolName,
createdAt: $this->clock->now(),
studentId: $event->studentId,
relationshipType: $event->relationshipType,
);
$this->tokenRepository->save($token);

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Cache;
use App\Administration\Domain\Exception\StudentGuardianNotFoundException;
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardianId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;
final readonly class CacheStudentGuardianRepository implements StudentGuardianRepository
{
private const string KEY_BY_ID = 'sg:';
private const string KEY_STUDENT = 'sg_student:';
private const string KEY_GUARDIAN = 'sg_guardian:';
private const string KEY_PAIR = 'sg_pair:';
public function __construct(
private StudentGuardianRepository $inner,
private CacheItemPoolInterface $studentGuardiansCache,
) {
}
#[Override]
public function save(StudentGuardian $link): void
{
$this->inner->save($link);
$this->invalidateForLink($link);
}
#[Override]
public function get(StudentGuardianId $id, TenantId $tenantId): StudentGuardian
{
$cacheKey = self::KEY_BY_ID . $id . ':' . $tenantId;
$item = $this->studentGuardiansCache->getItem($cacheKey);
if ($item->isHit()) {
/** @var array{id: string, student_id: string, guardian_id: string, relationship_type: string, tenant_id: string, created_at: string, created_by: string|null} $data */
$data = $item->get();
return $this->deserialize($data);
}
$link = $this->inner->get($id, $tenantId);
$item->set($this->serialize($link));
$this->studentGuardiansCache->save($item);
return $link;
}
#[Override]
public function findGuardiansForStudent(UserId $studentId, TenantId $tenantId): array
{
$cacheKey = self::KEY_STUDENT . $studentId . ':' . $tenantId;
$item = $this->studentGuardiansCache->getItem($cacheKey);
if ($item->isHit()) {
/** @var list<array{id: string, student_id: string, guardian_id: string, relationship_type: string, tenant_id: string, created_at: string, created_by: string|null}> $rows */
$rows = $item->get();
return array_map(fn (array $row) => $this->deserialize($row), $rows);
}
$links = $this->inner->findGuardiansForStudent($studentId, $tenantId);
$item->set(array_map(fn (StudentGuardian $link) => $this->serialize($link), $links));
$this->studentGuardiansCache->save($item);
return $links;
}
#[Override]
public function findStudentsForGuardian(UserId $guardianId, TenantId $tenantId): array
{
$cacheKey = self::KEY_GUARDIAN . $guardianId . ':' . $tenantId;
$item = $this->studentGuardiansCache->getItem($cacheKey);
if ($item->isHit()) {
/** @var list<array{id: string, student_id: string, guardian_id: string, relationship_type: string, tenant_id: string, created_at: string, created_by: string|null}> $rows */
$rows = $item->get();
return array_map(fn (array $row) => $this->deserialize($row), $rows);
}
$links = $this->inner->findStudentsForGuardian($guardianId, $tenantId);
$item->set(array_map(fn (StudentGuardian $link) => $this->serialize($link), $links));
$this->studentGuardiansCache->save($item);
return $links;
}
#[Override]
public function countGuardiansForStudent(UserId $studentId, TenantId $tenantId): int
{
return $this->inner->countGuardiansForStudent($studentId, $tenantId);
}
#[Override]
public function findByStudentAndGuardian(
UserId $studentId,
UserId $guardianId,
TenantId $tenantId,
): ?StudentGuardian {
$cacheKey = self::KEY_PAIR . $studentId . ':' . $guardianId . ':' . $tenantId;
$item = $this->studentGuardiansCache->getItem($cacheKey);
if ($item->isHit()) {
/** @var array{id: string, student_id: string, guardian_id: string, relationship_type: string, tenant_id: string, created_at: string, created_by: string|null}|null $data */
$data = $item->get();
return $data !== null ? $this->deserialize($data) : null;
}
$link = $this->inner->findByStudentAndGuardian($studentId, $guardianId, $tenantId);
$item->set($link !== null ? $this->serialize($link) : null);
$this->studentGuardiansCache->save($item);
return $link;
}
#[Override]
public function delete(StudentGuardianId $id, TenantId $tenantId): void
{
try {
$link = $this->get($id, $tenantId);
} catch (StudentGuardianNotFoundException) {
return;
}
$this->inner->delete($id, $tenantId);
$this->invalidateForLink($link);
}
private function invalidateForLink(StudentGuardian $link): void
{
$this->studentGuardiansCache->deleteItem(self::KEY_BY_ID . $link->id . ':' . $link->tenantId);
$this->studentGuardiansCache->deleteItem(self::KEY_STUDENT . $link->studentId . ':' . $link->tenantId);
$this->studentGuardiansCache->deleteItem(self::KEY_GUARDIAN . $link->guardianId . ':' . $link->tenantId);
$this->studentGuardiansCache->deleteItem(self::KEY_PAIR . $link->studentId . ':' . $link->guardianId . ':' . $link->tenantId);
}
/**
* @return array{id: string, student_id: string, guardian_id: string, relationship_type: string, tenant_id: string, created_at: string, created_by: string|null}
*/
private function serialize(StudentGuardian $link): array
{
return [
'id' => (string) $link->id,
'student_id' => (string) $link->studentId,
'guardian_id' => (string) $link->guardianId,
'relationship_type' => $link->relationshipType->value,
'tenant_id' => (string) $link->tenantId,
'created_at' => $link->createdAt->format(DateTimeImmutable::ATOM),
'created_by' => $link->createdBy !== null ? (string) $link->createdBy : null,
];
}
/**
* @param array{id: string, student_id: string, guardian_id: string, relationship_type: string, tenant_id: string, created_at: string, created_by: string|null} $data
*/
private function deserialize(array $data): StudentGuardian
{
return StudentGuardian::reconstitute(
id: StudentGuardianId::fromString($data['id']),
studentId: UserId::fromString($data['student_id']),
guardianId: UserId::fromString($data['guardian_id']),
relationshipType: RelationshipType::from($data['relationship_type']),
tenantId: TenantId::fromString($data['tenant_id']),
createdAt: new DateTimeImmutable($data['created_at']),
createdBy: $data['created_by'] !== null ? UserId::fromString($data['created_by']) : null,
);
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\LiaisonDejaExistanteException;
use App\Administration\Domain\Exception\StudentGuardianNotFoundException;
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardianId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Override;
final readonly class DoctrineStudentGuardianRepository implements StudentGuardianRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(StudentGuardian $link): void
{
try {
$this->connection->executeStatement(
'INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at, created_by)
VALUES (:id, :student_id, :guardian_id, :relationship_type, :tenant_id, :created_at, :created_by)
ON CONFLICT (student_id, guardian_id, tenant_id) DO UPDATE SET
relationship_type = EXCLUDED.relationship_type',
[
'id' => (string) $link->id,
'student_id' => (string) $link->studentId,
'guardian_id' => (string) $link->guardianId,
'relationship_type' => $link->relationshipType->value,
'tenant_id' => (string) $link->tenantId,
'created_at' => $link->createdAt->format(DateTimeImmutable::ATOM),
'created_by' => $link->createdBy !== null ? (string) $link->createdBy : null,
],
);
} catch (UniqueConstraintViolationException) {
throw LiaisonDejaExistanteException::pourParentEtEleve($link->guardianId, $link->studentId);
}
}
#[Override]
public function get(StudentGuardianId $id, TenantId $tenantId): StudentGuardian
{
$link = $this->findById($id, $tenantId);
if ($link === null) {
throw StudentGuardianNotFoundException::withId($id);
}
return $link;
}
#[Override]
public function findGuardiansForStudent(UserId $studentId, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM student_guardians
WHERE student_id = :student_id
AND tenant_id = :tenant_id
ORDER BY created_at ASC',
[
'student_id' => (string) $studentId,
'tenant_id' => (string) $tenantId,
],
);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
#[Override]
public function findStudentsForGuardian(UserId $guardianId, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM student_guardians
WHERE guardian_id = :guardian_id
AND tenant_id = :tenant_id
ORDER BY created_at ASC',
[
'guardian_id' => (string) $guardianId,
'tenant_id' => (string) $tenantId,
],
);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
#[Override]
public function countGuardiansForStudent(UserId $studentId, TenantId $tenantId): int
{
/** @var int|string $count */
$count = $this->connection->fetchOne(
'SELECT COUNT(*) FROM student_guardians
WHERE student_id = :student_id
AND tenant_id = :tenant_id',
[
'student_id' => (string) $studentId,
'tenant_id' => (string) $tenantId,
],
);
return (int) $count;
}
#[Override]
public function findByStudentAndGuardian(
UserId $studentId,
UserId $guardianId,
TenantId $tenantId,
): ?StudentGuardian {
$row = $this->connection->fetchAssociative(
'SELECT * FROM student_guardians
WHERE student_id = :student_id
AND guardian_id = :guardian_id
AND tenant_id = :tenant_id',
[
'student_id' => (string) $studentId,
'guardian_id' => (string) $guardianId,
'tenant_id' => (string) $tenantId,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function delete(StudentGuardianId $id, TenantId $tenantId): void
{
$this->connection->delete('student_guardians', [
'id' => (string) $id,
'tenant_id' => (string) $tenantId,
]);
}
private function findById(StudentGuardianId $id, TenantId $tenantId): ?StudentGuardian
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM student_guardians WHERE id = :id AND tenant_id = :tenant_id',
[
'id' => (string) $id,
'tenant_id' => (string) $tenantId,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): StudentGuardian
{
/** @var string $id */
$id = $row['id'];
/** @var string $studentId */
$studentId = $row['student_id'];
/** @var string $guardianId */
$guardianId = $row['guardian_id'];
/** @var string $relationshipType */
$relationshipType = $row['relationship_type'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string|null $createdBy */
$createdBy = $row['created_by'];
return StudentGuardian::reconstitute(
id: StudentGuardianId::fromString($id),
studentId: UserId::fromString($studentId),
guardianId: UserId::fromString($guardianId),
relationshipType: RelationshipType::from($relationshipType),
tenantId: TenantId::fromString($tenantId),
createdAt: new DateTimeImmutable($createdAt),
createdBy: $createdBy !== null ? UserId::fromString($createdBy) : null,
);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\StudentGuardianNotFoundException;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardianId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use Override;
final class InMemoryStudentGuardianRepository implements StudentGuardianRepository
{
/** @var array<string, StudentGuardian> */
private array $byId = [];
#[Override]
public function save(StudentGuardian $link): void
{
$this->byId[(string) $link->id] = $link;
}
#[Override]
public function get(StudentGuardianId $id, TenantId $tenantId): StudentGuardian
{
$link = $this->byId[(string) $id] ?? null;
if ($link === null || !$link->tenantId->equals($tenantId)) {
throw StudentGuardianNotFoundException::withId($id);
}
return $link;
}
#[Override]
public function findGuardiansForStudent(UserId $studentId, TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (StudentGuardian $link) => $link->studentId->equals($studentId)
&& $link->tenantId->equals($tenantId),
));
}
#[Override]
public function findStudentsForGuardian(UserId $guardianId, TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (StudentGuardian $link) => $link->guardianId->equals($guardianId)
&& $link->tenantId->equals($tenantId),
));
}
#[Override]
public function countGuardiansForStudent(UserId $studentId, TenantId $tenantId): int
{
return count($this->findGuardiansForStudent($studentId, $tenantId));
}
#[Override]
public function findByStudentAndGuardian(
UserId $studentId,
UserId $guardianId,
TenantId $tenantId,
): ?StudentGuardian {
foreach ($this->byId as $link) {
if ($link->studentId->equals($studentId)
&& $link->guardianId->equals($guardianId)
&& $link->tenantId->equals($tenantId)) {
return $link;
}
}
return null;
}
#[Override]
public function delete(StudentGuardianId $id, TenantId $tenantId): void
{
unset($this->byId[(string) $id]);
}
}

View File

@@ -48,7 +48,7 @@ final readonly class RedisActivationTokenRepository implements ActivationTokenRe
return null;
}
/** @var array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data */
/** @var array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null, student_id?: string|null, relationship_type?: string|null} $data */
$data = $item->get();
return $this->deserialize($data);
@@ -103,7 +103,7 @@ final readonly class RedisActivationTokenRepository implements ActivationTokenRe
}
/**
* @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null}
* @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null, student_id: string|null, relationship_type: string|null}
*/
private function serialize(ActivationToken $token): array
{
@@ -118,11 +118,13 @@ final readonly class RedisActivationTokenRepository implements ActivationTokenRe
'created_at' => $token->createdAt->format(DateTimeImmutable::ATOM),
'expires_at' => $token->expiresAt->format(DateTimeImmutable::ATOM),
'used_at' => $token->usedAt?->format(DateTimeImmutable::ATOM),
'student_id' => $token->studentId,
'relationship_type' => $token->relationshipType,
];
}
/**
* @param array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data
* @param array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null, student_id?: string|null, relationship_type?: string|null} $data
*/
private function deserialize(array $data): ActivationToken
{
@@ -137,6 +139,8 @@ final readonly class RedisActivationTokenRepository implements ActivationTokenRe
createdAt: new DateTimeImmutable($data['created_at']),
expiresAt: new DateTimeImmutable($data['expires_at']),
usedAt: $data['used_at'] !== null ? new DateTimeImmutable($data['used_at']) : null,
studentId: $data['student_id'] ?? null,
relationshipType: $data['relationship_type'] ?? null,
);
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function in_array;
use InvalidArgumentException;
use function is_string;
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 l'accès aux données d'un élève.
*
* Un parent ne peut voir que les données de ses enfants liés.
* Le personnel de l'établissement a accès à tous les élèves.
*
* @extends Voter<string, string>
*/
final class StudentGuardianVoter extends Voter
{
public const string VIEW_STUDENT = 'STUDENT_GUARDIAN_VIEW_STUDENT';
public const string MANAGE = 'STUDENT_GUARDIAN_MANAGE';
public function __construct(
private readonly StudentGuardianRepository $repository,
private readonly TenantContext $tenantContext,
) {
}
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
if ($attribute === self::VIEW_STUDENT && is_string($subject)) {
return true;
}
return $attribute === self::MANAGE && $subject === null;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof SecurityUser) {
return false;
}
$roles = $user->getRoles();
if ($attribute === self::MANAGE) {
return $this->isStaff($roles);
}
if ($this->isStaff($roles)) {
return true;
}
if ($this->isParent($roles)) {
return $this->parentIsLinkedToStudent($user->userId(), $subject);
}
return false;
}
/**
* @param string[] $roles
*/
private function isStaff(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
Role::SECRETARIAT->value,
Role::PROF->value,
Role::VIE_SCOLAIRE->value,
]);
}
/**
* @param string[] $roles
*/
private function isParent(array $roles): bool
{
return in_array(Role::PARENT->value, $roles, true);
}
private function parentIsLinkedToStudent(string $guardianId, string $studentId): bool
{
if (!$this->tenantContext->hasTenant()) {
return false;
}
try {
$tenantId = $this->tenantContext->getCurrentTenantId();
$link = $this->repository->findByStudentAndGuardian(
UserId::fromString($studentId),
UserId::fromString($guardianId),
TenantId::fromString((string) $tenantId),
);
return $link !== null;
} catch (InvalidArgumentException) {
return false;
}
}
/**
* @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;
}
}