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

@@ -49,6 +49,8 @@ final readonly class ActivateAccountHandler
tenantId: $token->tenantId,
role: $token->role,
hashedPassword: $hashedPassword,
studentId: $token->studentId,
relationshipType: $token->relationshipType,
);
}
}

View File

@@ -20,6 +20,8 @@ final readonly class ActivateAccountResult
public TenantId $tenantId,
public string $role,
public string $hashedPassword,
public ?string $studentId = null,
public ?string $relationshipType = null,
) {
}
}

View File

@@ -25,6 +25,8 @@ final readonly class InviteUserCommand
public string $lastName,
public ?string $dateNaissance = null,
array $roles = [],
public ?string $studentId = null,
public ?string $relationshipType = null,
) {
$resolved = $roles !== [] ? $roles : [$role];

View File

@@ -68,6 +68,8 @@ final readonly class InviteUserHandler
dateNaissance: $command->dateNaissance !== null
? new DateTimeImmutable($command->dateNaissance)
: null,
studentId: $command->studentId,
relationshipType: $command->relationshipType,
);
foreach (array_slice($roles, 1) as $additionalRole) {

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\LinkParentToStudent;
/**
* Command pour lier un parent/tuteur à un élève.
*/
final readonly class LinkParentToStudentCommand
{
public function __construct(
public string $studentId,
public string $guardianId,
public string $relationshipType,
public string $tenantId,
public ?string $createdBy = null,
) {
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\LinkParentToStudent;
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\Domain\Model\StudentGuardian\RelationshipType;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use InvalidArgumentException;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour lier un parent/tuteur à un élève.
*
* Vérifie :
* - Que la liaison n'existe pas déjà
* - Que le maximum de parents/tuteurs n'est pas atteint
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class LinkParentToStudentHandler
{
public function __construct(
private StudentGuardianRepository $repository,
private UserRepository $userRepository,
private Clock $clock,
) {
}
public function __invoke(LinkParentToStudentCommand $command): StudentGuardian
{
$studentId = UserId::fromString($command->studentId);
$guardianId = UserId::fromString($command->guardianId);
$tenantId = TenantId::fromString($command->tenantId);
$relationshipType = RelationshipType::tryFrom($command->relationshipType);
if ($relationshipType === null) {
throw new InvalidArgumentException("Type de relation invalide : \"{$command->relationshipType}\".");
}
$createdBy = $command->createdBy !== null
? UserId::fromString($command->createdBy)
: null;
$guardian = $this->userRepository->get($guardianId);
if (!$guardian->tenantId->equals($tenantId)) {
throw TenantMismatchException::pourUtilisateur($guardianId, $tenantId);
}
if (!$guardian->aLeRole(Role::PARENT)) {
throw InvalidGuardianRoleException::pourUtilisateur($guardianId);
}
$student = $this->userRepository->get($studentId);
if (!$student->tenantId->equals($tenantId)) {
throw TenantMismatchException::pourUtilisateur($studentId, $tenantId);
}
if (!$student->aLeRole(Role::ELEVE)) {
throw InvalidStudentRoleException::pourUtilisateur($studentId);
}
$existingLink = $this->repository->findByStudentAndGuardian($studentId, $guardianId, $tenantId);
if ($existingLink !== null) {
throw LiaisonDejaExistanteException::pourParentEtEleve($guardianId, $studentId);
}
// Note: this count-then-insert pattern is subject to a race condition under concurrent
// requests. The DB unique constraint prevents duplicate (student, guardian, tenant) pairs,
// but cannot enforce a max-count. In practice, simultaneous additions by different admins
// for the same student are extremely unlikely in a school context.
$count = $this->repository->countGuardiansForStudent($studentId, $tenantId);
if ($count >= StudentGuardian::MAX_GUARDIANS_PER_STUDENT) {
throw MaxGuardiansReachedException::pourEleve($studentId);
}
$link = StudentGuardian::lier(
studentId: $studentId,
guardianId: $guardianId,
relationshipType: $relationshipType,
tenantId: $tenantId,
createdAt: $this->clock->now(),
createdBy: $createdBy,
);
$this->repository->save($link);
return $link;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UnlinkParentFromStudent;
/**
* Command pour supprimer la liaison parent-élève.
*/
final readonly class UnlinkParentFromStudentCommand
{
public function __construct(
public string $linkId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UnlinkParentFromStudent;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardianId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour supprimer une liaison parent-élève.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UnlinkParentFromStudentHandler
{
public function __construct(
private StudentGuardianRepository $repository,
private Clock $clock,
) {
}
public function __invoke(UnlinkParentFromStudentCommand $command): StudentGuardian
{
$linkId = StudentGuardianId::fromString($command->linkId);
$tenantId = TenantId::fromString($command->tenantId);
$link = $this->repository->get($linkId, $tenantId);
$link->delier($this->clock->now());
$this->repository->delete($linkId, $tenantId);
return $link;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentsForStudent;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour récupérer les parents/tuteurs liés à un élève.
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetParentsForStudentHandler
{
public function __construct(
private StudentGuardianRepository $repository,
private UserRepository $userRepository,
) {
}
/**
* @return GuardianForStudentDto[]
*/
public function __invoke(GetParentsForStudentQuery $query): array
{
$links = $this->repository->findGuardiansForStudent(
UserId::fromString($query->studentId),
TenantId::fromString($query->tenantId),
);
return array_map(
fn ($link) => GuardianForStudentDto::fromDomainWithUser(
$link,
$this->userRepository->get($link->guardianId),
),
$links,
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentsForStudent;
/**
* Query pour récupérer les parents/tuteurs liés à un élève.
*/
final readonly class GetParentsForStudentQuery
{
public function __construct(
public string $studentId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentsForStudent;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\User\User;
use DateTimeImmutable;
/**
* DTO représentant un parent/tuteur lié à un élève.
*/
final readonly class GuardianForStudentDto
{
public function __construct(
public string $linkId,
public string $guardianId,
public string $relationshipType,
public string $relationshipLabel,
public DateTimeImmutable $linkedAt,
public string $firstName,
public string $lastName,
public string $email,
) {
}
public static function fromDomainWithUser(StudentGuardian $link, User $guardian): self
{
return new self(
linkId: (string) $link->id,
guardianId: (string) $link->guardianId,
relationshipType: $link->relationshipType->value,
relationshipLabel: $link->relationshipType->label(),
linkedAt: $link->createdAt,
firstName: $guardian->firstName,
lastName: $guardian->lastName,
email: (string) $guardian->email,
);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsForParent;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour récupérer les élèves liés à un parent.
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetStudentsForParentHandler
{
public function __construct(
private StudentGuardianRepository $repository,
private UserRepository $userRepository,
) {
}
/**
* @return StudentForParentDto[]
*/
public function __invoke(GetStudentsForParentQuery $query): array
{
$links = $this->repository->findStudentsForGuardian(
UserId::fromString($query->guardianId),
TenantId::fromString($query->tenantId),
);
return array_map(
fn ($link) => StudentForParentDto::fromDomainWithUser(
$link,
$this->userRepository->get($link->studentId),
),
$links,
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsForParent;
/**
* Query pour récupérer les élèves liés à un parent.
*/
final readonly class GetStudentsForParentQuery
{
public function __construct(
public string $guardianId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsForParent;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\User\User;
/**
* DTO représentant un élève lié à un parent.
*/
final readonly class StudentForParentDto
{
public function __construct(
public string $linkId,
public string $studentId,
public string $relationshipType,
public string $relationshipLabel,
public string $firstName,
public string $lastName,
) {
}
public static function fromDomainWithUser(StudentGuardian $link, User $student): self
{
return new self(
linkId: (string) $link->id,
studentId: (string) $link->studentId,
relationshipType: $link->relationshipType->value,
relationshipLabel: $link->relationshipType->label(),
firstName: $student->firstName,
lastName: $student->lastName,
);
}
}