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

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardianId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Event émis lors de la suppression de la liaison parent-élève.
*/
final readonly class ParentDelieDEleve implements DomainEvent
{
public function __construct(
public StudentGuardianId $linkId,
public UserId $studentId,
public UserId $guardianId,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->linkId->value;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardianId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Event émis lors de la liaison d'un parent à un élève.
*/
final readonly class ParentLieAEleve implements DomainEvent
{
public function __construct(
public StudentGuardianId $linkId,
public UserId $studentId,
public UserId $guardianId,
public RelationshipType $relationshipType,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->linkId->value;
}
}

View File

@@ -21,6 +21,8 @@ final readonly class UtilisateurInvite implements DomainEvent
public string $lastName,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
public ?string $studentId = null,
public ?string $relationshipType = null,
) {
}

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class LiaisonDejaExistanteException extends DomainException
{
public static function pourParentEtEleve(UserId $guardianId, UserId $studentId): self
{
return new self(sprintf(
'Le parent %s est déjà lié à l\'élève %s.',
$guardianId,
$studentId,
));
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class MaxGuardiansReachedException extends DomainException
{
public static function pourEleve(UserId $studentId): self
{
return new self(sprintf(
'Un élève ne peut avoir que %d parents/tuteurs liés maximum (élève : %s).',
StudentGuardian::MAX_GUARDIANS_PER_STUDENT,
$studentId,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardianId;
use DomainException;
use function sprintf;
final class StudentGuardianNotFoundException extends DomainException
{
public static function withId(StudentGuardianId $id): self
{
return new self(sprintf(
'Liaison parent-élève avec l\'ID « %s » introuvable.',
$id,
));
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
use DomainException;
use function sprintf;
final class TenantMismatchException extends DomainException
{
public static function pourUtilisateur(UserId $userId, TenantId $expected): self
{
return new self(sprintf(
'L\'utilisateur « %s » n\'appartient pas au tenant « %s ».',
$userId,
$expected,
));
}
}

View File

@@ -31,6 +31,8 @@ final class ActivationToken extends AggregateRoot
public private(set) string $schoolName,
public private(set) DateTimeImmutable $createdAt,
public private(set) DateTimeImmutable $expiresAt,
public private(set) ?string $studentId = null,
public private(set) ?string $relationshipType = null,
) {
}
@@ -41,6 +43,8 @@ final class ActivationToken extends AggregateRoot
string $role,
string $schoolName,
DateTimeImmutable $createdAt,
?string $studentId = null,
?string $relationshipType = null,
): self {
$token = new self(
id: ActivationTokenId::generate(),
@@ -52,6 +56,8 @@ final class ActivationToken extends AggregateRoot
schoolName: $schoolName,
createdAt: $createdAt,
expiresAt: $createdAt->modify(sprintf('+%d days', self::EXPIRATION_DAYS)),
studentId: $studentId,
relationshipType: $relationshipType,
);
$token->recordEvent(new ActivationTokenGenerated(
@@ -82,6 +88,8 @@ final class ActivationToken extends AggregateRoot
DateTimeImmutable $createdAt,
DateTimeImmutable $expiresAt,
?DateTimeImmutable $usedAt,
?string $studentId = null,
?string $relationshipType = null,
): self {
$token = new self(
id: $id,
@@ -93,6 +101,8 @@ final class ActivationToken extends AggregateRoot
schoolName: $schoolName,
createdAt: $createdAt,
expiresAt: $expiresAt,
studentId: $studentId,
relationshipType: $relationshipType,
);
$token->usedAt = $usedAt;

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\StudentGuardian;
/**
* Type de relation entre un parent/tuteur et un élève.
*/
enum RelationshipType: string
{
case FATHER = 'père';
case MOTHER = 'mère';
case TUTOR_M = 'tuteur';
case TUTOR_F = 'tutrice';
case GRANDPARENT_M = 'grand-père';
case GRANDPARENT_F = 'grand-mère';
case OTHER = 'autre';
/**
* Retourne le libellé pour affichage.
*/
public function label(): string
{
return match ($this) {
self::FATHER => 'Père',
self::MOTHER => 'Mère',
self::TUTOR_M => 'Tuteur',
self::TUTOR_F => 'Tutrice',
self::GRANDPARENT_M => 'Grand-père',
self::GRANDPARENT_F => 'Grand-mère',
self::OTHER => 'Autre',
};
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\StudentGuardian;
use App\Administration\Domain\Event\ParentDelieDEleve;
use App\Administration\Domain\Event\ParentLieAEleve;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root représentant la liaison entre un parent/tuteur et un élève.
*
* Un élève peut avoir au maximum 2 parents/tuteurs liés (contrainte métier).
* La vérification du maximum est faite au niveau Application Layer via le repository.
*
* @see FR6: Le parent peut suivre la scolarité de ses enfants
*/
final class StudentGuardian extends AggregateRoot
{
public const int MAX_GUARDIANS_PER_STUDENT = 2;
private function __construct(
public private(set) StudentGuardianId $id,
public private(set) UserId $studentId,
public private(set) UserId $guardianId,
public private(set) RelationshipType $relationshipType,
public private(set) TenantId $tenantId,
public private(set) DateTimeImmutable $createdAt,
public private(set) ?UserId $createdBy,
) {
}
/**
* Crée une liaison parent-élève.
*/
public static function lier(
UserId $studentId,
UserId $guardianId,
RelationshipType $relationshipType,
TenantId $tenantId,
DateTimeImmutable $createdAt,
?UserId $createdBy = null,
): self {
$link = new self(
id: StudentGuardianId::generate(),
studentId: $studentId,
guardianId: $guardianId,
relationshipType: $relationshipType,
tenantId: $tenantId,
createdAt: $createdAt,
createdBy: $createdBy,
);
$link->recordEvent(new ParentLieAEleve(
linkId: $link->id,
studentId: $link->studentId,
guardianId: $link->guardianId,
relationshipType: $link->relationshipType,
tenantId: $link->tenantId,
occurredOn: $createdAt,
));
return $link;
}
/**
* Enregistre un événement de suppression de liaison.
*/
public function delier(DateTimeImmutable $at): void
{
$this->recordEvent(new ParentDelieDEleve(
linkId: $this->id,
studentId: $this->studentId,
guardianId: $this->guardianId,
tenantId: $this->tenantId,
occurredOn: $at,
));
}
/**
* Reconstitue une liaison depuis le stockage.
*
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
StudentGuardianId $id,
UserId $studentId,
UserId $guardianId,
RelationshipType $relationshipType,
TenantId $tenantId,
DateTimeImmutable $createdAt,
?UserId $createdBy,
): self {
return new self(
id: $id,
studentId: $studentId,
guardianId: $guardianId,
relationshipType: $relationshipType,
tenantId: $tenantId,
createdAt: $createdAt,
createdBy: $createdBy,
);
}
}

View File

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

View File

@@ -257,6 +257,8 @@ final class User extends AggregateRoot
string $lastName,
DateTimeImmutable $invitedAt,
?DateTimeImmutable $dateNaissance = null,
?string $studentId = null,
?string $relationshipType = null,
): self {
$user = new self(
id: UserId::generate(),
@@ -281,6 +283,8 @@ final class User extends AggregateRoot
lastName: $lastName,
tenantId: $user->tenantId,
occurredOn: $invitedAt,
studentId: $studentId,
relationshipType: $relationshipType,
));
return $user;

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
use App\Administration\Domain\Model\StudentGuardian\StudentGuardianId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
interface StudentGuardianRepository
{
public function save(StudentGuardian $link): void;
/**
* @throws \App\Administration\Domain\Exception\StudentGuardianNotFoundException
*/
public function get(StudentGuardianId $id, TenantId $tenantId): StudentGuardian;
/**
* Retourne les parents/tuteurs liés à un élève.
*
* @return StudentGuardian[]
*/
public function findGuardiansForStudent(UserId $studentId, TenantId $tenantId): array;
/**
* Retourne les élèves liés à un parent/tuteur.
*
* @return StudentGuardian[]
*/
public function findStudentsForGuardian(UserId $guardianId, TenantId $tenantId): array;
/**
* Compte le nombre de parents/tuteurs liés à un élève.
*/
public function countGuardiansForStudent(UserId $studentId, TenantId $tenantId): int;
/**
* Recherche une liaison existante entre un parent et un élève.
*/
public function findByStudentAndGuardian(
UserId $studentId,
UserId $guardianId,
TenantId $tenantId,
): ?StudentGuardian;
public function delete(StudentGuardianId $id, TenantId $tenantId): void;
}