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:
@@ -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;
|
||||
}
|
||||
}
|
||||
42
backend/src/Administration/Domain/Event/ParentLieAEleve.php
Normal file
42
backend/src/Administration/Domain/Event/ParentLieAEleve.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user