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:
@@ -49,6 +49,8 @@ final readonly class ActivateAccountHandler
|
||||
tenantId: $token->tenantId,
|
||||
role: $token->role,
|
||||
hashedPassword: $hashedPassword,
|
||||
studentId: $token->studentId,
|
||||
relationshipType: $token->relationshipType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user