feat: Optimiser la pagination avec cache-aside et ports de lecture dédiés
Les listes paginées (utilisateurs, classes, matières, affectations, invitations parents, droits à l'image) effectuaient des requêtes SQL complètes à chaque chargement de page, sans aucun cache. Sur les établissements avec plusieurs centaines d'enregistrements, cela causait des temps de réponse perceptibles et une charge inutile sur PostgreSQL. Cette refactorisation introduit un cache tag-aware (Redis en prod, filesystem en dev) avec invalidation événementielle, et extrait les requêtes de lecture dans des ports Application / implémentations DBAL conformes à l'architecture hexagonale. Un middleware Messenger garantit l'invalidation synchrone du cache même pour les événements routés en asynchrone (envoi d'emails), évitant ainsi toute donnée périmée côté UI.
This commit is contained in:
@@ -39,6 +39,12 @@ framework:
|
|||||||
adapter: cache.adapter.filesystem
|
adapter: cache.adapter.filesystem
|
||||||
default_lifetime: 604800 # 7 jours
|
default_lifetime: 604800 # 7 jours
|
||||||
|
|
||||||
|
# Pool dédié au cache des requêtes paginées (1h TTL, tag-aware)
|
||||||
|
paginated_queries.cache:
|
||||||
|
adapter: cache.adapter.filesystem
|
||||||
|
default_lifetime: 3600 # 1 heure
|
||||||
|
tags: true
|
||||||
|
|
||||||
# Test environment uses Redis to avoid filesystem cache timing issues in E2E tests
|
# Test environment uses Redis to avoid filesystem cache timing issues in E2E tests
|
||||||
# (CLI creates tokens, FrankenPHP must see them immediately)
|
# (CLI creates tokens, FrankenPHP must see them immediately)
|
||||||
when@test:
|
when@test:
|
||||||
@@ -73,6 +79,10 @@ when@test:
|
|||||||
adapter: cache.adapter.redis
|
adapter: cache.adapter.redis
|
||||||
provider: '%env(REDIS_URL)%'
|
provider: '%env(REDIS_URL)%'
|
||||||
default_lifetime: 604800
|
default_lifetime: 604800
|
||||||
|
paginated_queries.cache:
|
||||||
|
adapter: cache.adapter.redis_tag_aware
|
||||||
|
provider: '%env(REDIS_URL)%'
|
||||||
|
default_lifetime: 3600
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
framework:
|
framework:
|
||||||
@@ -110,3 +120,7 @@ when@prod:
|
|||||||
adapter: cache.adapter.redis
|
adapter: cache.adapter.redis
|
||||||
provider: '%env(REDIS_URL)%'
|
provider: '%env(REDIS_URL)%'
|
||||||
default_lifetime: 604800 # 7 jours
|
default_lifetime: 604800 # 7 jours
|
||||||
|
paginated_queries.cache:
|
||||||
|
adapter: cache.adapter.redis_tag_aware
|
||||||
|
provider: '%env(REDIS_URL)%'
|
||||||
|
default_lifetime: 3600 # 1 heure
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ framework:
|
|||||||
middleware:
|
middleware:
|
||||||
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
|
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
|
||||||
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
|
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
|
||||||
|
- App\Administration\Infrastructure\Middleware\PaginatedCacheInvalidationMiddleware
|
||||||
- App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware
|
- App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware
|
||||||
|
|
||||||
transports:
|
transports:
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ services:
|
|||||||
Psr\Cache\CacheItemPoolInterface $sessionsCache: '@sessions.cache'
|
Psr\Cache\CacheItemPoolInterface $sessionsCache: '@sessions.cache'
|
||||||
# Bind student guardians cache pool (no TTL - persistent data)
|
# Bind student guardians cache pool (no TTL - persistent data)
|
||||||
Psr\Cache\CacheItemPoolInterface $studentGuardiansCache: '@student_guardians.cache'
|
Psr\Cache\CacheItemPoolInterface $studentGuardiansCache: '@student_guardians.cache'
|
||||||
|
# Bind paginated queries cache pool (1h TTL, tag-aware)
|
||||||
|
Symfony\Contracts\Cache\TagAwareCacheInterface $paginatedQueriesCache: '@paginated_queries.cache'
|
||||||
# Bind named message buses
|
# Bind named message buses
|
||||||
Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus'
|
Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus'
|
||||||
Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus'
|
Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus'
|
||||||
@@ -237,6 +239,25 @@ services:
|
|||||||
App\Administration\Domain\Repository\StudentGuardianRepository:
|
App\Administration\Domain\Repository\StudentGuardianRepository:
|
||||||
alias: App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository
|
alias: App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository
|
||||||
|
|
||||||
|
# Paginated Read Model Ports
|
||||||
|
App\Administration\Application\Port\PaginatedUsersReader:
|
||||||
|
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedUsersReader
|
||||||
|
|
||||||
|
App\Administration\Application\Port\PaginatedClassesReader:
|
||||||
|
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedClassesReader
|
||||||
|
|
||||||
|
App\Administration\Application\Port\PaginatedSubjectsReader:
|
||||||
|
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedSubjectsReader
|
||||||
|
|
||||||
|
App\Administration\Application\Port\PaginatedAssignmentsReader:
|
||||||
|
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedAssignmentsReader
|
||||||
|
|
||||||
|
App\Administration\Application\Port\PaginatedParentInvitationsReader:
|
||||||
|
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedParentInvitationsReader
|
||||||
|
|
||||||
|
App\Administration\Application\Port\PaginatedStudentImageRightsReader:
|
||||||
|
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedStudentImageRightsReader
|
||||||
|
|
||||||
# GradeExistenceChecker (stub until Notes module exists)
|
# GradeExistenceChecker (stub until Notes module exists)
|
||||||
App\Administration\Application\Port\GradeExistenceChecker:
|
App\Administration\Application\Port\GradeExistenceChecker:
|
||||||
alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker
|
alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Port;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-model port for paginated assignment queries (CQRS read side).
|
||||||
|
*
|
||||||
|
* @phpstan-type Result = PaginatedResult<AssignmentWithNamesDto>
|
||||||
|
*/
|
||||||
|
interface PaginatedAssignmentsReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<AssignmentWithNamesDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Port;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Query\GetClasses\ClassDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-model port for paginated class queries (CQRS read side).
|
||||||
|
*
|
||||||
|
* @phpstan-type Result = PaginatedResult<ClassDto>
|
||||||
|
*/
|
||||||
|
interface PaginatedClassesReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<ClassDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
string $academicYearId,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Port;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-model port for paginated parent invitation queries (CQRS read side).
|
||||||
|
*
|
||||||
|
* @phpstan-type Result = PaginatedResult<ParentInvitationDto>
|
||||||
|
*/
|
||||||
|
interface PaginatedParentInvitationsReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<ParentInvitationDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
?string $status,
|
||||||
|
?string $studentId,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Port;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-model port for paginated student image rights queries (CQRS read side).
|
||||||
|
*
|
||||||
|
* @phpstan-type Result = PaginatedResult<StudentImageRightsDto>
|
||||||
|
*/
|
||||||
|
interface PaginatedStudentImageRightsReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<StudentImageRightsDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
?string $status,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all students (no pagination) for export purposes.
|
||||||
|
*
|
||||||
|
* @return StudentImageRightsDto[]
|
||||||
|
*/
|
||||||
|
public function findAll(
|
||||||
|
string $tenantId,
|
||||||
|
?string $status,
|
||||||
|
): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Port;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Query\GetSubjects\SubjectDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-model port for paginated subject queries (CQRS read side).
|
||||||
|
*
|
||||||
|
* @phpstan-type Result = PaginatedResult<SubjectDto>
|
||||||
|
*/
|
||||||
|
interface PaginatedSubjectsReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<SubjectDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
string $schoolId,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Port;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Query\GetUsers\UserDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-model port for paginated user queries (CQRS read side).
|
||||||
|
*
|
||||||
|
* @phpstan-type Result = PaginatedResult<UserDto>
|
||||||
|
*/
|
||||||
|
interface PaginatedUsersReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<UserDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
?string $role,
|
||||||
|
?string $statut,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult;
|
||||||
|
}
|
||||||
@@ -5,27 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Application\Query\GetAllAssignments;
|
namespace App\Administration\Application\Query\GetAllAssignments;
|
||||||
|
|
||||||
use App\Administration\Application\Dto\PaginatedResult;
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
use App\Administration\Domain\Repository\ClassRepository;
|
use App\Administration\Application\Port\PaginatedAssignmentsReader;
|
||||||
use App\Administration\Domain\Repository\SubjectRepository;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
|
|
||||||
use App\Administration\Domain\Repository\UserRepository;
|
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
|
|
||||||
use function array_slice;
|
|
||||||
use function count;
|
|
||||||
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
#[AsMessageHandler(bus: 'query.bus')]
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
final readonly class GetAllAssignmentsHandler
|
final readonly class GetAllAssignmentsHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private TeacherAssignmentRepository $assignmentRepository,
|
private PaginatedAssignmentsReader $reader,
|
||||||
private UserRepository $userRepository,
|
private PaginatedQueryCache $cache,
|
||||||
private ClassRepository $classRepository,
|
|
||||||
private SubjectRepository $subjectRepository,
|
|
||||||
private LoggerInterface $logger,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,101 +23,29 @@ final readonly class GetAllAssignmentsHandler
|
|||||||
*/
|
*/
|
||||||
public function __invoke(GetAllAssignmentsQuery $query): PaginatedResult
|
public function __invoke(GetAllAssignmentsQuery $query): PaginatedResult
|
||||||
{
|
{
|
||||||
$tenantId = TenantId::fromString($query->tenantId);
|
/* @var PaginatedResult<AssignmentWithNamesDto> */
|
||||||
|
return $this->cache->getOrLoad(
|
||||||
$assignments = $this->assignmentRepository->findAllActiveByTenant($tenantId);
|
'assignments',
|
||||||
|
$query->tenantId,
|
||||||
// Build lookup maps for users, classes, and subjects
|
$this->cacheParams($query),
|
||||||
$users = $this->userRepository->findAllByTenant($tenantId);
|
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||||
/** @var array<string, array{firstName: string, lastName: string}> $userNames */
|
tenantId: $query->tenantId,
|
||||||
$userNames = [];
|
search: $query->search,
|
||||||
foreach ($users as $user) {
|
|
||||||
$userNames[(string) $user->id] = [
|
|
||||||
'firstName' => $user->firstName,
|
|
||||||
'lastName' => $user->lastName,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
|
|
||||||
/** @var array<string, string> $classNames */
|
|
||||||
$classNames = [];
|
|
||||||
foreach ($classes as $class) {
|
|
||||||
$classNames[(string) $class->id] = (string) $class->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
|
|
||||||
/** @var array<string, string> $subjectNames */
|
|
||||||
$subjectNames = [];
|
|
||||||
foreach ($subjects as $subject) {
|
|
||||||
$subjectNames[(string) $subject->id] = (string) $subject->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
$dtos = [];
|
|
||||||
foreach ($assignments as $assignment) {
|
|
||||||
$teacherId = (string) $assignment->teacherId;
|
|
||||||
$classId = (string) $assignment->classId;
|
|
||||||
$subjectId = (string) $assignment->subjectId;
|
|
||||||
|
|
||||||
if (!isset($userNames[$teacherId])) {
|
|
||||||
$this->logger->warning('Assignment {assignmentId} references unknown teacher {teacherId}', [
|
|
||||||
'assignmentId' => (string) $assignment->id,
|
|
||||||
'teacherId' => $teacherId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($classNames[$classId])) {
|
|
||||||
$this->logger->warning('Assignment {assignmentId} references unknown class {classId}', [
|
|
||||||
'assignmentId' => (string) $assignment->id,
|
|
||||||
'classId' => $classId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($subjectNames[$subjectId])) {
|
|
||||||
$this->logger->warning('Assignment {assignmentId} references unknown subject {subjectId}', [
|
|
||||||
'assignmentId' => (string) $assignment->id,
|
|
||||||
'subjectId' => $subjectId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$teacher = $userNames[$teacherId] ?? ['firstName' => '', 'lastName' => ''];
|
|
||||||
|
|
||||||
$dtos[] = new AssignmentWithNamesDto(
|
|
||||||
id: (string) $assignment->id,
|
|
||||||
teacherId: $teacherId,
|
|
||||||
teacherFirstName: $teacher['firstName'],
|
|
||||||
teacherLastName: $teacher['lastName'],
|
|
||||||
classId: $classId,
|
|
||||||
className: $classNames[$classId] ?? '',
|
|
||||||
subjectId: $subjectId,
|
|
||||||
subjectName: $subjectNames[$subjectId] ?? '',
|
|
||||||
academicYearId: (string) $assignment->academicYearId,
|
|
||||||
status: $assignment->status->value,
|
|
||||||
startDate: $assignment->startDate,
|
|
||||||
endDate: $assignment->endDate,
|
|
||||||
createdAt: $assignment->createdAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($query->search !== null && $query->search !== '') {
|
|
||||||
$searchLower = mb_strtolower($query->search);
|
|
||||||
$dtos = array_values(array_filter(
|
|
||||||
$dtos,
|
|
||||||
static fn (AssignmentWithNamesDto $dto) => str_contains(mb_strtolower($dto->teacherFirstName), $searchLower)
|
|
||||||
|| str_contains(mb_strtolower($dto->teacherLastName), $searchLower)
|
|
||||||
|| str_contains(mb_strtolower($dto->className), $searchLower)
|
|
||||||
|| str_contains(mb_strtolower($dto->subjectName), $searchLower),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$total = count($dtos);
|
|
||||||
$offset = ($query->page - 1) * $query->limit;
|
|
||||||
$items = array_slice($dtos, $offset, $query->limit);
|
|
||||||
|
|
||||||
return new PaginatedResult(
|
|
||||||
items: $items,
|
|
||||||
total: $total,
|
|
||||||
page: $query->page,
|
page: $query->page,
|
||||||
limit: $query->limit,
|
limit: $query->limit,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function cacheParams(GetAllAssignmentsQuery $query): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => $query->page,
|
||||||
|
'limit' => $query->limit,
|
||||||
|
'search' => $query->search,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Application\Query\GetClasses;
|
namespace App\Administration\Application\Query\GetClasses;
|
||||||
|
|
||||||
use App\Administration\Application\Dto\PaginatedResult;
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
use App\Administration\Application\Port\PaginatedClassesReader;
|
||||||
use App\Administration\Domain\Repository\ClassRepository;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
|
|
||||||
use function array_slice;
|
|
||||||
use function count;
|
|
||||||
|
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
#[AsMessageHandler(bus: 'query.bus')]
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
final readonly class GetClassesHandler
|
final readonly class GetClassesHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ClassRepository $classRepository,
|
private PaginatedClassesReader $reader,
|
||||||
|
private PaginatedQueryCache $cache,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,33 +23,31 @@ final readonly class GetClassesHandler
|
|||||||
*/
|
*/
|
||||||
public function __invoke(GetClassesQuery $query): PaginatedResult
|
public function __invoke(GetClassesQuery $query): PaginatedResult
|
||||||
{
|
{
|
||||||
$classes = $this->classRepository->findActiveByTenantAndYear(
|
/* @var PaginatedResult<ClassDto> */
|
||||||
TenantId::fromString($query->tenantId),
|
return $this->cache->getOrLoad(
|
||||||
AcademicYearId::fromString($query->academicYearId),
|
'classes',
|
||||||
);
|
$query->tenantId,
|
||||||
|
$this->cacheParams($query),
|
||||||
if ($query->search !== null && $query->search !== '') {
|
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||||
$searchLower = mb_strtolower($query->search);
|
tenantId: $query->tenantId,
|
||||||
$classes = array_filter(
|
academicYearId: $query->academicYearId,
|
||||||
$classes,
|
search: $query->search,
|
||||||
static fn ($class) => str_contains(mb_strtolower((string) $class->name), $searchLower)
|
|
||||||
|| ($class->level !== null && str_contains(mb_strtolower($class->level->value), $searchLower)),
|
|
||||||
);
|
|
||||||
$classes = array_values($classes);
|
|
||||||
}
|
|
||||||
|
|
||||||
$total = count($classes);
|
|
||||||
$offset = ($query->page - 1) * $query->limit;
|
|
||||||
$items = array_slice($classes, $offset, $query->limit);
|
|
||||||
|
|
||||||
return new PaginatedResult(
|
|
||||||
items: array_map(
|
|
||||||
static fn ($class) => ClassDto::fromDomain($class),
|
|
||||||
$items,
|
|
||||||
),
|
|
||||||
total: $total,
|
|
||||||
page: $query->page,
|
page: $query->page,
|
||||||
limit: $query->limit,
|
limit: $query->limit,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function cacheParams(GetClassesQuery $query): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => $query->page,
|
||||||
|
'limit' => $query->limit,
|
||||||
|
'academic_year_id' => $query->academicYearId,
|
||||||
|
'search' => $query->search,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,29 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Application\Query\GetParentInvitations;
|
namespace App\Administration\Application\Query\GetParentInvitations;
|
||||||
|
|
||||||
use App\Administration\Application\Dto\PaginatedResult;
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
use App\Administration\Domain\Model\Invitation\InvitationStatus;
|
use App\Administration\Application\Port\PaginatedParentInvitationsReader;
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Administration\Domain\Repository\ParentInvitationRepository;
|
|
||||||
use App\Administration\Domain\Repository\UserRepository;
|
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
|
|
||||||
use function array_filter;
|
|
||||||
use function array_map;
|
|
||||||
use function array_slice;
|
|
||||||
use function array_values;
|
|
||||||
use function count;
|
|
||||||
use function mb_strtolower;
|
|
||||||
use function str_contains;
|
|
||||||
|
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
#[AsMessageHandler(bus: 'query.bus')]
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
final readonly class GetParentInvitationsHandler
|
final readonly class GetParentInvitationsHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ParentInvitationRepository $invitationRepository,
|
private PaginatedParentInvitationsReader $reader,
|
||||||
private UserRepository $userRepository,
|
private PaginatedQueryCache $cache,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,97 +23,33 @@ final readonly class GetParentInvitationsHandler
|
|||||||
*/
|
*/
|
||||||
public function __invoke(GetParentInvitationsQuery $query): PaginatedResult
|
public function __invoke(GetParentInvitationsQuery $query): PaginatedResult
|
||||||
{
|
{
|
||||||
$tenantId = TenantId::fromString($query->tenantId);
|
/* @var PaginatedResult<ParentInvitationDto> */
|
||||||
|
return $this->cache->getOrLoad(
|
||||||
$invitations = $this->invitationRepository->findAllByTenant($tenantId);
|
'parent_invitations',
|
||||||
|
$query->tenantId,
|
||||||
if ($query->status !== null) {
|
$this->cacheParams($query),
|
||||||
$filterStatus = InvitationStatus::tryFrom($query->status);
|
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||||
if ($filterStatus !== null) {
|
tenantId: $query->tenantId,
|
||||||
$invitations = array_filter(
|
status: $query->status,
|
||||||
$invitations,
|
studentId: $query->studentId,
|
||||||
static fn ($inv) => $inv->status === $filterStatus,
|
search: $query->search,
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($query->studentId !== null) {
|
|
||||||
$filterStudentId = UserId::fromString($query->studentId);
|
|
||||||
$invitations = array_filter(
|
|
||||||
$invitations,
|
|
||||||
static fn ($inv) => $inv->studentId->equals($filterStudentId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a student name cache for search and DTO enrichment
|
|
||||||
$studentNames = $this->loadStudentNames($invitations);
|
|
||||||
|
|
||||||
if ($query->search !== null && $query->search !== '') {
|
|
||||||
$searchLower = mb_strtolower($query->search);
|
|
||||||
$invitations = array_filter(
|
|
||||||
$invitations,
|
|
||||||
static function ($inv) use ($searchLower, $studentNames) {
|
|
||||||
$studentId = (string) $inv->studentId;
|
|
||||||
$firstName = $studentNames[$studentId]['firstName'] ?? '';
|
|
||||||
$lastName = $studentNames[$studentId]['lastName'] ?? '';
|
|
||||||
|
|
||||||
return str_contains(mb_strtolower((string) $inv->parentEmail), $searchLower)
|
|
||||||
|| str_contains(mb_strtolower($firstName), $searchLower)
|
|
||||||
|| str_contains(mb_strtolower($lastName), $searchLower);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$invitations = array_values($invitations);
|
|
||||||
$total = count($invitations);
|
|
||||||
|
|
||||||
$offset = ($query->page - 1) * $query->limit;
|
|
||||||
$items = array_slice($invitations, $offset, $query->limit);
|
|
||||||
|
|
||||||
return new PaginatedResult(
|
|
||||||
items: array_map(
|
|
||||||
static function ($inv) use ($studentNames) {
|
|
||||||
$studentId = (string) $inv->studentId;
|
|
||||||
|
|
||||||
return ParentInvitationDto::fromDomain(
|
|
||||||
$inv,
|
|
||||||
$studentNames[$studentId]['firstName'] ?? null,
|
|
||||||
$studentNames[$studentId]['lastName'] ?? null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
$items,
|
|
||||||
),
|
|
||||||
total: $total,
|
|
||||||
page: $query->page,
|
page: $query->page,
|
||||||
limit: $query->limit,
|
limit: $query->limit,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param iterable<\App\Administration\Domain\Model\Invitation\ParentInvitation> $invitations
|
* @return array<string, mixed>
|
||||||
*
|
|
||||||
* @return array<string, array{firstName: string, lastName: string}>
|
|
||||||
*/
|
*/
|
||||||
private function loadStudentNames(iterable $invitations): array
|
private function cacheParams(GetParentInvitationsQuery $query): array
|
||||||
{
|
{
|
||||||
$studentIds = [];
|
return [
|
||||||
foreach ($invitations as $inv) {
|
'page' => $query->page,
|
||||||
$studentIds[(string) $inv->studentId] = true;
|
'limit' => $query->limit,
|
||||||
}
|
'status' => $query->status,
|
||||||
|
'student_id' => $query->studentId,
|
||||||
$names = [];
|
'search' => $query->search,
|
||||||
foreach ($studentIds as $id => $_) {
|
|
||||||
try {
|
|
||||||
$student = $this->userRepository->get(UserId::fromString($id));
|
|
||||||
$names[$id] = [
|
|
||||||
'firstName' => $student->firstName,
|
|
||||||
'lastName' => $student->lastName,
|
|
||||||
];
|
];
|
||||||
} catch (Throwable) {
|
|
||||||
$names[$id] = ['firstName' => '', 'lastName' => ''];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $names;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,41 +4,50 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Application\Query\GetStudentsImageRights;
|
namespace App\Administration\Application\Query\GetStudentsImageRights;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\User\ImageRightsStatus;
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
use App\Administration\Domain\Repository\UserRepository;
|
use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
#[AsMessageHandler(bus: 'query.bus')]
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
final readonly class GetStudentsImageRightsHandler
|
final readonly class GetStudentsImageRightsHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private UserRepository $userRepository,
|
private PaginatedStudentImageRightsReader $reader,
|
||||||
|
private PaginatedQueryCache $cache,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return StudentImageRightsDto[]
|
* @return PaginatedResult<StudentImageRightsDto>
|
||||||
*/
|
*/
|
||||||
public function __invoke(GetStudentsImageRightsQuery $query): array
|
public function __invoke(GetStudentsImageRightsQuery $query): PaginatedResult
|
||||||
{
|
{
|
||||||
$students = $this->userRepository->findStudentsByTenant(
|
/* @var PaginatedResult<StudentImageRightsDto> */
|
||||||
TenantId::fromString($query->tenantId),
|
return $this->cache->getOrLoad(
|
||||||
);
|
'students_image_rights',
|
||||||
|
$query->tenantId,
|
||||||
if ($query->status !== null) {
|
$this->cacheParams($query),
|
||||||
$filterStatus = ImageRightsStatus::tryFrom($query->status);
|
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||||
if ($filterStatus !== null) {
|
tenantId: $query->tenantId,
|
||||||
$students = array_filter(
|
status: $query->status,
|
||||||
$students,
|
search: $query->search,
|
||||||
static fn ($user) => $user->imageRightsStatus === $filterStatus,
|
page: $query->page,
|
||||||
|
limit: $query->limit,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_map(
|
/**
|
||||||
static fn ($user) => StudentImageRightsDto::fromDomain($user),
|
* @return array<string, mixed>
|
||||||
$students,
|
*/
|
||||||
));
|
private function cacheParams(GetStudentsImageRightsQuery $query): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => $query->page,
|
||||||
|
'limit' => $query->limit,
|
||||||
|
'status' => $query->status,
|
||||||
|
'search' => $query->search,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,23 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Application\Query\GetStudentsImageRights;
|
namespace App\Administration\Application\Query\GetStudentsImageRights;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
|
||||||
final readonly class GetStudentsImageRightsQuery
|
final readonly class GetStudentsImageRightsQuery
|
||||||
{
|
{
|
||||||
|
public int $page;
|
||||||
|
public int $limit;
|
||||||
|
public ?string $search;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $tenantId,
|
public string $tenantId,
|
||||||
public ?string $status = null,
|
public ?string $status = null,
|
||||||
|
int $page = PaginatedResult::DEFAULT_PAGE,
|
||||||
|
int $limit = PaginatedResult::DEFAULT_LIMIT,
|
||||||
|
?string $search = null,
|
||||||
) {
|
) {
|
||||||
|
$this->page = max(1, $page);
|
||||||
|
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
|
||||||
|
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Application\Query\GetSubjects;
|
namespace App\Administration\Application\Query\GetSubjects;
|
||||||
|
|
||||||
use App\Administration\Application\Dto\PaginatedResult;
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
use App\Administration\Application\Port\PaginatedSubjectsReader;
|
||||||
use App\Administration\Domain\Repository\SubjectRepository;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
|
|
||||||
use function array_slice;
|
|
||||||
use function count;
|
|
||||||
|
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
#[AsMessageHandler(bus: 'query.bus')]
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
final readonly class GetSubjectsHandler
|
final readonly class GetSubjectsHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private SubjectRepository $subjectRepository,
|
private PaginatedSubjectsReader $reader,
|
||||||
|
private PaginatedQueryCache $cache,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,37 +23,31 @@ final readonly class GetSubjectsHandler
|
|||||||
*/
|
*/
|
||||||
public function __invoke(GetSubjectsQuery $query): PaginatedResult
|
public function __invoke(GetSubjectsQuery $query): PaginatedResult
|
||||||
{
|
{
|
||||||
$subjects = $this->subjectRepository->findActiveByTenantAndSchool(
|
/* @var PaginatedResult<SubjectDto> */
|
||||||
TenantId::fromString($query->tenantId),
|
return $this->cache->getOrLoad(
|
||||||
SchoolId::fromString($query->schoolId),
|
'subjects',
|
||||||
);
|
$query->tenantId,
|
||||||
|
$this->cacheParams($query),
|
||||||
if ($query->search !== null && $query->search !== '') {
|
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||||
$searchLower = mb_strtolower($query->search);
|
tenantId: $query->tenantId,
|
||||||
$subjects = array_filter(
|
schoolId: $query->schoolId,
|
||||||
$subjects,
|
search: $query->search,
|
||||||
static fn ($subject) => str_contains(mb_strtolower((string) $subject->name), $searchLower)
|
|
||||||
|| str_contains(mb_strtolower((string) $subject->code), $searchLower),
|
|
||||||
);
|
|
||||||
$subjects = array_values($subjects);
|
|
||||||
}
|
|
||||||
|
|
||||||
$total = count($subjects);
|
|
||||||
$offset = ($query->page - 1) * $query->limit;
|
|
||||||
$items = array_slice($subjects, $offset, $query->limit);
|
|
||||||
|
|
||||||
return new PaginatedResult(
|
|
||||||
items: array_map(
|
|
||||||
static fn ($subject) => SubjectDto::fromDomain(
|
|
||||||
$subject,
|
|
||||||
teacherCount: 0,
|
|
||||||
classCount: 0,
|
|
||||||
),
|
|
||||||
$items,
|
|
||||||
),
|
|
||||||
total: $total,
|
|
||||||
page: $query->page,
|
page: $query->page,
|
||||||
limit: $query->limit,
|
limit: $query->limit,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function cacheParams(GetSubjectsQuery $query): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => $query->page,
|
||||||
|
'limit' => $query->limit,
|
||||||
|
'school_id' => $query->schoolId,
|
||||||
|
'search' => $query->search,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Application\Query\GetUsers;
|
namespace App\Administration\Application\Query\GetUsers;
|
||||||
|
|
||||||
use App\Administration\Application\Dto\PaginatedResult;
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
use App\Administration\Domain\Model\User\Role;
|
use App\Administration\Application\Port\PaginatedUsersReader;
|
||||||
use App\Administration\Domain\Model\User\StatutCompte;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Administration\Domain\Repository\UserRepository;
|
|
||||||
use App\Shared\Domain\Clock;
|
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
|
|
||||||
use function array_slice;
|
|
||||||
use function count;
|
|
||||||
|
|
||||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
#[AsMessageHandler(bus: 'query.bus')]
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
final readonly class GetUsersHandler
|
final readonly class GetUsersHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private UserRepository $userRepository,
|
private PaginatedUsersReader $reader,
|
||||||
private Clock $clock,
|
private PaginatedQueryCache $cache,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,54 +23,33 @@ final readonly class GetUsersHandler
|
|||||||
*/
|
*/
|
||||||
public function __invoke(GetUsersQuery $query): PaginatedResult
|
public function __invoke(GetUsersQuery $query): PaginatedResult
|
||||||
{
|
{
|
||||||
$users = $this->userRepository->findAllByTenant(
|
/* @var PaginatedResult<UserDto> */
|
||||||
TenantId::fromString($query->tenantId),
|
return $this->cache->getOrLoad(
|
||||||
);
|
'users',
|
||||||
|
$query->tenantId,
|
||||||
if ($query->role !== null) {
|
$this->cacheParams($query),
|
||||||
$filterRole = Role::tryFrom($query->role);
|
fn (): PaginatedResult => $this->reader->findPaginated(
|
||||||
if ($filterRole !== null) {
|
tenantId: $query->tenantId,
|
||||||
$users = array_filter(
|
role: $query->role,
|
||||||
$users,
|
statut: $query->statut,
|
||||||
static fn ($user) => $user->aLeRole($filterRole),
|
search: $query->search,
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($query->statut !== null) {
|
|
||||||
$filterStatut = StatutCompte::tryFrom($query->statut);
|
|
||||||
if ($filterStatut !== null) {
|
|
||||||
$users = array_filter(
|
|
||||||
$users,
|
|
||||||
static fn ($user) => $user->statut === $filterStatut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($query->search !== null && $query->search !== '') {
|
|
||||||
$searchLower = mb_strtolower($query->search);
|
|
||||||
$users = array_filter(
|
|
||||||
$users,
|
|
||||||
static fn ($user) => str_contains(mb_strtolower($user->firstName), $searchLower)
|
|
||||||
|| str_contains(mb_strtolower($user->lastName), $searchLower)
|
|
||||||
|| str_contains(mb_strtolower((string) $user->email), $searchLower),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$users = array_values($users);
|
|
||||||
$total = count($users);
|
|
||||||
|
|
||||||
$offset = ($query->page - 1) * $query->limit;
|
|
||||||
$items = array_slice($users, $offset, $query->limit);
|
|
||||||
|
|
||||||
return new PaginatedResult(
|
|
||||||
items: array_map(
|
|
||||||
fn ($user) => UserDto::fromDomain($user, $this->clock),
|
|
||||||
$items,
|
|
||||||
),
|
|
||||||
total: $total,
|
|
||||||
page: $query->page,
|
page: $query->page,
|
||||||
limit: $query->limit,
|
limit: $query->limit,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function cacheParams(GetUsersQuery $query): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => $query->page,
|
||||||
|
'limit' => $query->limit,
|
||||||
|
'role' => $query->role,
|
||||||
|
'statut' => $query->statut,
|
||||||
|
'search' => $query->search,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Service\Cache;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
|
||||||
|
use function json_encode;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
use function ksort;
|
||||||
|
use function md5;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
use Symfony\Contracts\Cache\TagAwareCacheInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service cache-aside pour les requêtes paginées.
|
||||||
|
*
|
||||||
|
* Chaque entrée est taguée par type d'entité + tenant,
|
||||||
|
* permettant une invalidation ciblée lors de mutations.
|
||||||
|
*/
|
||||||
|
final readonly class PaginatedQueryCache
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TagAwareCacheInterface $paginatedQueriesCache,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params Filtres + page + limit
|
||||||
|
* @param callable(): PaginatedResult<T> $loader Fonction qui exécute la requête SQL
|
||||||
|
*
|
||||||
|
* @return PaginatedResult<T>
|
||||||
|
*/
|
||||||
|
public function getOrLoad(
|
||||||
|
string $entityType,
|
||||||
|
string $tenantId,
|
||||||
|
array $params,
|
||||||
|
callable $loader,
|
||||||
|
): PaginatedResult {
|
||||||
|
$key = $this->buildKey($entityType, $tenantId, $params);
|
||||||
|
$tag = sprintf('query_%s_%s', $entityType, $tenantId);
|
||||||
|
|
||||||
|
/* @var PaginatedResult<T> */
|
||||||
|
return $this->paginatedQueriesCache->get(
|
||||||
|
$key,
|
||||||
|
static function (ItemInterface $item) use ($tag, $loader): PaginatedResult {
|
||||||
|
$item->tag([$tag]);
|
||||||
|
|
||||||
|
return $loader();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invalidate(string $entityType, string $tenantId): void
|
||||||
|
{
|
||||||
|
$this->paginatedQueriesCache->invalidateTags(
|
||||||
|
[sprintf('query_%s_%s', $entityType, $tenantId)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
*/
|
||||||
|
private function buildKey(string $entityType, string $tenantId, array $params): string
|
||||||
|
{
|
||||||
|
ksort($params);
|
||||||
|
|
||||||
|
return sprintf('query_%s_%s_%s', $entityType, $tenantId, md5(json_encode($params, JSON_THROW_ON_ERROR)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use App\Administration\Domain\Model\Subject\SubjectId;
|
|||||||
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
|
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
use App\Shared\Domain\DomainEvent;
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Override;
|
use Override;
|
||||||
use Ramsey\Uuid\UuidInterface;
|
use Ramsey\Uuid\UuidInterface;
|
||||||
@@ -20,6 +21,7 @@ final readonly class AffectationRetiree implements DomainEvent
|
|||||||
public UserId $teacherId,
|
public UserId $teacherId,
|
||||||
public ClassId $classId,
|
public ClassId $classId,
|
||||||
public SubjectId $subjectId,
|
public SubjectId $subjectId,
|
||||||
|
public TenantId $tenantId,
|
||||||
private DateTimeImmutable $occurredOn,
|
private DateTimeImmutable $occurredOn,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Administration\Domain\Model\Subject\SubjectId;
|
|||||||
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
|
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
use App\Shared\Domain\DomainEvent;
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Override;
|
use Override;
|
||||||
use Ramsey\Uuid\UuidInterface;
|
use Ramsey\Uuid\UuidInterface;
|
||||||
@@ -20,6 +21,7 @@ final readonly class EnseignantAffecte implements DomainEvent
|
|||||||
public UserId $teacherId,
|
public UserId $teacherId,
|
||||||
public ClassId $classId,
|
public ClassId $classId,
|
||||||
public SubjectId $subjectId,
|
public SubjectId $subjectId,
|
||||||
|
public TenantId $tenantId,
|
||||||
private DateTimeImmutable $occurredOn,
|
private DateTimeImmutable $occurredOn,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ final class TeacherAssignment extends AggregateRoot
|
|||||||
teacherId: $assignment->teacherId,
|
teacherId: $assignment->teacherId,
|
||||||
classId: $assignment->classId,
|
classId: $assignment->classId,
|
||||||
subjectId: $assignment->subjectId,
|
subjectId: $assignment->subjectId,
|
||||||
|
tenantId: $assignment->tenantId,
|
||||||
occurredOn: $createdAt,
|
occurredOn: $createdAt,
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -93,6 +94,7 @@ final class TeacherAssignment extends AggregateRoot
|
|||||||
teacherId: $this->teacherId,
|
teacherId: $this->teacherId,
|
||||||
classId: $this->classId,
|
classId: $this->classId,
|
||||||
subjectId: $this->subjectId,
|
subjectId: $this->subjectId,
|
||||||
|
tenantId: $this->tenantId,
|
||||||
occurredOn: $at,
|
occurredOn: $at,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -119,6 +121,7 @@ final class TeacherAssignment extends AggregateRoot
|
|||||||
teacherId: $this->teacherId,
|
teacherId: $this->teacherId,
|
||||||
classId: $this->classId,
|
classId: $this->classId,
|
||||||
subjectId: $this->subjectId,
|
subjectId: $this->subjectId,
|
||||||
|
tenantId: $this->tenantId,
|
||||||
occurredOn: $at,
|
occurredOn: $at,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace App\Administration\Infrastructure\Api\Provider;
|
namespace App\Administration\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\Pagination\TraversablePaginator;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsHandler;
|
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsHandler;
|
||||||
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery;
|
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery;
|
||||||
use App\Administration\Infrastructure\Api\Resource\ImageRightsResource;
|
use App\Administration\Infrastructure\Api\Resource\ImageRightsResource;
|
||||||
use App\Administration\Infrastructure\Security\ImageRightsVoter;
|
use App\Administration\Infrastructure\Security\ImageRightsVoter;
|
||||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
|
||||||
|
use ArrayIterator;
|
||||||
use Override;
|
use Override;
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
@@ -28,11 +33,8 @@ final readonly class ImageRightsCollectionProvider implements ProviderInterface
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return ImageRightsResource[]
|
|
||||||
*/
|
|
||||||
#[Override]
|
#[Override]
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
|
||||||
{
|
{
|
||||||
if (!$this->authorizationChecker->isGranted(ImageRightsVoter::VIEW)) {
|
if (!$this->authorizationChecker->isGranted(ImageRightsVoter::VIEW)) {
|
||||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter les droits à l\'image.');
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter les droits à l\'image.');
|
||||||
@@ -42,19 +44,30 @@ final readonly class ImageRightsCollectionProvider implements ProviderInterface
|
|||||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||||
/** @var array<string, string> $filters */
|
/** @var array<string, string> $filters */
|
||||||
$filters = $context['filters'] ?? [];
|
$filters = $context['filters'] ?? [];
|
||||||
|
|
||||||
|
$page = (int) ($filters['page'] ?? 1);
|
||||||
|
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
|
||||||
|
|
||||||
$query = new GetStudentsImageRightsQuery(
|
$query = new GetStudentsImageRightsQuery(
|
||||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
tenantId: $tenantId,
|
||||||
status: isset($filters['status']) ? (string) $filters['status'] : null,
|
status: isset($filters['status']) ? (string) $filters['status'] : null,
|
||||||
|
page: $page,
|
||||||
|
limit: $itemsPerPage,
|
||||||
|
search: isset($filters['search']) ? (string) $filters['search'] : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
$dtos = ($this->handler)($query);
|
$result = ($this->handler)($query);
|
||||||
|
|
||||||
return array_map(
|
$resources = array_map(ImageRightsResource::fromDto(...), $result->items);
|
||||||
static fn ($dto) => ImageRightsResource::fromDto($dto),
|
|
||||||
$dtos,
|
return new TraversablePaginator(
|
||||||
|
new ArrayIterator($resources),
|
||||||
|
$result->page,
|
||||||
|
$result->limit,
|
||||||
|
$result->total,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ namespace App\Administration\Infrastructure\Api\Provider;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsHandler;
|
use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
|
||||||
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery;
|
|
||||||
use App\Administration\Application\Service\ImageRightsExporter;
|
use App\Administration\Application\Service\ImageRightsExporter;
|
||||||
use App\Administration\Infrastructure\Security\ImageRightsVoter;
|
use App\Administration\Infrastructure\Security\ImageRightsVoter;
|
||||||
use App\Shared\Application\Port\AuditLogger;
|
use App\Shared\Application\Port\AuditLogger;
|
||||||
@@ -27,7 +26,7 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
|||||||
final readonly class ImageRightsExportProvider implements ProviderInterface
|
final readonly class ImageRightsExportProvider implements ProviderInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private GetStudentsImageRightsHandler $handler,
|
private PaginatedStudentImageRightsReader $reader,
|
||||||
private ImageRightsExporter $exporter,
|
private ImageRightsExporter $exporter,
|
||||||
private AuthorizationCheckerInterface $authorizationChecker,
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
private TenantContext $tenantContext,
|
private TenantContext $tenantContext,
|
||||||
@@ -46,15 +45,14 @@ final readonly class ImageRightsExportProvider implements ProviderInterface
|
|||||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||||
/** @var array<string, string> $filters */
|
/** @var array<string, string> $filters */
|
||||||
$filters = $context['filters'] ?? [];
|
$filters = $context['filters'] ?? [];
|
||||||
|
|
||||||
$query = new GetStudentsImageRightsQuery(
|
$dtos = $this->reader->findAll(
|
||||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
$tenantId,
|
||||||
status: isset($filters['status']) ? (string) $filters['status'] : null,
|
isset($filters['status']) ? (string) $filters['status'] : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
$dtos = ($this->handler)($query);
|
|
||||||
$csv = $this->exporter->export($dtos);
|
$csv = $this->exporter->export($dtos);
|
||||||
|
|
||||||
$this->auditLogger->logExport(
|
$this->auditLogger->logExport(
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\EventHandler;
|
||||||
|
|
||||||
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
|
use App\Administration\Domain\Event\AffectationRetiree;
|
||||||
|
use App\Administration\Domain\Event\ClasseArchivee;
|
||||||
|
use App\Administration\Domain\Event\ClasseCreee;
|
||||||
|
use App\Administration\Domain\Event\ClasseModifiee;
|
||||||
|
use App\Administration\Domain\Event\CompteActive;
|
||||||
|
use App\Administration\Domain\Event\CompteCreated;
|
||||||
|
use App\Administration\Domain\Event\DroitImageModifie;
|
||||||
|
use App\Administration\Domain\Event\EleveInscrit;
|
||||||
|
use App\Administration\Domain\Event\EnseignantAffecte;
|
||||||
|
use App\Administration\Domain\Event\ImportElevesTermine;
|
||||||
|
use App\Administration\Domain\Event\ImportEnseignantsTermine;
|
||||||
|
use App\Administration\Domain\Event\InvitationParentActivee;
|
||||||
|
use App\Administration\Domain\Event\InvitationParentEnvoyee;
|
||||||
|
use App\Administration\Domain\Event\InvitationRenvoyee;
|
||||||
|
use App\Administration\Domain\Event\MatiereCreee;
|
||||||
|
use App\Administration\Domain\Event\MatiereModifiee;
|
||||||
|
use App\Administration\Domain\Event\MatiereSupprimee;
|
||||||
|
use App\Administration\Domain\Event\ParentDelieDEleve;
|
||||||
|
use App\Administration\Domain\Event\ParentLieAEleve;
|
||||||
|
use App\Administration\Domain\Event\RoleAttribue;
|
||||||
|
use App\Administration\Domain\Event\RoleRetire;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurBloque;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurDebloque;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
final readonly class PaginatedQueryCacheInvalidator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PaginatedQueryCache $cache,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Users ===
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onUtilisateurInvite(UtilisateurInvite $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('users', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onUtilisateurBloque(UtilisateurBloque $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('users', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onUtilisateurDebloque(UtilisateurDebloque $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('users', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onCompteActive(CompteActive $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('users', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onCompteCreated(CompteCreated $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('users', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onEleveInscrit(EleveInscrit $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('users', $tenantId);
|
||||||
|
$this->cache->invalidate('students_image_rights', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onRoleAttribue(RoleAttribue $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('users', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onRoleRetire(RoleRetire $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('users', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onInvitationRenvoyee(InvitationRenvoyee $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('users', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Classes (also invalidates assignments: class names appear in assignment list) ===
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onClasseCreee(ClasseCreee $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('classes', $tenantId);
|
||||||
|
$this->cache->invalidate('assignments', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onClasseModifiee(ClasseModifiee $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('classes', $tenantId);
|
||||||
|
$this->cache->invalidate('assignments', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onClasseArchivee(ClasseArchivee $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('classes', $tenantId);
|
||||||
|
$this->cache->invalidate('assignments', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Subjects (also invalidates assignments: subject names appear in assignment list) ===
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onMatiereCreee(MatiereCreee $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('subjects', $tenantId);
|
||||||
|
$this->cache->invalidate('assignments', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onMatiereModifiee(MatiereModifiee $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('subjects', $tenantId);
|
||||||
|
$this->cache->invalidate('assignments', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onMatiereSupprimee(MatiereSupprimee $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('subjects', $tenantId);
|
||||||
|
$this->cache->invalidate('assignments', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Assignments ===
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onEnseignantAffecte(EnseignantAffecte $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('assignments', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onAffectationRetiree(AffectationRetiree $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('assignments', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Parent invitations ===
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onInvitationParentEnvoyee(InvitationParentEnvoyee $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('parent_invitations', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onInvitationParentActivee(InvitationParentActivee $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('parent_invitations', $tenantId);
|
||||||
|
$this->cache->invalidate('users', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onParentLieAEleve(ParentLieAEleve $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('parent_invitations', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onParentDelieDEleve(ParentDelieDEleve $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('parent_invitations', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Image rights ===
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onDroitImageModifie(DroitImageModifie $event): void
|
||||||
|
{
|
||||||
|
$this->cache->invalidate('students_image_rights', (string) $event->tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Imports (invalidate multiple caches) ===
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onImportElevesTermine(ImportElevesTermine $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('users', $tenantId);
|
||||||
|
$this->cache->invalidate('students_image_rights', $tenantId);
|
||||||
|
$this->cache->invalidate('classes', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
public function onImportEnseignantsTermine(ImportEnseignantsTermine $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('users', $tenantId);
|
||||||
|
$this->cache->invalidate('assignments', $tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Middleware;
|
||||||
|
|
||||||
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
|
use App\Administration\Domain\Event\CompteActive;
|
||||||
|
use App\Administration\Domain\Event\InvitationParentActivee;
|
||||||
|
use App\Administration\Domain\Event\InvitationParentEnvoyee;
|
||||||
|
use App\Administration\Domain\Event\InvitationRenvoyee;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||||
|
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||||
|
use Symfony\Component\Messenger\Stamp\ReceivedStamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates paginated query caches synchronously for events routed to async transport.
|
||||||
|
*
|
||||||
|
* Without this middleware, cache invalidation for async-routed events (UtilisateurInvite,
|
||||||
|
* CompteActive, etc.) would only happen when the async worker processes the event,
|
||||||
|
* causing stale data in the UI until then.
|
||||||
|
*
|
||||||
|
* This middleware runs before SendMessageMiddleware, ensuring the cache is invalidated
|
||||||
|
* immediately during the originating HTTP request. The PaginatedQueryCacheInvalidator
|
||||||
|
* handlers still exist as a safety net for worker-side processing.
|
||||||
|
*/
|
||||||
|
final readonly class PaginatedCacheInvalidationMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PaginatedQueryCache $cache,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||||
|
{
|
||||||
|
if ($envelope->last(ReceivedStamp::class) === null) {
|
||||||
|
$this->invalidateIfNeeded($envelope->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stack->next()->handle($envelope, $stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invalidateIfNeeded(object $message): void
|
||||||
|
{
|
||||||
|
match (true) {
|
||||||
|
$message instanceof UtilisateurInvite,
|
||||||
|
$message instanceof CompteActive,
|
||||||
|
$message instanceof InvitationRenvoyee => $this->cache->invalidate('users', (string) $message->tenantId),
|
||||||
|
|
||||||
|
$message instanceof InvitationParentEnvoyee => $this->cache->invalidate('parent_invitations', (string) $message->tenantId),
|
||||||
|
|
||||||
|
$message instanceof InvitationParentActivee => $this->invalidateParentActivee($message),
|
||||||
|
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invalidateParentActivee(InvitationParentActivee $event): void
|
||||||
|
{
|
||||||
|
$tenantId = (string) $event->tenantId;
|
||||||
|
$this->cache->invalidate('parent_invitations', $tenantId);
|
||||||
|
$this->cache->invalidate('users', $tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\ReadModel;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedAssignmentsReader;
|
||||||
|
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
final readonly class DbalPaginatedAssignmentsReader implements PaginatedAssignmentsReader
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<AssignmentWithNamesDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult {
|
||||||
|
$params = ['tenant_id' => $tenantId];
|
||||||
|
$whereClause = 'ta.tenant_id = :tenant_id AND ta.status = :status';
|
||||||
|
$params['status'] = 'active';
|
||||||
|
|
||||||
|
if ($search !== null && $search !== '') {
|
||||||
|
$whereClause .= ' AND (u.first_name ILIKE :search OR u.last_name ILIKE :search OR sc.name ILIKE :search OR s.name ILIKE :search)';
|
||||||
|
$params['search'] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromClause = <<<SQL
|
||||||
|
FROM teacher_assignments ta
|
||||||
|
JOIN users u ON u.id = ta.teacher_id AND u.tenant_id = ta.tenant_id
|
||||||
|
JOIN school_classes sc ON sc.id = ta.school_class_id AND sc.tenant_id = ta.tenant_id
|
||||||
|
JOIN subjects s ON s.id = ta.subject_id AND s.tenant_id = ta.tenant_id
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$countSql = "SELECT COUNT(*) {$fromClause} WHERE {$whereClause}";
|
||||||
|
|
||||||
|
/** @var int|string|false $totalRaw */
|
||||||
|
$totalRaw = $this->connection->fetchOne($countSql, $params);
|
||||||
|
$total = (int) $totalRaw;
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$selectSql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
ta.id, ta.teacher_id,
|
||||||
|
u.first_name AS teacher_first_name, u.last_name AS teacher_last_name,
|
||||||
|
ta.school_class_id AS class_id, sc.name AS class_name,
|
||||||
|
ta.subject_id, s.name AS subject_name,
|
||||||
|
ta.academic_year_id, ta.status, ta.start_date, ta.end_date, ta.created_at
|
||||||
|
{$fromClause}
|
||||||
|
WHERE {$whereClause}
|
||||||
|
ORDER BY u.last_name ASC, u.first_name ASC, sc.name ASC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$params['limit'] = $limit;
|
||||||
|
$params['offset'] = $offset;
|
||||||
|
|
||||||
|
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
|
||||||
|
|
||||||
|
$items = array_map(static function (array $row): AssignmentWithNamesDto {
|
||||||
|
/** @var string $id */
|
||||||
|
$id = $row['id'];
|
||||||
|
/** @var string $teacherId */
|
||||||
|
$teacherId = $row['teacher_id'];
|
||||||
|
/** @var string $teacherFirstName */
|
||||||
|
$teacherFirstName = $row['teacher_first_name'];
|
||||||
|
/** @var string $teacherLastName */
|
||||||
|
$teacherLastName = $row['teacher_last_name'];
|
||||||
|
/** @var string $classId */
|
||||||
|
$classId = $row['class_id'];
|
||||||
|
/** @var string $className */
|
||||||
|
$className = $row['class_name'];
|
||||||
|
/** @var string $subjectId */
|
||||||
|
$subjectId = $row['subject_id'];
|
||||||
|
/** @var string $subjectName */
|
||||||
|
$subjectName = $row['subject_name'];
|
||||||
|
/** @var string $academicYearId */
|
||||||
|
$academicYearId = $row['academic_year_id'];
|
||||||
|
/** @var string $status */
|
||||||
|
$status = $row['status'];
|
||||||
|
/** @var string $startDate */
|
||||||
|
$startDate = $row['start_date'];
|
||||||
|
/** @var string|null $endDate */
|
||||||
|
$endDate = $row['end_date'];
|
||||||
|
/** @var string $createdAt */
|
||||||
|
$createdAt = $row['created_at'];
|
||||||
|
|
||||||
|
return new AssignmentWithNamesDto(
|
||||||
|
id: $id,
|
||||||
|
teacherId: $teacherId,
|
||||||
|
teacherFirstName: $teacherFirstName,
|
||||||
|
teacherLastName: $teacherLastName,
|
||||||
|
classId: $classId,
|
||||||
|
className: $className,
|
||||||
|
subjectId: $subjectId,
|
||||||
|
subjectName: $subjectName,
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
status: $status,
|
||||||
|
startDate: new DateTimeImmutable($startDate),
|
||||||
|
endDate: $endDate !== null ? new DateTimeImmutable($endDate) : null,
|
||||||
|
createdAt: new DateTimeImmutable($createdAt),
|
||||||
|
);
|
||||||
|
}, $rows);
|
||||||
|
|
||||||
|
return new PaginatedResult(
|
||||||
|
items: $items,
|
||||||
|
total: $total,
|
||||||
|
page: $page,
|
||||||
|
limit: $limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\ReadModel;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedClassesReader;
|
||||||
|
use App\Administration\Application\Query\GetClasses\ClassDto;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
final readonly class DbalPaginatedClassesReader implements PaginatedClassesReader
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<ClassDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
string $academicYearId,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult {
|
||||||
|
$params = [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'academic_year_id' => $academicYearId,
|
||||||
|
'status' => ClassStatus::ACTIVE->value,
|
||||||
|
];
|
||||||
|
$whereClause = 'sc.tenant_id = :tenant_id AND sc.academic_year_id = :academic_year_id AND sc.status = :status';
|
||||||
|
|
||||||
|
if ($search !== null && $search !== '') {
|
||||||
|
$whereClause .= ' AND (sc.name ILIKE :search OR sc.level ILIKE :search)';
|
||||||
|
$params['search'] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$countSql = "SELECT COUNT(*) FROM school_classes sc WHERE {$whereClause}";
|
||||||
|
|
||||||
|
/** @var int|string|false $totalRaw */
|
||||||
|
$totalRaw = $this->connection->fetchOne($countSql, $params);
|
||||||
|
$total = (int) $totalRaw;
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$selectSql = <<<SQL
|
||||||
|
SELECT sc.id, sc.name, sc.level, sc.capacity, sc.status, sc.description, sc.created_at, sc.updated_at
|
||||||
|
FROM school_classes sc
|
||||||
|
WHERE {$whereClause}
|
||||||
|
ORDER BY sc.name ASC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$params['limit'] = $limit;
|
||||||
|
$params['offset'] = $offset;
|
||||||
|
|
||||||
|
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
|
||||||
|
|
||||||
|
$items = array_map(static function (array $row): ClassDto {
|
||||||
|
/** @var string $id */
|
||||||
|
$id = $row['id'];
|
||||||
|
/** @var string $name */
|
||||||
|
$name = $row['name'];
|
||||||
|
/** @var string|null $level */
|
||||||
|
$level = $row['level'];
|
||||||
|
/** @var int|string|null $capacityRaw */
|
||||||
|
$capacityRaw = $row['capacity'];
|
||||||
|
$capacity = $capacityRaw !== null ? (int) $capacityRaw : null;
|
||||||
|
/** @var string $status */
|
||||||
|
$status = $row['status'];
|
||||||
|
/** @var string|null $description */
|
||||||
|
$description = $row['description'];
|
||||||
|
/** @var string $createdAt */
|
||||||
|
$createdAt = $row['created_at'];
|
||||||
|
/** @var string $updatedAt */
|
||||||
|
$updatedAt = $row['updated_at'];
|
||||||
|
|
||||||
|
return new ClassDto(
|
||||||
|
id: $id,
|
||||||
|
name: $name,
|
||||||
|
level: $level,
|
||||||
|
capacity: $capacity,
|
||||||
|
status: $status,
|
||||||
|
description: $description,
|
||||||
|
createdAt: new DateTimeImmutable($createdAt),
|
||||||
|
updatedAt: new DateTimeImmutable($updatedAt),
|
||||||
|
);
|
||||||
|
}, $rows);
|
||||||
|
|
||||||
|
return new PaginatedResult(
|
||||||
|
items: $items,
|
||||||
|
total: $total,
|
||||||
|
page: $page,
|
||||||
|
limit: $limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\ReadModel;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedParentInvitationsReader;
|
||||||
|
use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
final readonly class DbalPaginatedParentInvitationsReader implements PaginatedParentInvitationsReader
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<ParentInvitationDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
?string $status,
|
||||||
|
?string $studentId,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult {
|
||||||
|
$params = ['tenant_id' => $tenantId];
|
||||||
|
$whereClause = 'pi.tenant_id = :tenant_id';
|
||||||
|
|
||||||
|
if ($status !== null) {
|
||||||
|
$whereClause .= ' AND pi.status = :status';
|
||||||
|
$params['status'] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($studentId !== null) {
|
||||||
|
$whereClause .= ' AND pi.student_id = :student_id';
|
||||||
|
$params['student_id'] = $studentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($search !== null && $search !== '') {
|
||||||
|
$whereClause .= ' AND (pi.parent_email ILIKE :search OR u.first_name ILIKE :search OR u.last_name ILIKE :search)';
|
||||||
|
$params['search'] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromClause = <<<SQL
|
||||||
|
FROM parent_invitations pi
|
||||||
|
LEFT JOIN users u ON u.id = pi.student_id AND u.tenant_id = pi.tenant_id
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$countSql = "SELECT COUNT(*) {$fromClause} WHERE {$whereClause}";
|
||||||
|
|
||||||
|
/** @var int|string|false $totalRaw */
|
||||||
|
$totalRaw = $this->connection->fetchOne($countSql, $params);
|
||||||
|
$total = (int) $totalRaw;
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$selectSql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
pi.id, pi.student_id, pi.parent_email, pi.status,
|
||||||
|
pi.created_at, pi.expires_at, pi.sent_at, pi.activated_at, pi.activated_user_id,
|
||||||
|
u.first_name AS student_first_name, u.last_name AS student_last_name
|
||||||
|
{$fromClause}
|
||||||
|
WHERE {$whereClause}
|
||||||
|
ORDER BY pi.created_at DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$params['limit'] = $limit;
|
||||||
|
$params['offset'] = $offset;
|
||||||
|
|
||||||
|
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
|
||||||
|
|
||||||
|
$items = array_map(static function (array $row): ParentInvitationDto {
|
||||||
|
/** @var string $id */
|
||||||
|
$id = $row['id'];
|
||||||
|
/** @var string $studentId */
|
||||||
|
$studentId = $row['student_id'];
|
||||||
|
/** @var string $parentEmail */
|
||||||
|
$parentEmail = $row['parent_email'];
|
||||||
|
/** @var string $status */
|
||||||
|
$status = $row['status'];
|
||||||
|
/** @var string $createdAt */
|
||||||
|
$createdAt = $row['created_at'];
|
||||||
|
/** @var string $expiresAt */
|
||||||
|
$expiresAt = $row['expires_at'];
|
||||||
|
/** @var string|null $sentAt */
|
||||||
|
$sentAt = $row['sent_at'];
|
||||||
|
/** @var string|null $activatedAt */
|
||||||
|
$activatedAt = $row['activated_at'];
|
||||||
|
/** @var string|null $activatedUserId */
|
||||||
|
$activatedUserId = $row['activated_user_id'];
|
||||||
|
/** @var string|null $studentFirstName */
|
||||||
|
$studentFirstName = $row['student_first_name'];
|
||||||
|
/** @var string|null $studentLastName */
|
||||||
|
$studentLastName = $row['student_last_name'];
|
||||||
|
|
||||||
|
return new ParentInvitationDto(
|
||||||
|
id: $id,
|
||||||
|
studentId: $studentId,
|
||||||
|
parentEmail: $parentEmail,
|
||||||
|
status: $status,
|
||||||
|
createdAt: new DateTimeImmutable($createdAt),
|
||||||
|
expiresAt: new DateTimeImmutable($expiresAt),
|
||||||
|
sentAt: $sentAt !== null ? new DateTimeImmutable($sentAt) : null,
|
||||||
|
activatedAt: $activatedAt !== null ? new DateTimeImmutable($activatedAt) : null,
|
||||||
|
activatedUserId: $activatedUserId,
|
||||||
|
studentFirstName: $studentFirstName,
|
||||||
|
studentLastName: $studentLastName,
|
||||||
|
);
|
||||||
|
}, $rows);
|
||||||
|
|
||||||
|
return new PaginatedResult(
|
||||||
|
items: $items,
|
||||||
|
total: $total,
|
||||||
|
page: $page,
|
||||||
|
limit: $limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\ReadModel;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
|
||||||
|
use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto;
|
||||||
|
use App\Administration\Domain\Model\User\ImageRightsStatus;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use function json_encode;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
final readonly class DbalPaginatedStudentImageRightsReader implements PaginatedStudentImageRightsReader
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<StudentImageRightsDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
?string $status,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult {
|
||||||
|
$params = $this->buildBaseParams($tenantId);
|
||||||
|
$whereClause = $this->buildBaseWhere();
|
||||||
|
|
||||||
|
if ($status !== null && $status !== '') {
|
||||||
|
$whereClause .= ' AND u.image_rights_status = :status';
|
||||||
|
$params['status'] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($search !== null && $search !== '') {
|
||||||
|
$whereClause .= ' AND (u.first_name ILIKE :search OR u.last_name ILIKE :search OR u.email ILIKE :search)';
|
||||||
|
$params['search'] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$countSql = "SELECT COUNT(*) FROM users u WHERE {$whereClause}";
|
||||||
|
|
||||||
|
/** @var int|string|false $totalRaw */
|
||||||
|
$totalRaw = $this->connection->fetchOne($countSql, $params);
|
||||||
|
$total = (int) $totalRaw;
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$selectSql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
u.id, u.first_name, u.last_name, u.email,
|
||||||
|
u.image_rights_status, u.image_rights_updated_at,
|
||||||
|
(
|
||||||
|
SELECT sc.name
|
||||||
|
FROM class_assignments ca
|
||||||
|
JOIN school_classes sc ON sc.id = ca.school_class_id
|
||||||
|
WHERE ca.user_id = u.id AND ca.tenant_id = u.tenant_id
|
||||||
|
ORDER BY ca.assigned_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS class_name
|
||||||
|
FROM users u
|
||||||
|
WHERE {$whereClause}
|
||||||
|
ORDER BY u.last_name ASC, u.first_name ASC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$params['limit'] = $limit;
|
||||||
|
$params['offset'] = $offset;
|
||||||
|
|
||||||
|
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
|
||||||
|
|
||||||
|
$items = array_map(self::mapRowToDto(...), $rows);
|
||||||
|
|
||||||
|
return new PaginatedResult(
|
||||||
|
items: $items,
|
||||||
|
total: $total,
|
||||||
|
page: $page,
|
||||||
|
limit: $limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return StudentImageRightsDto[]
|
||||||
|
*/
|
||||||
|
public function findAll(
|
||||||
|
string $tenantId,
|
||||||
|
?string $status,
|
||||||
|
): array {
|
||||||
|
$params = $this->buildBaseParams($tenantId);
|
||||||
|
$whereClause = $this->buildBaseWhere();
|
||||||
|
|
||||||
|
if ($status !== null && $status !== '') {
|
||||||
|
$whereClause .= ' AND u.image_rights_status = :status';
|
||||||
|
$params['status'] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
u.id, u.first_name, u.last_name, u.email,
|
||||||
|
u.image_rights_status, u.image_rights_updated_at,
|
||||||
|
(
|
||||||
|
SELECT sc.name
|
||||||
|
FROM class_assignments ca
|
||||||
|
JOIN school_classes sc ON sc.id = ca.school_class_id
|
||||||
|
WHERE ca.user_id = u.id AND ca.tenant_id = u.tenant_id
|
||||||
|
ORDER BY ca.assigned_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS class_name
|
||||||
|
FROM users u
|
||||||
|
WHERE {$whereClause}
|
||||||
|
ORDER BY u.last_name ASC, u.first_name ASC
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$rows = $this->connection->fetchAllAssociative($sql, $params);
|
||||||
|
|
||||||
|
return array_map(self::mapRowToDto(...), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function buildBaseParams(string $tenantId): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'role' => json_encode([Role::ELEVE->value], JSON_THROW_ON_ERROR),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildBaseWhere(): string
|
||||||
|
{
|
||||||
|
return 'u.tenant_id = :tenant_id AND u.roles::jsonb @> :role::jsonb';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private static function mapRowToDto(array $row): StudentImageRightsDto
|
||||||
|
{
|
||||||
|
/** @var string $id */
|
||||||
|
$id = $row['id'];
|
||||||
|
/** @var string $firstName */
|
||||||
|
$firstName = $row['first_name'];
|
||||||
|
/** @var string $lastName */
|
||||||
|
$lastName = $row['last_name'];
|
||||||
|
/** @var string|null $email */
|
||||||
|
$email = $row['email'];
|
||||||
|
/** @var string $imageRightsStatusValue */
|
||||||
|
$imageRightsStatusValue = $row['image_rights_status'];
|
||||||
|
/** @var string|null $imageRightsUpdatedAt */
|
||||||
|
$imageRightsUpdatedAt = $row['image_rights_updated_at'];
|
||||||
|
/** @var string|null $className */
|
||||||
|
$className = $row['class_name'] ?? null;
|
||||||
|
|
||||||
|
$statusEnum = ImageRightsStatus::from($imageRightsStatusValue);
|
||||||
|
|
||||||
|
return new StudentImageRightsDto(
|
||||||
|
id: $id,
|
||||||
|
firstName: $firstName,
|
||||||
|
lastName: $lastName,
|
||||||
|
email: $email ?? '',
|
||||||
|
imageRightsStatus: $statusEnum->value,
|
||||||
|
imageRightsStatusLabel: $statusEnum->label(),
|
||||||
|
imageRightsUpdatedAt: $imageRightsUpdatedAt !== null ? new DateTimeImmutable($imageRightsUpdatedAt) : null,
|
||||||
|
className: $className,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\ReadModel;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedSubjectsReader;
|
||||||
|
use App\Administration\Application\Query\GetSubjects\SubjectDto;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsReader
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<SubjectDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
string $schoolId,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult {
|
||||||
|
$params = [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'school_id' => $schoolId,
|
||||||
|
'status' => 'active',
|
||||||
|
];
|
||||||
|
$whereClause = 's.tenant_id = :tenant_id AND s.school_id = :school_id AND s.status = :status AND s.deleted_at IS NULL';
|
||||||
|
|
||||||
|
if ($search !== null && $search !== '') {
|
||||||
|
$whereClause .= ' AND (s.name ILIKE :search OR s.code ILIKE :search)';
|
||||||
|
$params['search'] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$countSql = "SELECT COUNT(*) FROM subjects s WHERE {$whereClause}";
|
||||||
|
|
||||||
|
/** @var int|string|false $totalRaw */
|
||||||
|
$totalRaw = $this->connection->fetchOne($countSql, $params);
|
||||||
|
$total = (int) $totalRaw;
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$selectSql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
s.id, s.name, s.code, s.color, s.description, s.status,
|
||||||
|
s.created_at, s.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS teacher_count,
|
||||||
|
(SELECT COUNT(DISTINCT ta.school_class_id) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS class_count
|
||||||
|
FROM subjects s
|
||||||
|
WHERE {$whereClause}
|
||||||
|
ORDER BY s.name ASC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$params['limit'] = $limit;
|
||||||
|
$params['offset'] = $offset;
|
||||||
|
|
||||||
|
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
|
||||||
|
|
||||||
|
$items = array_map(static function (array $row): SubjectDto {
|
||||||
|
/** @var string $id */
|
||||||
|
$id = $row['id'];
|
||||||
|
/** @var string $name */
|
||||||
|
$name = $row['name'];
|
||||||
|
/** @var string $code */
|
||||||
|
$code = $row['code'];
|
||||||
|
/** @var string|null $color */
|
||||||
|
$color = $row['color'];
|
||||||
|
/** @var string|null $description */
|
||||||
|
$description = $row['description'];
|
||||||
|
/** @var string $status */
|
||||||
|
$status = $row['status'];
|
||||||
|
/** @var string $createdAt */
|
||||||
|
$createdAt = $row['created_at'];
|
||||||
|
/** @var string $updatedAt */
|
||||||
|
$updatedAt = $row['updated_at'];
|
||||||
|
/** @var int|string $teacherCountRaw */
|
||||||
|
$teacherCountRaw = $row['teacher_count'] ?? 0;
|
||||||
|
/** @var int|string $classCountRaw */
|
||||||
|
$classCountRaw = $row['class_count'] ?? 0;
|
||||||
|
|
||||||
|
return new SubjectDto(
|
||||||
|
id: $id,
|
||||||
|
name: $name,
|
||||||
|
code: $code,
|
||||||
|
color: $color,
|
||||||
|
description: $description,
|
||||||
|
status: $status,
|
||||||
|
createdAt: new DateTimeImmutable($createdAt),
|
||||||
|
updatedAt: new DateTimeImmutable($updatedAt),
|
||||||
|
teacherCount: (int) $teacherCountRaw,
|
||||||
|
classCount: (int) $classCountRaw,
|
||||||
|
);
|
||||||
|
}, $rows);
|
||||||
|
|
||||||
|
return new PaginatedResult(
|
||||||
|
items: $items,
|
||||||
|
total: $total,
|
||||||
|
page: $page,
|
||||||
|
limit: $limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\ReadModel;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedUsersReader;
|
||||||
|
use App\Administration\Application\Query\GetUsers\UserDto;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use function json_decode;
|
||||||
|
use function json_encode;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
final readonly class DbalPaginatedUsersReader implements PaginatedUsersReader
|
||||||
|
{
|
||||||
|
private const int INVITATION_EXPIRY_DAYS = 7;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PaginatedResult<UserDto>
|
||||||
|
*/
|
||||||
|
public function findPaginated(
|
||||||
|
string $tenantId,
|
||||||
|
?string $role,
|
||||||
|
?string $statut,
|
||||||
|
?string $search,
|
||||||
|
int $page,
|
||||||
|
int $limit,
|
||||||
|
): PaginatedResult {
|
||||||
|
$params = ['tenant_id' => $tenantId];
|
||||||
|
$whereClause = 'u.tenant_id = :tenant_id';
|
||||||
|
|
||||||
|
if ($role !== null) {
|
||||||
|
$filterRole = Role::tryFrom($role);
|
||||||
|
if ($filterRole !== null) {
|
||||||
|
$whereClause .= ' AND u.roles::jsonb @> :role::jsonb';
|
||||||
|
$params['role'] = json_encode([$filterRole->value], JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($statut !== null) {
|
||||||
|
$whereClause .= ' AND u.statut = :statut';
|
||||||
|
$params['statut'] = $statut;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($search !== null && $search !== '') {
|
||||||
|
$whereClause .= ' AND (u.first_name ILIKE :search OR u.last_name ILIKE :search OR u.email ILIKE :search)';
|
||||||
|
$params['search'] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$countSql = "SELECT COUNT(*) FROM users u WHERE {$whereClause}";
|
||||||
|
|
||||||
|
/** @var int|string|false $totalRaw */
|
||||||
|
$totalRaw = $this->connection->fetchOne($countSql, $params);
|
||||||
|
$total = (int) $totalRaw;
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$selectSql = <<<SQL
|
||||||
|
SELECT
|
||||||
|
u.id, u.email, u.roles, u.first_name, u.last_name,
|
||||||
|
u.statut, u.created_at, u.invited_at, u.activated_at,
|
||||||
|
u.blocked_at, u.blocked_reason
|
||||||
|
FROM users u
|
||||||
|
WHERE {$whereClause}
|
||||||
|
ORDER BY u.last_name ASC, u.first_name ASC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$params['limit'] = $limit;
|
||||||
|
$params['offset'] = $offset;
|
||||||
|
|
||||||
|
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
|
||||||
|
|
||||||
|
$now = $this->clock->now();
|
||||||
|
$items = array_map(
|
||||||
|
static fn (array $row): UserDto => self::mapRowToDto($row, $now),
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new PaginatedResult(
|
||||||
|
items: $items,
|
||||||
|
total: $total,
|
||||||
|
page: $page,
|
||||||
|
limit: $limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private static function mapRowToDto(array $row, DateTimeImmutable $now): UserDto
|
||||||
|
{
|
||||||
|
/** @var string $id */
|
||||||
|
$id = $row['id'];
|
||||||
|
/** @var string|null $email */
|
||||||
|
$email = $row['email'];
|
||||||
|
/** @var string $rolesJson */
|
||||||
|
$rolesJson = $row['roles'];
|
||||||
|
/** @var string $firstName */
|
||||||
|
$firstName = $row['first_name'];
|
||||||
|
/** @var string $lastName */
|
||||||
|
$lastName = $row['last_name'];
|
||||||
|
/** @var string $statut */
|
||||||
|
$statut = $row['statut'];
|
||||||
|
/** @var string $createdAt */
|
||||||
|
$createdAt = $row['created_at'];
|
||||||
|
/** @var string|null $invitedAt */
|
||||||
|
$invitedAt = $row['invited_at'];
|
||||||
|
/** @var string|null $activatedAt */
|
||||||
|
$activatedAt = $row['activated_at'];
|
||||||
|
/** @var string|null $blockedAt */
|
||||||
|
$blockedAt = $row['blocked_at'];
|
||||||
|
/** @var string|null $blockedReason */
|
||||||
|
$blockedReason = $row['blocked_reason'];
|
||||||
|
|
||||||
|
/** @var string[] $roleValues */
|
||||||
|
$roleValues = json_decode($rolesJson, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$primaryRole = Role::from($roleValues[0] ?? Role::ELEVE->value);
|
||||||
|
|
||||||
|
$invitedAtDate = $invitedAt !== null ? new DateTimeImmutable($invitedAt) : null;
|
||||||
|
$invitationExpiree = $invitedAtDate !== null
|
||||||
|
&& $activatedAt === null
|
||||||
|
&& $invitedAtDate->modify('+' . self::INVITATION_EXPIRY_DAYS . ' days') < $now;
|
||||||
|
|
||||||
|
return new UserDto(
|
||||||
|
id: $id,
|
||||||
|
email: $email ?? '',
|
||||||
|
role: $primaryRole->value,
|
||||||
|
roleLabel: $primaryRole->label(),
|
||||||
|
roles: $roleValues,
|
||||||
|
firstName: $firstName,
|
||||||
|
lastName: $lastName,
|
||||||
|
statut: $statut,
|
||||||
|
createdAt: new DateTimeImmutable($createdAt),
|
||||||
|
invitedAt: $invitedAtDate,
|
||||||
|
activatedAt: $activatedAt !== null ? new DateTimeImmutable($activatedAt) : null,
|
||||||
|
blockedAt: $blockedAt !== null ? new DateTimeImmutable($blockedAt) : null,
|
||||||
|
blockedReason: $blockedReason,
|
||||||
|
invitationExpiree: $invitationExpiree,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Functional\Administration\Api;
|
namespace App\Tests\Functional\Administration\Api;
|
||||||
|
|
||||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||||
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Administration\Domain\Model\User\Email;
|
use App\Administration\Domain\Model\User\Email;
|
||||||
use App\Administration\Domain\Model\User\ImageRightsStatus;
|
use App\Administration\Domain\Model\User\ImageRightsStatus;
|
||||||
use App\Administration\Domain\Model\User\Role;
|
use App\Administration\Domain\Model\User\Role;
|
||||||
@@ -43,6 +44,10 @@ final class ImageRightsEndpointsTest extends ApiTestCase
|
|||||||
$connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::STUDENT_2_ID]);
|
$connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::STUDENT_2_ID]);
|
||||||
$connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::USER_ID]);
|
$connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::USER_ID]);
|
||||||
|
|
||||||
|
/** @var PaginatedQueryCache $paginatedCache */
|
||||||
|
$paginatedCache = $container->get(PaginatedQueryCache::class);
|
||||||
|
$paginatedCache->invalidate('students_image_rights', self::TENANT_ID);
|
||||||
|
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,365 +4,140 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Unit\Administration\Application\Query\GetAllAssignments;
|
namespace App\Tests\Unit\Administration\Application\Query\GetAllAssignments;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedAssignmentsReader;
|
||||||
|
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
|
||||||
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsHandler;
|
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsHandler;
|
||||||
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsQuery;
|
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsQuery;
|
||||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
|
||||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
|
||||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
|
||||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
|
||||||
use App\Administration\Domain\Model\Subject\Subject;
|
|
||||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
|
||||||
use App\Administration\Domain\Model\Subject\SubjectName;
|
|
||||||
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
|
|
||||||
use App\Administration\Domain\Model\User\Email;
|
|
||||||
use App\Administration\Domain\Model\User\Role;
|
|
||||||
use App\Administration\Domain\Model\User\User;
|
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
|
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
|
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
|
|
||||||
use function count;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\NullLogger;
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
final class GetAllAssignmentsHandlerTest extends TestCase
|
final class GetAllAssignmentsHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
private PaginatedAssignmentsReader $reader;
|
||||||
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
|
private PaginatedQueryCache $cache;
|
||||||
|
|
||||||
private InMemoryTeacherAssignmentRepository $assignmentRepository;
|
|
||||||
private InMemoryUserRepository $userRepository;
|
|
||||||
private InMemoryClassRepository $classRepository;
|
|
||||||
private InMemorySubjectRepository $subjectRepository;
|
|
||||||
private GetAllAssignmentsHandler $handler;
|
private GetAllAssignmentsHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->assignmentRepository = new InMemoryTeacherAssignmentRepository();
|
$this->reader = $this->createMock(PaginatedAssignmentsReader::class);
|
||||||
$this->userRepository = new InMemoryUserRepository();
|
$this->cache = new PaginatedQueryCache(
|
||||||
$this->classRepository = new InMemoryClassRepository();
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
$this->subjectRepository = new InMemorySubjectRepository();
|
|
||||||
$this->handler = new GetAllAssignmentsHandler(
|
|
||||||
$this->assignmentRepository,
|
|
||||||
$this->userRepository,
|
|
||||||
$this->classRepository,
|
|
||||||
$this->subjectRepository,
|
|
||||||
new NullLogger(),
|
|
||||||
);
|
);
|
||||||
|
$this->handler = new GetAllAssignmentsHandler($this->reader, $this->cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function returnsAllActiveAssignmentsWithNames(): void
|
public function returnsItemsForTenant(): void
|
||||||
{
|
{
|
||||||
$this->seedData();
|
$dto = $this->createAssignmentDto();
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
$query = new GetAllAssignmentsQuery(tenantId: self::TENANT_ID);
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(2, $result->items);
|
|
||||||
self::assertSame(2, $result->total);
|
|
||||||
self::assertSame(1, $result->page);
|
|
||||||
self::assertSame(30, $result->limit);
|
|
||||||
|
|
||||||
// Verify denormalized names are populated
|
|
||||||
$dto = $result->items[0];
|
|
||||||
self::assertNotSame('', $dto->teacherFirstName);
|
|
||||||
self::assertNotSame('', $dto->className);
|
|
||||||
self::assertNotSame('', $dto->subjectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function searchesByTeacherName(): void
|
|
||||||
{
|
|
||||||
$this->seedData();
|
|
||||||
|
|
||||||
$query = new GetAllAssignmentsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
search: 'Jean',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
$result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1'));
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
self::assertCount(1, $result->items);
|
||||||
self::assertSame('Jean', $result->items[0]->teacherFirstName);
|
self::assertSame(1, $result->total);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function searchesByClassName(): void
|
public function mapsDtoFields(): void
|
||||||
{
|
{
|
||||||
$this->seedData();
|
$dto = $this->createAssignmentDto();
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
$query = new GetAllAssignmentsQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
search: '6ème',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertGreaterThanOrEqual(1, count($result->items));
|
$result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1'));
|
||||||
self::assertStringContainsString('6ème', $result->items[0]->className);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
$item = $result->items[0];
|
||||||
public function searchesBySubjectName(): void
|
self::assertSame('assign-1', $item->id);
|
||||||
{
|
self::assertSame('teacher-1', $item->teacherId);
|
||||||
$this->seedData();
|
self::assertSame('Jean', $item->teacherFirstName);
|
||||||
|
self::assertSame('Dupont', $item->teacherLastName);
|
||||||
$query = new GetAllAssignmentsQuery(
|
self::assertSame('class-1', $item->classId);
|
||||||
tenantId: self::TENANT_ID,
|
self::assertSame('6eme A', $item->className);
|
||||||
search: 'Français',
|
self::assertSame('subj-1', $item->subjectId);
|
||||||
);
|
self::assertSame('Mathematiques', $item->subjectName);
|
||||||
$result = ($this->handler)($query);
|
self::assertSame('year-1', $item->academicYearId);
|
||||||
|
self::assertSame('active', $item->status);
|
||||||
self::assertCount(1, $result->items);
|
self::assertNull($item->endDate);
|
||||||
self::assertSame('Français', $result->items[0]->subjectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function searchIsCaseInsensitive(): void
|
|
||||||
{
|
|
||||||
$this->seedData();
|
|
||||||
|
|
||||||
$query = new GetAllAssignmentsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
search: 'jean',
|
|
||||||
);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
|
||||||
self::assertSame('Jean', $result->items[0]->teacherFirstName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function paginatesResults(): void
|
public function paginatesResults(): void
|
||||||
{
|
{
|
||||||
$this->seedData();
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
|
||||||
$query = new GetAllAssignmentsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
page: 1,
|
|
||||||
limit: 1,
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
$result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1', page: 2, limit: 10));
|
||||||
self::assertSame(2, $result->total);
|
|
||||||
self::assertSame(1, $result->page);
|
|
||||||
self::assertSame(1, $result->limit);
|
|
||||||
self::assertSame(2, $result->totalPages());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
self::assertSame(50, $result->total);
|
||||||
public function returnsSecondPage(): void
|
|
||||||
{
|
|
||||||
$this->seedData();
|
|
||||||
|
|
||||||
$query = new GetAllAssignmentsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
page: 2,
|
|
||||||
limit: 1,
|
|
||||||
);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
|
||||||
self::assertSame(2, $result->total);
|
|
||||||
self::assertSame(2, $result->page);
|
self::assertSame(2, $result->page);
|
||||||
|
self::assertSame(10, $result->limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function returnsEmptyWhenNoMatches(): void
|
public function cachesResult(): void
|
||||||
{
|
{
|
||||||
$this->seedData();
|
$dto = $this->createAssignmentDto();
|
||||||
|
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
|
||||||
$query = new GetAllAssignmentsQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
search: 'nonexistent',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$query = new GetAllAssignmentsQuery(tenantId: 'tenant-1');
|
||||||
|
($this->handler)($query);
|
||||||
$result = ($this->handler)($query);
|
$result = ($this->handler)($query);
|
||||||
|
|
||||||
self::assertCount(0, $result->items);
|
|
||||||
self::assertSame(0, $result->total);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function excludesAssignmentsFromOtherTenants(): void
|
|
||||||
{
|
|
||||||
$this->seedData();
|
|
||||||
|
|
||||||
$query = new GetAllAssignmentsQuery(tenantId: self::OTHER_TENANT_ID);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(0, $result->items);
|
|
||||||
self::assertSame(0, $result->total);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function handlesOrphanedAssignment(): void
|
|
||||||
{
|
|
||||||
// Create an assignment with no matching teacher/class/subject
|
|
||||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
||||||
$now = new DateTimeImmutable('2026-02-01 10:00:00');
|
|
||||||
|
|
||||||
$orphanedClass = SchoolClass::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolId: SchoolId::generate(),
|
|
||||||
academicYearId: AcademicYearId::generate(),
|
|
||||||
name: new ClassName('Orphan Class'),
|
|
||||||
level: null,
|
|
||||||
capacity: null,
|
|
||||||
createdAt: $now,
|
|
||||||
);
|
|
||||||
$this->classRepository->save($orphanedClass);
|
|
||||||
|
|
||||||
$orphanedSubject = Subject::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolId: SchoolId::generate(),
|
|
||||||
name: new SubjectName('Orphan Subject'),
|
|
||||||
code: new SubjectCode('ORPH'),
|
|
||||||
color: null,
|
|
||||||
createdAt: $now,
|
|
||||||
);
|
|
||||||
$this->subjectRepository->save($orphanedSubject);
|
|
||||||
|
|
||||||
// Teacher does NOT exist in userRepository
|
|
||||||
$orphanedTeacher = User::inviter(
|
|
||||||
email: new Email('orphan@example.com'),
|
|
||||||
role: Role::PROF,
|
|
||||||
tenantId: TenantId::fromString(self::OTHER_TENANT_ID), // Different tenant — won't be found
|
|
||||||
schoolName: 'Autre',
|
|
||||||
firstName: 'Ghost',
|
|
||||||
lastName: 'Teacher',
|
|
||||||
invitedAt: $now,
|
|
||||||
);
|
|
||||||
$this->userRepository->save($orphanedTeacher);
|
|
||||||
|
|
||||||
$assignment = TeacherAssignment::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
teacherId: $orphanedTeacher->id,
|
|
||||||
classId: $orphanedClass->id,
|
|
||||||
subjectId: $orphanedSubject->id,
|
|
||||||
academicYearId: AcademicYearId::generate(),
|
|
||||||
createdAt: $now,
|
|
||||||
);
|
|
||||||
$this->assignmentRepository->save($assignment);
|
|
||||||
|
|
||||||
$query = new GetAllAssignmentsQuery(tenantId: self::TENANT_ID);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
// Assignment should appear with empty teacher names (orphaned reference logged as warning)
|
|
||||||
self::assertCount(1, $result->items);
|
self::assertCount(1, $result->items);
|
||||||
self::assertSame('', $result->items[0]->teacherFirstName);
|
|
||||||
self::assertSame('', $result->items[0]->teacherLastName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function clampsInvalidPageToOne(): void
|
public function clampsPageToMinimumOne(): void
|
||||||
{
|
{
|
||||||
$query = new GetAllAssignmentsQuery(
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
tenantId: self::TENANT_ID,
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
|
||||||
page: -1,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(1, $query->page);
|
$result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1', page: -5));
|
||||||
|
|
||||||
|
self::assertSame(1, $result->page);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function clampsExcessiveLimitToMax(): void
|
public function clampsLimitToMaximumHundred(): void
|
||||||
{
|
{
|
||||||
$query = new GetAllAssignmentsQuery(
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
tenantId: self::TENANT_ID,
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
|
||||||
limit: 999,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(100, $query->limit);
|
$result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1', limit: 500));
|
||||||
|
|
||||||
|
self::assertSame(100, $result->limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedData(): void
|
private function createAssignmentDto(): AssignmentWithNamesDto
|
||||||
{
|
{
|
||||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
return new AssignmentWithNamesDto(
|
||||||
$schoolId = SchoolId::generate();
|
id: 'assign-1',
|
||||||
$academicYearId = AcademicYearId::generate();
|
teacherId: 'teacher-1',
|
||||||
$now = new DateTimeImmutable('2026-02-01 10:00:00');
|
teacherFirstName: 'Jean',
|
||||||
|
teacherLastName: 'Dupont',
|
||||||
// Create teachers
|
classId: 'class-1',
|
||||||
$teacher1 = User::inviter(
|
className: '6eme A',
|
||||||
email: new Email('teacher1@example.com'),
|
subjectId: 'subj-1',
|
||||||
role: Role::PROF,
|
subjectName: 'Mathematiques',
|
||||||
tenantId: $tenantId,
|
academicYearId: 'year-1',
|
||||||
schoolName: 'École Alpha',
|
status: 'active',
|
||||||
firstName: 'Jean',
|
startDate: new DateTimeImmutable('2026-02-01'),
|
||||||
lastName: 'Dupont',
|
endDate: null,
|
||||||
invitedAt: $now,
|
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||||
);
|
);
|
||||||
$this->userRepository->save($teacher1);
|
|
||||||
|
|
||||||
$teacher2 = User::inviter(
|
|
||||||
email: new Email('teacher2@example.com'),
|
|
||||||
role: Role::PROF,
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolName: 'École Alpha',
|
|
||||||
firstName: 'Marie',
|
|
||||||
lastName: 'Martin',
|
|
||||||
invitedAt: $now,
|
|
||||||
);
|
|
||||||
$this->userRepository->save($teacher2);
|
|
||||||
|
|
||||||
// Create classes
|
|
||||||
$class1 = SchoolClass::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolId: $schoolId,
|
|
||||||
academicYearId: $academicYearId,
|
|
||||||
name: new ClassName('6ème A'),
|
|
||||||
level: SchoolLevel::SIXIEME,
|
|
||||||
capacity: 30,
|
|
||||||
createdAt: $now,
|
|
||||||
);
|
|
||||||
$this->classRepository->save($class1);
|
|
||||||
|
|
||||||
// Create subjects
|
|
||||||
$subject1 = Subject::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolId: $schoolId,
|
|
||||||
name: new SubjectName('Mathématiques'),
|
|
||||||
code: new SubjectCode('MATH'),
|
|
||||||
color: null,
|
|
||||||
createdAt: $now,
|
|
||||||
);
|
|
||||||
$this->subjectRepository->save($subject1);
|
|
||||||
|
|
||||||
$subject2 = Subject::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolId: $schoolId,
|
|
||||||
name: new SubjectName('Français'),
|
|
||||||
code: new SubjectCode('FR'),
|
|
||||||
color: null,
|
|
||||||
createdAt: $now,
|
|
||||||
);
|
|
||||||
$this->subjectRepository->save($subject2);
|
|
||||||
|
|
||||||
// Create assignments
|
|
||||||
$assignment1 = TeacherAssignment::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
teacherId: $teacher1->id,
|
|
||||||
classId: $class1->id,
|
|
||||||
subjectId: $subject1->id,
|
|
||||||
academicYearId: $academicYearId,
|
|
||||||
createdAt: $now,
|
|
||||||
);
|
|
||||||
$this->assignmentRepository->save($assignment1);
|
|
||||||
|
|
||||||
$assignment2 = TeacherAssignment::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
teacherId: $teacher2->id,
|
|
||||||
classId: $class1->id,
|
|
||||||
subjectId: $subject2->id,
|
|
||||||
academicYearId: $academicYearId,
|
|
||||||
createdAt: $now,
|
|
||||||
);
|
|
||||||
$this->assignmentRepository->save($assignment2);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,212 +4,130 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Unit\Administration\Application\Query\GetClasses;
|
namespace App\Tests\Unit\Administration\Application\Query\GetClasses;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedClassesReader;
|
||||||
|
use App\Administration\Application\Query\GetClasses\ClassDto;
|
||||||
use App\Administration\Application\Query\GetClasses\GetClassesHandler;
|
use App\Administration\Application\Query\GetClasses\GetClassesHandler;
|
||||||
use App\Administration\Application\Query\GetClasses\GetClassesQuery;
|
use App\Administration\Application\Query\GetClasses\GetClassesQuery;
|
||||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
|
||||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
|
||||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
|
||||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
final class GetClassesHandlerTest extends TestCase
|
final class GetClassesHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
private PaginatedClassesReader $reader;
|
||||||
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
|
private PaginatedQueryCache $cache;
|
||||||
|
|
||||||
private InMemoryClassRepository $classRepository;
|
|
||||||
private GetClassesHandler $handler;
|
private GetClassesHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->classRepository = new InMemoryClassRepository();
|
$this->reader = $this->createMock(PaginatedClassesReader::class);
|
||||||
$this->handler = new GetClassesHandler($this->classRepository);
|
$this->cache = new PaginatedQueryCache(
|
||||||
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
|
);
|
||||||
|
$this->handler = new GetClassesHandler($this->reader, $this->cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function returnsAllActiveClassesForTenantAndYear(): void
|
public function returnsItemsForTenant(): void
|
||||||
{
|
{
|
||||||
$this->seedClasses();
|
$dto = $this->createClassDto();
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
$query = new GetClassesQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(3, $result->items);
|
$result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1'));
|
||||||
self::assertSame(3, $result->total);
|
|
||||||
self::assertSame(1, $result->page);
|
|
||||||
self::assertSame(30, $result->limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function filtersClassesByName(): void
|
|
||||||
{
|
|
||||||
$this->seedClasses();
|
|
||||||
|
|
||||||
$query = new GetClassesQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
|
||||||
search: '6ème',
|
|
||||||
);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
self::assertCount(1, $result->items);
|
||||||
self::assertSame('6ème A', $result->items[0]->name);
|
self::assertSame(1, $result->total);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function filtersClassesByLevel(): void
|
public function mapsDtoFields(): void
|
||||||
{
|
{
|
||||||
$this->seedClasses();
|
$dto = $this->createClassDto();
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
$query = new GetClassesQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
|
||||||
search: SchoolLevel::CM2->value,
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
$result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1'));
|
||||||
self::assertSame('CM2 B', $result->items[0]->name);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
$item = $result->items[0];
|
||||||
public function searchIsCaseInsensitive(): void
|
self::assertSame('class-1', $item->id);
|
||||||
{
|
self::assertSame('6eme A', $item->name);
|
||||||
$this->seedClasses();
|
self::assertSame('sixieme', $item->level);
|
||||||
|
self::assertSame(30, $item->capacity);
|
||||||
$query = new GetClassesQuery(
|
self::assertSame('active', $item->status);
|
||||||
tenantId: self::TENANT_ID,
|
self::assertSame('Description test', $item->description);
|
||||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
|
||||||
search: 'cm2',
|
|
||||||
);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function paginatesResults(): void
|
public function paginatesResults(): void
|
||||||
{
|
{
|
||||||
$this->seedClasses();
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
|
||||||
$query = new GetClassesQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
|
||||||
page: 1,
|
|
||||||
limit: 2,
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(2, $result->items);
|
$result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1', page: 2, limit: 10));
|
||||||
self::assertSame(3, $result->total);
|
|
||||||
self::assertSame(1, $result->page);
|
self::assertSame(50, $result->total);
|
||||||
self::assertSame(2, $result->limit);
|
self::assertSame(2, $result->page);
|
||||||
self::assertSame(2, $result->totalPages());
|
self::assertSame(10, $result->limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function returnsSecondPage(): void
|
public function cachesResult(): void
|
||||||
{
|
{
|
||||||
$this->seedClasses();
|
$dto = $this->createClassDto();
|
||||||
|
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
|
||||||
$query = new GetClassesQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
|
||||||
page: 2,
|
|
||||||
limit: 2,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$query = new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1');
|
||||||
|
($this->handler)($query);
|
||||||
$result = ($this->handler)($query);
|
$result = ($this->handler)($query);
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
self::assertCount(1, $result->items);
|
||||||
self::assertSame(3, $result->total);
|
|
||||||
self::assertSame(2, $result->page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function returnsEmptyWhenNoMatches(): void
|
public function clampsPageToMinimumOne(): void
|
||||||
{
|
{
|
||||||
$this->seedClasses();
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
|
||||||
$query = new GetClassesQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
|
||||||
search: 'nonexistent',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(0, $result->items);
|
$result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1', page: -5));
|
||||||
self::assertSame(0, $result->total);
|
|
||||||
|
self::assertSame(1, $result->page);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function clampsInvalidPageToOne(): void
|
public function clampsLimitToMaximumHundred(): void
|
||||||
{
|
{
|
||||||
$this->seedClasses();
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
|
||||||
$query = new GetClassesQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
|
||||||
page: -1,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(1, $query->page);
|
$result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1', limit: 500));
|
||||||
|
|
||||||
|
self::assertSame(100, $result->limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
private function createClassDto(): ClassDto
|
||||||
public function clampsExcessiveLimitToMax(): void
|
|
||||||
{
|
{
|
||||||
$query = new GetClassesQuery(
|
return new ClassDto(
|
||||||
tenantId: self::TENANT_ID,
|
id: 'class-1',
|
||||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
name: '6eme A',
|
||||||
limit: 999,
|
level: 'sixieme',
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(100, $query->limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function seedClasses(): void
|
|
||||||
{
|
|
||||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
||||||
$schoolId = SchoolId::generate();
|
|
||||||
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
|
|
||||||
$now = new DateTimeImmutable('2026-02-01 10:00:00');
|
|
||||||
|
|
||||||
$this->classRepository->save(SchoolClass::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolId: $schoolId,
|
|
||||||
academicYearId: $academicYearId,
|
|
||||||
name: new ClassName('6ème A'),
|
|
||||||
level: SchoolLevel::SIXIEME,
|
|
||||||
capacity: 30,
|
capacity: 30,
|
||||||
createdAt: $now,
|
status: 'active',
|
||||||
));
|
description: 'Description test',
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||||
$this->classRepository->save(SchoolClass::creer(
|
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||||
tenantId: $tenantId,
|
);
|
||||||
schoolId: $schoolId,
|
|
||||||
academicYearId: $academicYearId,
|
|
||||||
name: new ClassName('CM2 B'),
|
|
||||||
level: SchoolLevel::CM2,
|
|
||||||
capacity: 25,
|
|
||||||
createdAt: $now,
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->classRepository->save(SchoolClass::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolId: $schoolId,
|
|
||||||
academicYearId: $academicYearId,
|
|
||||||
name: new ClassName('CP Alpha'),
|
|
||||||
level: SchoolLevel::CP,
|
|
||||||
capacity: 20,
|
|
||||||
createdAt: $now,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,204 +4,135 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Unit\Administration\Application\Query\GetParentInvitations;
|
namespace App\Tests\Unit\Administration\Application\Query\GetParentInvitations;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedParentInvitationsReader;
|
||||||
use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsHandler;
|
use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsHandler;
|
||||||
use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsQuery;
|
use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsQuery;
|
||||||
use App\Administration\Domain\Model\Invitation\InvitationCode;
|
use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto;
|
||||||
use App\Administration\Domain\Model\Invitation\ParentInvitation;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Administration\Domain\Model\User\Email;
|
|
||||||
use App\Administration\Domain\Model\User\Role;
|
|
||||||
use App\Administration\Domain\Model\User\User;
|
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
|
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
final class GetParentInvitationsHandlerTest extends TestCase
|
final class GetParentInvitationsHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
private PaginatedParentInvitationsReader $reader;
|
||||||
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
|
private PaginatedQueryCache $cache;
|
||||||
|
|
||||||
private InMemoryParentInvitationRepository $invitationRepository;
|
|
||||||
private InMemoryUserRepository $userRepository;
|
|
||||||
private GetParentInvitationsHandler $handler;
|
private GetParentInvitationsHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->invitationRepository = new InMemoryParentInvitationRepository();
|
$this->reader = $this->createMock(PaginatedParentInvitationsReader::class);
|
||||||
$this->userRepository = new InMemoryUserRepository();
|
$this->cache = new PaginatedQueryCache(
|
||||||
$this->handler = new GetParentInvitationsHandler(
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
$this->invitationRepository,
|
|
||||||
$this->userRepository,
|
|
||||||
);
|
);
|
||||||
|
$this->handler = new GetParentInvitationsHandler($this->reader, $this->cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function itReturnsAllInvitationsForTenant(): void
|
public function returnsItemsForTenant(): void
|
||||||
{
|
{
|
||||||
$student = $this->createAndSaveStudent('Alice', 'Dupont');
|
$dto = $this->createInvitationDto();
|
||||||
$this->createAndSaveInvitation($student->id, 'parent1@example.com');
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
$this->createAndSaveInvitation($student->id, 'parent2@example.com');
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
$result = ($this->handler)(new GetParentInvitationsQuery(
|
$result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1'));
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
));
|
|
||||||
|
|
||||||
self::assertSame(2, $result->total);
|
self::assertCount(1, $result->items);
|
||||||
self::assertCount(2, $result->items);
|
self::assertSame(1, $result->total);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function itFiltersInvitationsByStatus(): void
|
public function mapsDtoFields(): void
|
||||||
{
|
{
|
||||||
$student = $this->createAndSaveStudent('Bob', 'Martin');
|
$dto = $this->createInvitationDto();
|
||||||
$invitation = $this->createAndSaveInvitation($student->id, 'parent@example.com');
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
$this->createPendingInvitation($student->id, 'parent2@example.com');
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
$result = ($this->handler)(new GetParentInvitationsQuery(
|
$result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1'));
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
|
$item = $result->items[0];
|
||||||
|
self::assertSame('inv-1', $item->id);
|
||||||
|
self::assertSame('student-1', $item->studentId);
|
||||||
|
self::assertSame('parent@test.com', $item->parentEmail);
|
||||||
|
self::assertSame('sent', $item->status);
|
||||||
|
self::assertSame('Alice', $item->studentFirstName);
|
||||||
|
self::assertSame('Dupont', $item->studentLastName);
|
||||||
|
self::assertNull($item->activatedAt);
|
||||||
|
self::assertNull($item->activatedUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function paginatesResults(): void
|
||||||
|
{
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1', page: 2, limit: 10));
|
||||||
|
|
||||||
|
self::assertSame(50, $result->total);
|
||||||
|
self::assertSame(2, $result->page);
|
||||||
|
self::assertSame(10, $result->limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function cachesResult(): void
|
||||||
|
{
|
||||||
|
$dto = $this->createInvitationDto();
|
||||||
|
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
$query = new GetParentInvitationsQuery(tenantId: 'tenant-1');
|
||||||
|
($this->handler)($query);
|
||||||
|
$result = ($this->handler)($query);
|
||||||
|
|
||||||
|
self::assertCount(1, $result->items);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function clampsPageToMinimumOne(): void
|
||||||
|
{
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1', page: -5));
|
||||||
|
|
||||||
|
self::assertSame(1, $result->page);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function clampsLimitToMaximumHundred(): void
|
||||||
|
{
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1', limit: 500));
|
||||||
|
|
||||||
|
self::assertSame(100, $result->limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createInvitationDto(): ParentInvitationDto
|
||||||
|
{
|
||||||
|
return new ParentInvitationDto(
|
||||||
|
id: 'inv-1',
|
||||||
|
studentId: 'student-1',
|
||||||
|
parentEmail: 'parent@test.com',
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
));
|
createdAt: new DateTimeImmutable('2026-02-07'),
|
||||||
|
expiresAt: new DateTimeImmutable('2026-03-07'),
|
||||||
self::assertSame(1, $result->total);
|
sentAt: new DateTimeImmutable('2026-02-07'),
|
||||||
self::assertSame('parent@example.com', $result->items[0]->parentEmail);
|
activatedAt: null,
|
||||||
}
|
activatedUserId: null,
|
||||||
|
studentFirstName: 'Alice',
|
||||||
#[Test]
|
studentLastName: 'Dupont',
|
||||||
public function itFiltersInvitationsByStudentId(): void
|
|
||||||
{
|
|
||||||
$student1 = $this->createAndSaveStudent('Alice', 'Dupont');
|
|
||||||
$student2 = $this->createAndSaveStudent('Bob', 'Martin');
|
|
||||||
$this->createAndSaveInvitation($student1->id, 'parent1@example.com');
|
|
||||||
$this->createAndSaveInvitation($student2->id, 'parent2@example.com');
|
|
||||||
|
|
||||||
$result = ($this->handler)(new GetParentInvitationsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
studentId: (string) $student1->id,
|
|
||||||
));
|
|
||||||
|
|
||||||
self::assertSame(1, $result->total);
|
|
||||||
self::assertSame('parent1@example.com', $result->items[0]->parentEmail);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function itSearchesByParentEmailOrStudentName(): void
|
|
||||||
{
|
|
||||||
$student = $this->createAndSaveStudent('Alice', 'Dupont');
|
|
||||||
$this->createAndSaveInvitation($student->id, 'parent@example.com');
|
|
||||||
$this->createAndSaveInvitation($student->id, 'other@example.com');
|
|
||||||
|
|
||||||
$result = ($this->handler)(new GetParentInvitationsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
search: 'Alice',
|
|
||||||
));
|
|
||||||
|
|
||||||
self::assertSame(2, $result->total);
|
|
||||||
|
|
||||||
$result = ($this->handler)(new GetParentInvitationsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
search: 'parent@',
|
|
||||||
));
|
|
||||||
|
|
||||||
self::assertSame(1, $result->total);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function itPaginatesResults(): void
|
|
||||||
{
|
|
||||||
$student = $this->createAndSaveStudent('Alice', 'Dupont');
|
|
||||||
for ($i = 0; $i < 5; ++$i) {
|
|
||||||
$this->createAndSaveInvitation($student->id, "parent{$i}@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = ($this->handler)(new GetParentInvitationsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
page: 1,
|
|
||||||
limit: 2,
|
|
||||||
));
|
|
||||||
|
|
||||||
self::assertSame(5, $result->total);
|
|
||||||
self::assertCount(2, $result->items);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function itEnrichesResultsWithStudentNames(): void
|
|
||||||
{
|
|
||||||
$student = $this->createAndSaveStudent('Alice', 'Dupont');
|
|
||||||
$this->createAndSaveInvitation($student->id, 'parent@example.com');
|
|
||||||
|
|
||||||
$result = ($this->handler)(new GetParentInvitationsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
));
|
|
||||||
|
|
||||||
self::assertSame('Alice', $result->items[0]->studentFirstName);
|
|
||||||
self::assertSame('Dupont', $result->items[0]->studentLastName);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function itIsolatesByTenant(): void
|
|
||||||
{
|
|
||||||
$student = $this->createAndSaveStudent('Alice', 'Dupont');
|
|
||||||
$this->createAndSaveInvitation($student->id, 'parent@example.com');
|
|
||||||
|
|
||||||
$result = ($this->handler)(new GetParentInvitationsQuery(
|
|
||||||
tenantId: self::OTHER_TENANT_ID,
|
|
||||||
));
|
|
||||||
|
|
||||||
self::assertSame(0, $result->total);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createAndSaveStudent(string $firstName, string $lastName): User
|
|
||||||
{
|
|
||||||
$student = User::inviter(
|
|
||||||
email: new Email($firstName . '@example.com'),
|
|
||||||
role: Role::ELEVE,
|
|
||||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
||||||
schoolName: 'École Alpha',
|
|
||||||
firstName: $firstName,
|
|
||||||
lastName: $lastName,
|
|
||||||
invitedAt: new DateTimeImmutable('2026-02-07 10:00:00'),
|
|
||||||
);
|
);
|
||||||
$student->pullDomainEvents();
|
|
||||||
$this->userRepository->save($student);
|
|
||||||
|
|
||||||
return $student;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createAndSaveInvitation(UserId $studentId, string $parentEmail): ParentInvitation
|
|
||||||
{
|
|
||||||
$code = bin2hex(random_bytes(16));
|
|
||||||
$invitation = ParentInvitation::creer(
|
|
||||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
||||||
studentId: $studentId,
|
|
||||||
parentEmail: new Email($parentEmail),
|
|
||||||
code: new InvitationCode($code),
|
|
||||||
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
|
|
||||||
createdBy: UserId::generate(),
|
|
||||||
);
|
|
||||||
$invitation->envoyer(new DateTimeImmutable('2026-02-07 10:00:00'));
|
|
||||||
$invitation->pullDomainEvents();
|
|
||||||
$this->invitationRepository->save($invitation);
|
|
||||||
|
|
||||||
return $invitation;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createPendingInvitation(UserId $studentId, string $parentEmail): ParentInvitation
|
|
||||||
{
|
|
||||||
$code = bin2hex(random_bytes(16));
|
|
||||||
$invitation = ParentInvitation::creer(
|
|
||||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
||||||
studentId: $studentId,
|
|
||||||
parentEmail: new Email($parentEmail),
|
|
||||||
code: new InvitationCode($code),
|
|
||||||
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
|
|
||||||
createdBy: UserId::generate(),
|
|
||||||
);
|
|
||||||
$invitation->pullDomainEvents();
|
|
||||||
$this->invitationRepository->save($invitation);
|
|
||||||
|
|
||||||
return $invitation;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,141 +4,131 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Unit\Administration\Application\Query\GetStudentsImageRights;
|
namespace App\Tests\Unit\Administration\Application\Query\GetStudentsImageRights;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
|
||||||
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsHandler;
|
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsHandler;
|
||||||
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery;
|
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery;
|
||||||
use App\Administration\Domain\Model\User\Email;
|
use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto;
|
||||||
use App\Administration\Domain\Model\User\ImageRightsStatus;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Administration\Domain\Model\User\Role;
|
|
||||||
use App\Administration\Domain\Model\User\User;
|
|
||||||
use App\Administration\Domain\Model\User\UserId;
|
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
final class GetStudentsImageRightsHandlerTest extends TestCase
|
final class GetStudentsImageRightsHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
private PaginatedStudentImageRightsReader $reader;
|
||||||
|
private PaginatedQueryCache $cache;
|
||||||
private InMemoryUserRepository $userRepository;
|
|
||||||
private GetStudentsImageRightsHandler $handler;
|
private GetStudentsImageRightsHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->userRepository = new InMemoryUserRepository();
|
$this->reader = $this->createMock(PaginatedStudentImageRightsReader::class);
|
||||||
$this->handler = new GetStudentsImageRightsHandler($this->userRepository);
|
$this->cache = new PaginatedQueryCache(
|
||||||
}
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function returnsOnlyStudents(): void
|
|
||||||
{
|
|
||||||
$this->seedStudentsAndParent();
|
|
||||||
|
|
||||||
$query = new GetStudentsImageRightsQuery(tenantId: self::TENANT_ID);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(2, $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function filtersStudentsByStatus(): void
|
|
||||||
{
|
|
||||||
$this->seedStudentsAndParent();
|
|
||||||
|
|
||||||
$query = new GetStudentsImageRightsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
status: 'authorized',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
$this->handler = new GetStudentsImageRightsHandler($this->reader, $this->cache);
|
||||||
|
|
||||||
self::assertCount(1, $result);
|
|
||||||
self::assertSame('authorized', $result[0]->imageRightsStatus);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function returnsEmptyForNoStudents(): void
|
public function returnsItemsForTenant(): void
|
||||||
{
|
{
|
||||||
$query = new GetStudentsImageRightsQuery(tenantId: self::TENANT_ID);
|
$dto = $this->createStudentImageRightsDto();
|
||||||
$result = ($this->handler)($query);
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
self::assertCount(0, $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function doesNotReturnStudentsFromOtherTenant(): void
|
|
||||||
{
|
|
||||||
$this->seedStudentsAndParent();
|
|
||||||
|
|
||||||
$query = new GetStudentsImageRightsQuery(
|
|
||||||
tenantId: '550e8400-e29b-41d4-a716-446655440099',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(0, $result);
|
$result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1'));
|
||||||
|
|
||||||
|
self::assertCount(1, $result->items);
|
||||||
|
self::assertSame(1, $result->total);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function returnsDtoWithCorrectFields(): void
|
public function mapsDtoFields(): void
|
||||||
{
|
{
|
||||||
$this->seedStudentsAndParent();
|
$dto = $this->createStudentImageRightsDto();
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
$query = new GetStudentsImageRightsQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
status: 'authorized',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result);
|
$result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1'));
|
||||||
$dto = $result[0];
|
|
||||||
self::assertSame('Alice', $dto->firstName);
|
$item = $result->items[0];
|
||||||
self::assertSame('Dupont', $dto->lastName);
|
self::assertSame('student-1', $item->id);
|
||||||
self::assertSame('authorized', $dto->imageRightsStatus);
|
self::assertSame('Alice', $item->firstName);
|
||||||
self::assertSame('Autorisé', $dto->imageRightsStatusLabel);
|
self::assertSame('Dupont', $item->lastName);
|
||||||
|
self::assertSame('alice@test.com', $item->email);
|
||||||
|
self::assertSame('authorized', $item->imageRightsStatus);
|
||||||
|
self::assertSame('Autorise', $item->imageRightsStatusLabel);
|
||||||
|
self::assertSame('6eme A', $item->className);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedStudentsAndParent(): void
|
#[Test]
|
||||||
|
public function paginatesResults(): void
|
||||||
{
|
{
|
||||||
$student1 = User::inviter(
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
email: new Email('alice@example.com'),
|
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
|
||||||
role: Role::ELEVE,
|
);
|
||||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
||||||
schoolName: 'École Alpha',
|
$result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1', page: 2, limit: 10));
|
||||||
|
|
||||||
|
self::assertSame(50, $result->total);
|
||||||
|
self::assertSame(2, $result->page);
|
||||||
|
self::assertSame(10, $result->limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function cachesResult(): void
|
||||||
|
{
|
||||||
|
$dto = $this->createStudentImageRightsDto();
|
||||||
|
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
$query = new GetStudentsImageRightsQuery(tenantId: 'tenant-1');
|
||||||
|
($this->handler)($query);
|
||||||
|
$result = ($this->handler)($query);
|
||||||
|
|
||||||
|
self::assertCount(1, $result->items);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function clampsPageToMinimumOne(): void
|
||||||
|
{
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1', page: -5));
|
||||||
|
|
||||||
|
self::assertSame(1, $result->page);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function clampsLimitToMaximumHundred(): void
|
||||||
|
{
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1', limit: 500));
|
||||||
|
|
||||||
|
self::assertSame(100, $result->limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createStudentImageRightsDto(): StudentImageRightsDto
|
||||||
|
{
|
||||||
|
return new StudentImageRightsDto(
|
||||||
|
id: 'student-1',
|
||||||
firstName: 'Alice',
|
firstName: 'Alice',
|
||||||
lastName: 'Dupont',
|
lastName: 'Dupont',
|
||||||
invitedAt: new DateTimeImmutable('2026-01-15'),
|
email: 'alice@test.com',
|
||||||
dateNaissance: new DateTimeImmutable('2012-06-15'),
|
imageRightsStatus: 'authorized',
|
||||||
|
imageRightsStatusLabel: 'Autorise',
|
||||||
|
imageRightsUpdatedAt: new DateTimeImmutable('2026-02-01'),
|
||||||
|
className: '6eme A',
|
||||||
);
|
);
|
||||||
$student1->modifierDroitImage(
|
|
||||||
ImageRightsStatus::AUTHORIZED,
|
|
||||||
UserId::fromString('550e8400-e29b-41d4-a716-446655440099'),
|
|
||||||
new DateTimeImmutable('2026-02-01'),
|
|
||||||
);
|
|
||||||
|
|
||||||
$student2 = User::inviter(
|
|
||||||
email: new Email('bob@example.com'),
|
|
||||||
role: Role::ELEVE,
|
|
||||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
||||||
schoolName: 'École Alpha',
|
|
||||||
firstName: 'Bob',
|
|
||||||
lastName: 'Martin',
|
|
||||||
invitedAt: new DateTimeImmutable('2026-01-15'),
|
|
||||||
dateNaissance: new DateTimeImmutable('2013-03-20'),
|
|
||||||
);
|
|
||||||
// Bob has default NOT_SPECIFIED
|
|
||||||
|
|
||||||
$parent = User::inviter(
|
|
||||||
email: new Email('parent@example.com'),
|
|
||||||
role: Role::PARENT,
|
|
||||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
||||||
schoolName: 'École Alpha',
|
|
||||||
firstName: 'Pierre',
|
|
||||||
lastName: 'Dupont',
|
|
||||||
invitedAt: new DateTimeImmutable('2026-01-15'),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->userRepository->save($student1);
|
|
||||||
$this->userRepository->save($student2);
|
|
||||||
$this->userRepository->save($parent);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,206 +4,134 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Unit\Administration\Application\Query\GetSubjects;
|
namespace App\Tests\Unit\Administration\Application\Query\GetSubjects;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedSubjectsReader;
|
||||||
use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler;
|
use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler;
|
||||||
use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery;
|
use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery;
|
||||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
use App\Administration\Application\Query\GetSubjects\SubjectDto;
|
||||||
use App\Administration\Domain\Model\Subject\Subject;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
|
||||||
use App\Administration\Domain\Model\Subject\SubjectColor;
|
|
||||||
use App\Administration\Domain\Model\Subject\SubjectName;
|
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
|
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
final class GetSubjectsHandlerTest extends TestCase
|
final class GetSubjectsHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
private PaginatedSubjectsReader $reader;
|
||||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440020';
|
private PaginatedQueryCache $cache;
|
||||||
|
|
||||||
private InMemorySubjectRepository $subjectRepository;
|
|
||||||
private GetSubjectsHandler $handler;
|
private GetSubjectsHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->subjectRepository = new InMemorySubjectRepository();
|
$this->reader = $this->createMock(PaginatedSubjectsReader::class);
|
||||||
$this->handler = new GetSubjectsHandler($this->subjectRepository);
|
$this->cache = new PaginatedQueryCache(
|
||||||
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
|
);
|
||||||
|
$this->handler = new GetSubjectsHandler($this->reader, $this->cache);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function returnsAllActiveSubjectsForTenantAndSchool(): void
|
public function returnsItemsForTenant(): void
|
||||||
{
|
{
|
||||||
$this->seedSubjects();
|
$dto = $this->createSubjectDto();
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
$query = new GetSubjectsQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
schoolId: self::SCHOOL_ID,
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(3, $result->items);
|
$result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1'));
|
||||||
self::assertSame(3, $result->total);
|
|
||||||
self::assertSame(1, $result->page);
|
|
||||||
self::assertSame(30, $result->limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function filtersSubjectsByName(): void
|
|
||||||
{
|
|
||||||
$this->seedSubjects();
|
|
||||||
|
|
||||||
$query = new GetSubjectsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
schoolId: self::SCHOOL_ID,
|
|
||||||
search: 'Mathématiques',
|
|
||||||
);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
self::assertCount(1, $result->items);
|
||||||
self::assertSame('Mathématiques', $result->items[0]->name);
|
self::assertSame(1, $result->total);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function filtersSubjectsByCode(): void
|
public function mapsDtoFields(): void
|
||||||
{
|
{
|
||||||
$this->seedSubjects();
|
$dto = $this->createSubjectDto();
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
$query = new GetSubjectsQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
schoolId: self::SCHOOL_ID,
|
|
||||||
search: 'FR',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
$result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1'));
|
||||||
self::assertSame('FR', $result->items[0]->code);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
$item = $result->items[0];
|
||||||
public function searchIsCaseInsensitive(): void
|
self::assertSame('subject-1', $item->id);
|
||||||
{
|
self::assertSame('Mathematiques', $item->name);
|
||||||
$this->seedSubjects();
|
self::assertSame('MATH', $item->code);
|
||||||
|
self::assertSame('#3B82F6', $item->color);
|
||||||
$query = new GetSubjectsQuery(
|
self::assertSame('Maths avancees', $item->description);
|
||||||
tenantId: self::TENANT_ID,
|
self::assertSame('active', $item->status);
|
||||||
schoolId: self::SCHOOL_ID,
|
self::assertSame(2, $item->teacherCount);
|
||||||
search: 'math',
|
self::assertSame(1, $item->classCount);
|
||||||
);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function paginatesResults(): void
|
public function paginatesResults(): void
|
||||||
{
|
{
|
||||||
$this->seedSubjects();
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
|
||||||
$query = new GetSubjectsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
schoolId: self::SCHOOL_ID,
|
|
||||||
page: 1,
|
|
||||||
limit: 2,
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(2, $result->items);
|
$result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1', page: 2, limit: 10));
|
||||||
self::assertSame(3, $result->total);
|
|
||||||
self::assertSame(1, $result->page);
|
self::assertSame(50, $result->total);
|
||||||
self::assertSame(2, $result->limit);
|
self::assertSame(2, $result->page);
|
||||||
self::assertSame(2, $result->totalPages());
|
self::assertSame(10, $result->limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function returnsSecondPage(): void
|
public function cachesResult(): void
|
||||||
{
|
{
|
||||||
$this->seedSubjects();
|
$dto = $this->createSubjectDto();
|
||||||
|
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
|
||||||
$query = new GetSubjectsQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
schoolId: self::SCHOOL_ID,
|
|
||||||
page: 2,
|
|
||||||
limit: 2,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$query = new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1');
|
||||||
|
($this->handler)($query);
|
||||||
$result = ($this->handler)($query);
|
$result = ($this->handler)($query);
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
self::assertCount(1, $result->items);
|
||||||
self::assertSame(3, $result->total);
|
|
||||||
self::assertSame(2, $result->page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function returnsEmptyWhenNoMatches(): void
|
public function clampsPageToMinimumOne(): void
|
||||||
{
|
{
|
||||||
$this->seedSubjects();
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
|
||||||
$query = new GetSubjectsQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
schoolId: self::SCHOOL_ID,
|
|
||||||
search: 'nonexistent',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(0, $result->items);
|
$result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1', page: -5));
|
||||||
self::assertSame(0, $result->total);
|
|
||||||
|
self::assertSame(1, $result->page);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function clampsInvalidPageToOne(): void
|
public function clampsLimitToMaximumHundred(): void
|
||||||
{
|
{
|
||||||
$query = new GetSubjectsQuery(
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
tenantId: self::TENANT_ID,
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
|
||||||
schoolId: self::SCHOOL_ID,
|
|
||||||
page: -1,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(1, $query->page);
|
$result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1', limit: 500));
|
||||||
|
|
||||||
|
self::assertSame(100, $result->limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
private function createSubjectDto(): SubjectDto
|
||||||
public function clampsExcessiveLimitToMax(): void
|
|
||||||
{
|
{
|
||||||
$query = new GetSubjectsQuery(
|
return new SubjectDto(
|
||||||
tenantId: self::TENANT_ID,
|
id: 'subject-1',
|
||||||
schoolId: self::SCHOOL_ID,
|
name: 'Mathematiques',
|
||||||
limit: 999,
|
code: 'MATH',
|
||||||
|
color: '#3B82F6',
|
||||||
|
description: 'Maths avancees',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||||
|
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||||
|
teacherCount: 2,
|
||||||
|
classCount: 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(100, $query->limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function seedSubjects(): void
|
|
||||||
{
|
|
||||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
||||||
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
|
|
||||||
$now = new DateTimeImmutable('2026-02-01 10:00:00');
|
|
||||||
|
|
||||||
$this->subjectRepository->save(Subject::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolId: $schoolId,
|
|
||||||
name: new SubjectName('Mathématiques'),
|
|
||||||
code: new SubjectCode('MATH'),
|
|
||||||
color: new SubjectColor('#3B82F6'),
|
|
||||||
createdAt: $now,
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->subjectRepository->save(Subject::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolId: $schoolId,
|
|
||||||
name: new SubjectName('Français'),
|
|
||||||
code: new SubjectCode('FR'),
|
|
||||||
color: new SubjectColor('#EF4444'),
|
|
||||||
createdAt: $now,
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->subjectRepository->save(Subject::creer(
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolId: $schoolId,
|
|
||||||
name: new SubjectName('Histoire-Géo'),
|
|
||||||
code: new SubjectCode('HG'),
|
|
||||||
color: new SubjectColor('#10B981'),
|
|
||||||
createdAt: $now,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,337 +4,134 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Unit\Administration\Application\Query\GetUsers;
|
namespace App\Tests\Unit\Administration\Application\Query\GetUsers;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Port\PaginatedUsersReader;
|
||||||
use App\Administration\Application\Query\GetUsers\GetUsersHandler;
|
use App\Administration\Application\Query\GetUsers\GetUsersHandler;
|
||||||
use App\Administration\Application\Query\GetUsers\GetUsersQuery;
|
use App\Administration\Application\Query\GetUsers\GetUsersQuery;
|
||||||
use App\Administration\Domain\Model\User\Email;
|
use App\Administration\Application\Query\GetUsers\UserDto;
|
||||||
use App\Administration\Domain\Model\User\Role;
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
use App\Administration\Domain\Model\User\User;
|
|
||||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
|
||||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
|
||||||
use App\Shared\Domain\Clock;
|
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
final class GetUsersHandlerTest extends TestCase
|
final class GetUsersHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
private PaginatedUsersReader $reader;
|
||||||
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
|
private PaginatedQueryCache $cache;
|
||||||
|
|
||||||
private InMemoryUserRepository $userRepository;
|
|
||||||
private Clock $clock;
|
|
||||||
private GetUsersHandler $handler;
|
private GetUsersHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->userRepository = new InMemoryUserRepository();
|
$this->reader = $this->createMock(PaginatedUsersReader::class);
|
||||||
$this->clock = new class implements Clock {
|
$this->cache = new PaginatedQueryCache(
|
||||||
public function now(): DateTimeImmutable
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
{
|
|
||||||
return new DateTimeImmutable('2026-02-07 10:00:00');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
$this->handler = new GetUsersHandler($this->userRepository, $this->clock);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function returnsAllUsersForTenant(): void
|
|
||||||
{
|
|
||||||
$this->seedUsers();
|
|
||||||
|
|
||||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(3, $result->items);
|
|
||||||
self::assertSame(3, $result->total);
|
|
||||||
self::assertSame(1, $result->page);
|
|
||||||
self::assertSame(30, $result->limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function filtersUsersByRole(): void
|
|
||||||
{
|
|
||||||
$this->seedUsers();
|
|
||||||
|
|
||||||
$query = new GetUsersQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
role: Role::PROF->value,
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
$this->handler = new GetUsersHandler($this->reader, $this->cache);
|
||||||
|
|
||||||
self::assertCount(2, $result->items);
|
|
||||||
self::assertSame(2, $result->total);
|
|
||||||
foreach ($result->items as $dto) {
|
|
||||||
self::assertSame(Role::PROF->value, $dto->role);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function filtersUsersByStatut(): void
|
public function returnsUsersForTenant(): void
|
||||||
{
|
{
|
||||||
$this->seedUsers();
|
$dto = $this->createUserDto();
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
$query = new GetUsersQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
statut: 'pending',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(2, $result->items);
|
$result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1'));
|
||||||
self::assertSame(2, $result->total);
|
|
||||||
foreach ($result->items as $dto) {
|
|
||||||
self::assertSame('pending', $dto->statut);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function excludesUsersFromOtherTenants(): void
|
|
||||||
{
|
|
||||||
$this->seedUsers();
|
|
||||||
|
|
||||||
$otherUser = User::inviter(
|
|
||||||
email: new Email('other@example.com'),
|
|
||||||
role: Role::ADMIN,
|
|
||||||
tenantId: TenantId::fromString(self::OTHER_TENANT_ID),
|
|
||||||
schoolName: 'Autre École',
|
|
||||||
firstName: 'Autre',
|
|
||||||
lastName: 'User',
|
|
||||||
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
|
||||||
);
|
|
||||||
$this->userRepository->save($otherUser);
|
|
||||||
|
|
||||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(3, $result->items);
|
|
||||||
self::assertSame(3, $result->total);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function calculatesInvitationExpiree(): void
|
|
||||||
{
|
|
||||||
$user = User::inviter(
|
|
||||||
email: new Email('old@example.com'),
|
|
||||||
role: Role::PROF,
|
|
||||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
||||||
schoolName: 'École Alpha',
|
|
||||||
firstName: 'Old',
|
|
||||||
lastName: 'Invitation',
|
|
||||||
invitedAt: new DateTimeImmutable('2026-01-25 10:00:00'),
|
|
||||||
);
|
|
||||||
$this->userRepository->save($user);
|
|
||||||
|
|
||||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
self::assertCount(1, $result->items);
|
||||||
self::assertTrue($result->items[0]->invitationExpiree);
|
self::assertSame(1, $result->total);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function mapsDtoFields(): void
|
||||||
|
{
|
||||||
|
$dto = $this->createUserDto();
|
||||||
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1'));
|
||||||
|
|
||||||
|
$item = $result->items[0];
|
||||||
|
self::assertSame('user-1', $item->id);
|
||||||
|
self::assertSame('prof@test.com', $item->email);
|
||||||
|
self::assertSame('ROLE_PROF', $item->role);
|
||||||
|
self::assertSame('Dupont', $item->lastName);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function paginatesResults(): void
|
public function paginatesResults(): void
|
||||||
{
|
{
|
||||||
$this->seedUsers();
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
|
||||||
$query = new GetUsersQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
page: 1,
|
|
||||||
limit: 2,
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(2, $result->items);
|
$result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1', page: 2, limit: 10));
|
||||||
self::assertSame(3, $result->total);
|
|
||||||
self::assertSame(1, $result->page);
|
|
||||||
self::assertSame(2, $result->limit);
|
|
||||||
self::assertSame(2, $result->totalPages());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
self::assertSame(50, $result->total);
|
||||||
public function returnsSecondPage(): void
|
|
||||||
{
|
|
||||||
$this->seedUsers();
|
|
||||||
|
|
||||||
$query = new GetUsersQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
page: 2,
|
|
||||||
limit: 2,
|
|
||||||
);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
|
||||||
self::assertSame(3, $result->total);
|
|
||||||
self::assertSame(2, $result->page);
|
self::assertSame(2, $result->page);
|
||||||
|
self::assertSame(10, $result->limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function searchesByFirstName(): void
|
public function cachesResult(): void
|
||||||
{
|
{
|
||||||
$this->seedUsers();
|
$dto = $this->createUserDto();
|
||||||
|
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
|
||||||
$query = new GetUsersQuery(
|
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
search: 'Jean',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$query = new GetUsersQuery(tenantId: 'tenant-1');
|
||||||
|
($this->handler)($query);
|
||||||
$result = ($this->handler)($query);
|
$result = ($this->handler)($query);
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
self::assertCount(1, $result->items);
|
||||||
self::assertSame('Jean', $result->items[0]->firstName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function searchesByLastName(): void
|
public function clampsPageToMinimumOne(): void
|
||||||
{
|
{
|
||||||
$this->seedUsers();
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
|
||||||
$query = new GetUsersQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
search: 'Martin',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
$result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1', page: -5));
|
||||||
self::assertSame('Martin', $result->items[0]->lastName);
|
|
||||||
|
self::assertSame(1, $result->page);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function searchesByEmail(): void
|
public function clampsLimitToMaximumHundred(): void
|
||||||
{
|
{
|
||||||
$this->seedUsers();
|
$this->reader->method('findPaginated')->willReturn(
|
||||||
|
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
|
||||||
$query = new GetUsersQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
search: 'parent@',
|
|
||||||
);
|
);
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
$result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1', limit: 500));
|
||||||
self::assertSame('parent@example.com', $result->items[0]->email);
|
|
||||||
|
self::assertSame(100, $result->limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
private function createUserDto(): UserDto
|
||||||
public function searchIsCaseInsensitive(): void
|
|
||||||
{
|
{
|
||||||
$this->seedUsers();
|
return new UserDto(
|
||||||
|
id: 'user-1',
|
||||||
$query = new GetUsersQuery(
|
email: 'prof@test.com',
|
||||||
tenantId: self::TENANT_ID,
|
role: 'ROLE_PROF',
|
||||||
search: 'jean',
|
roleLabel: 'Enseignant',
|
||||||
);
|
roles: ['ROLE_PROF'],
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
|
||||||
self::assertSame('Jean', $result->items[0]->firstName);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function searchCombinesWithRoleFilter(): void
|
|
||||||
{
|
|
||||||
$this->seedUsers();
|
|
||||||
|
|
||||||
$query = new GetUsersQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
role: Role::PROF->value,
|
|
||||||
search: 'Jean',
|
|
||||||
);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(1, $result->items);
|
|
||||||
self::assertSame('Jean', $result->items[0]->firstName);
|
|
||||||
self::assertSame(Role::PROF->value, $result->items[0]->role);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function searchResetsCountCorrectly(): void
|
|
||||||
{
|
|
||||||
$this->seedUsers();
|
|
||||||
|
|
||||||
$query = new GetUsersQuery(
|
|
||||||
tenantId: self::TENANT_ID,
|
|
||||||
search: 'nonexistent',
|
|
||||||
);
|
|
||||||
$result = ($this->handler)($query);
|
|
||||||
|
|
||||||
self::assertCount(0, $result->items);
|
|
||||||
self::assertSame(0, $result->total);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function clampsPageZeroToOne(): void
|
|
||||||
{
|
|
||||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, page: 0);
|
|
||||||
self::assertSame(1, $query->page);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function clampsNegativePageToOne(): void
|
|
||||||
{
|
|
||||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, page: -5);
|
|
||||||
self::assertSame(1, $query->page);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function clampsLimitZeroToOne(): void
|
|
||||||
{
|
|
||||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: 0);
|
|
||||||
self::assertSame(1, $query->limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function clampsExcessiveLimitToHundred(): void
|
|
||||||
{
|
|
||||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: 999);
|
|
||||||
self::assertSame(100, $query->limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function clampsNegativeLimitToOne(): void
|
|
||||||
{
|
|
||||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: -10);
|
|
||||||
self::assertSame(1, $query->limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function seedUsers(): void
|
|
||||||
{
|
|
||||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
|
||||||
|
|
||||||
$teacher1 = User::inviter(
|
|
||||||
email: new Email('teacher1@example.com'),
|
|
||||||
role: Role::PROF,
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolName: 'École Alpha',
|
|
||||||
firstName: 'Jean',
|
firstName: 'Jean',
|
||||||
lastName: 'Dupont',
|
lastName: 'Dupont',
|
||||||
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
statut: 'actif',
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||||
|
invitedAt: new DateTimeImmutable('2026-01-10'),
|
||||||
|
activatedAt: new DateTimeImmutable('2026-01-12'),
|
||||||
|
blockedAt: null,
|
||||||
|
blockedReason: null,
|
||||||
|
invitationExpiree: false,
|
||||||
);
|
);
|
||||||
$this->userRepository->save($teacher1);
|
|
||||||
|
|
||||||
$teacher2 = User::inviter(
|
|
||||||
email: new Email('teacher2@example.com'),
|
|
||||||
role: Role::PROF,
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolName: 'École Alpha',
|
|
||||||
firstName: 'Marie',
|
|
||||||
lastName: 'Martin',
|
|
||||||
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
|
||||||
);
|
|
||||||
$teacher2->activer(
|
|
||||||
'$argon2id$hashed',
|
|
||||||
new DateTimeImmutable('2026-02-02 10:00:00'),
|
|
||||||
new ConsentementParentalPolicy($this->clock),
|
|
||||||
);
|
|
||||||
$this->userRepository->save($teacher2);
|
|
||||||
|
|
||||||
$parent = User::inviter(
|
|
||||||
email: new Email('parent@example.com'),
|
|
||||||
role: Role::PARENT,
|
|
||||||
tenantId: $tenantId,
|
|
||||||
schoolName: 'École Alpha',
|
|
||||||
firstName: 'Pierre',
|
|
||||||
lastName: 'Parent',
|
|
||||||
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
|
||||||
);
|
|
||||||
$this->userRepository->save($parent);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Service\Cache;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
|
use App\Administration\Domain\Event\AffectationRetiree;
|
||||||
|
use App\Administration\Domain\Event\ClasseCreee;
|
||||||
|
use App\Administration\Domain\Event\DroitImageModifie;
|
||||||
|
use App\Administration\Domain\Event\EleveInscrit;
|
||||||
|
use App\Administration\Domain\Event\EnseignantAffecte;
|
||||||
|
use App\Administration\Domain\Event\ImportElevesTermine;
|
||||||
|
use App\Administration\Domain\Event\ImportEnseignantsTermine;
|
||||||
|
use App\Administration\Domain\Event\InvitationParentActivee;
|
||||||
|
use App\Administration\Domain\Event\InvitationRenvoyee;
|
||||||
|
use App\Administration\Domain\Event\MatiereCreee;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||||
|
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||||
|
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||||
|
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
|
||||||
|
use App\Administration\Domain\Model\User\ImageRightsStatus;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Infrastructure\EventHandler\PaginatedQueryCacheInvalidator;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
|
final class PaginatedQueryCacheInvalidatorTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private PaginatedQueryCache $cache;
|
||||||
|
private PaginatedQueryCacheInvalidator $invalidator;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->cache = new PaginatedQueryCache(
|
||||||
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
|
);
|
||||||
|
$this->invalidator = new PaginatedQueryCacheInvalidator($this->cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Users ===
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function utilisateurInviteInvalidatesUsersCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
|
||||||
|
$this->invalidator->onUtilisateurInvite($this->createUtilisateurInvite(self::TENANT_ID));
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invitationRenvoyeeInvalidatesUsersCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new InvitationRenvoyee(
|
||||||
|
userId: UserId::generate(),
|
||||||
|
email: 'test@example.com',
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invalidator->onInvitationRenvoyee($event);
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function eleveInscritInvalidatesUsersAndImageRightsCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
$this->warmCache('students_image_rights', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new EleveInscrit(
|
||||||
|
userId: UserId::generate(),
|
||||||
|
firstName: 'Alice',
|
||||||
|
lastName: 'Martin',
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invalidator->onEleveInscrit($event);
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
$this->assertCacheWasInvalidated('students_image_rights', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Classes ===
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function classeCreeeInvalidatesClassesAndAssignmentsCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('classes', self::TENANT_ID);
|
||||||
|
$this->warmCache('assignments', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new ClasseCreee(
|
||||||
|
classId: ClassId::generate(),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
name: new ClassName('6ème A'),
|
||||||
|
level: null,
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invalidator->onClasseCreee($event);
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('classes', self::TENANT_ID);
|
||||||
|
$this->assertCacheWasInvalidated('assignments', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Subjects ===
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function matiereCreeeInvalidatesSubjectsAndAssignmentsCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('subjects', self::TENANT_ID);
|
||||||
|
$this->warmCache('assignments', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new MatiereCreee(
|
||||||
|
subjectId: SubjectId::generate(),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
name: new SubjectName('Mathématiques'),
|
||||||
|
code: new SubjectCode('MATH'),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invalidator->onMatiereCreee($event);
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('subjects', self::TENANT_ID);
|
||||||
|
$this->assertCacheWasInvalidated('assignments', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Assignments ===
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function enseignantAffecteInvalidatesAssignmentsCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('assignments', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new EnseignantAffecte(
|
||||||
|
assignmentId: TeacherAssignmentId::generate(),
|
||||||
|
teacherId: UserId::generate(),
|
||||||
|
classId: ClassId::generate(),
|
||||||
|
subjectId: SubjectId::generate(),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invalidator->onEnseignantAffecte($event);
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('assignments', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function affectationRetireeInvalidatesAssignmentsCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('assignments', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new AffectationRetiree(
|
||||||
|
assignmentId: TeacherAssignmentId::generate(),
|
||||||
|
teacherId: UserId::generate(),
|
||||||
|
classId: ClassId::generate(),
|
||||||
|
subjectId: SubjectId::generate(),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invalidator->onAffectationRetiree($event);
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('assignments', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Parent invitations ===
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invitationParentActiveeInvalidatesParentInvitationsAndUsersCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('parent_invitations', self::TENANT_ID);
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new InvitationParentActivee(
|
||||||
|
invitationId: ParentInvitationId::generate(),
|
||||||
|
studentId: UserId::generate(),
|
||||||
|
parentUserId: UserId::generate(),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invalidator->onInvitationParentActivee($event);
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('parent_invitations', self::TENANT_ID);
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Image rights ===
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function droitImageModifieInvalidatesImageRightsCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('students_image_rights', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new DroitImageModifie(
|
||||||
|
userId: UserId::generate(),
|
||||||
|
email: 'alice@example.com',
|
||||||
|
ancienStatut: ImageRightsStatus::NOT_SPECIFIED,
|
||||||
|
nouveauStatut: ImageRightsStatus::AUTHORIZED,
|
||||||
|
modifiePar: UserId::generate(),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invalidator->onDroitImageModifie($event);
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('students_image_rights', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Imports ===
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function importElevesTermineInvalidatesUsersImageRightsAndClassesCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
$this->warmCache('students_image_rights', self::TENANT_ID);
|
||||||
|
$this->warmCache('classes', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new ImportElevesTermine(
|
||||||
|
batchId: ImportBatchId::generate(),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
importedCount: 10,
|
||||||
|
errorCount: 0,
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invalidator->onImportElevesTermine($event);
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
$this->assertCacheWasInvalidated('students_image_rights', self::TENANT_ID);
|
||||||
|
$this->assertCacheWasInvalidated('classes', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function importEnseignantsTermineInvalidatesUsersAndAssignmentsCache(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
$this->warmCache('assignments', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new ImportEnseignantsTermine(
|
||||||
|
batchId: ImportBatchId::generate(),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
importedCount: 5,
|
||||||
|
errorCount: 0,
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invalidator->onImportEnseignantsTermine($event);
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
$this->assertCacheWasInvalidated('assignments', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tenant isolation ===
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invalidationDoesNotAffectOtherTenants(): void
|
||||||
|
{
|
||||||
|
$otherTenantId = '550e8400-e29b-41d4-a716-446655440099';
|
||||||
|
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
$this->warmCache('users', $otherTenantId);
|
||||||
|
|
||||||
|
$this->invalidator->onUtilisateurInvite($this->createUtilisateurInvite(self::TENANT_ID));
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
$this->assertCacheStillValid('users', $otherTenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invalidationDoesNotAffectOtherEntityTypes(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
$this->warmCache('classes', self::TENANT_ID);
|
||||||
|
|
||||||
|
$this->invalidator->onUtilisateurInvite($this->createUtilisateurInvite(self::TENANT_ID));
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
$this->assertCacheStillValid('classes', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function warmCache(string $entityType, string $tenantId): void
|
||||||
|
{
|
||||||
|
$this->cache->getOrLoad(
|
||||||
|
$entityType,
|
||||||
|
$tenantId,
|
||||||
|
['page' => 1, 'limit' => 30],
|
||||||
|
static fn (): PaginatedResult => new PaginatedResult(items: ['original'], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCacheWasInvalidated(string $entityType, string $tenantId): void
|
||||||
|
{
|
||||||
|
$result = $this->cache->getOrLoad(
|
||||||
|
$entityType,
|
||||||
|
$tenantId,
|
||||||
|
['page' => 1, 'limit' => 30],
|
||||||
|
static fn (): PaginatedResult => new PaginatedResult(items: ['fresh'], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['fresh'], $result->items, "Cache for {$entityType}/{$tenantId} should have been invalidated");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCacheStillValid(string $entityType, string $tenantId): void
|
||||||
|
{
|
||||||
|
$result = $this->cache->getOrLoad(
|
||||||
|
$entityType,
|
||||||
|
$tenantId,
|
||||||
|
['page' => 1, 'limit' => 30],
|
||||||
|
static fn (): PaginatedResult => new PaginatedResult(items: ['fresh'], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['original'], $result->items, "Cache for {$entityType}/{$tenantId} should still contain original data");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUtilisateurInvite(string $tenantId): UtilisateurInvite
|
||||||
|
{
|
||||||
|
return new UtilisateurInvite(
|
||||||
|
userId: UserId::generate(),
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'ROLE_PROF',
|
||||||
|
firstName: 'Jean',
|
||||||
|
lastName: 'Dupont',
|
||||||
|
tenantId: TenantId::fromString($tenantId),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Service\Cache;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
|
||||||
|
final class PaginatedQueryCacheTest extends TestCase
|
||||||
|
{
|
||||||
|
private PaginatedQueryCache $cache;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->cache = new PaginatedQueryCache(
|
||||||
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function loadsFromCallableOnCacheMiss(): void
|
||||||
|
{
|
||||||
|
$expected = new PaginatedResult(items: ['item1'], total: 1, page: 1, limit: 30);
|
||||||
|
$callCount = 0;
|
||||||
|
|
||||||
|
$result = $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], static function () use ($expected, &$callCount): PaginatedResult {
|
||||||
|
++$callCount;
|
||||||
|
|
||||||
|
return $expected;
|
||||||
|
});
|
||||||
|
|
||||||
|
self::assertSame($expected, $result);
|
||||||
|
self::assertSame(1, $callCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function returnsCachedResultOnHit(): void
|
||||||
|
{
|
||||||
|
$expected = new PaginatedResult(items: ['item1'], total: 1, page: 1, limit: 30);
|
||||||
|
$callCount = 0;
|
||||||
|
|
||||||
|
$loader = static function () use ($expected, &$callCount): PaginatedResult {
|
||||||
|
++$callCount;
|
||||||
|
|
||||||
|
return $expected;
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader);
|
||||||
|
$result = $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader);
|
||||||
|
|
||||||
|
self::assertSame(1, $callCount);
|
||||||
|
self::assertEquals($expected, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function differentParamsProduceDifferentCacheEntries(): void
|
||||||
|
{
|
||||||
|
$result1 = new PaginatedResult(items: ['page1'], total: 2, page: 1, limit: 1);
|
||||||
|
$result2 = new PaginatedResult(items: ['page2'], total: 2, page: 2, limit: 1);
|
||||||
|
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], static fn (): PaginatedResult => $result1);
|
||||||
|
$actual = $this->cache->getOrLoad('users', 'tenant-1', ['page' => 2], static fn (): PaginatedResult => $result2);
|
||||||
|
|
||||||
|
self::assertEquals($result2, $actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invalidatesCacheByEntityTypeAndTenant(): void
|
||||||
|
{
|
||||||
|
$callCount = 0;
|
||||||
|
$loader = static function () use (&$callCount): PaginatedResult {
|
||||||
|
++$callCount;
|
||||||
|
|
||||||
|
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader);
|
||||||
|
self::assertSame(1, $callCount);
|
||||||
|
|
||||||
|
$this->cache->invalidate('users', 'tenant-1');
|
||||||
|
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader);
|
||||||
|
self::assertSame(2, $callCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invalidationDoesNotAffectOtherEntityTypes(): void
|
||||||
|
{
|
||||||
|
$usersCallCount = 0;
|
||||||
|
$classesCallCount = 0;
|
||||||
|
|
||||||
|
$usersLoader = static function () use (&$usersCallCount): PaginatedResult {
|
||||||
|
++$usersCallCount;
|
||||||
|
|
||||||
|
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
$classesLoader = static function () use (&$classesCallCount): PaginatedResult {
|
||||||
|
++$classesCallCount;
|
||||||
|
|
||||||
|
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $usersLoader);
|
||||||
|
$this->cache->getOrLoad('classes', 'tenant-1', ['page' => 1], $classesLoader);
|
||||||
|
|
||||||
|
$this->cache->invalidate('users', 'tenant-1');
|
||||||
|
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $usersLoader);
|
||||||
|
$this->cache->getOrLoad('classes', 'tenant-1', ['page' => 1], $classesLoader);
|
||||||
|
|
||||||
|
self::assertSame(2, $usersCallCount);
|
||||||
|
self::assertSame(1, $classesCallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invalidationDoesNotAffectOtherTenants(): void
|
||||||
|
{
|
||||||
|
$tenant1Count = 0;
|
||||||
|
$tenant2Count = 0;
|
||||||
|
|
||||||
|
$tenant1Loader = static function () use (&$tenant1Count): PaginatedResult {
|
||||||
|
++$tenant1Count;
|
||||||
|
|
||||||
|
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
$tenant2Loader = static function () use (&$tenant2Count): PaginatedResult {
|
||||||
|
++$tenant2Count;
|
||||||
|
|
||||||
|
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $tenant1Loader);
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-2', ['page' => 1], $tenant2Loader);
|
||||||
|
|
||||||
|
$this->cache->invalidate('users', 'tenant-1');
|
||||||
|
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $tenant1Loader);
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-2', ['page' => 1], $tenant2Loader);
|
||||||
|
|
||||||
|
self::assertSame(2, $tenant1Count);
|
||||||
|
self::assertSame(1, $tenant2Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function paramOrderDoesNotAffectCacheKey(): void
|
||||||
|
{
|
||||||
|
$callCount = 0;
|
||||||
|
$loader = static function () use (&$callCount): PaginatedResult {
|
||||||
|
++$callCount;
|
||||||
|
|
||||||
|
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1, 'role' => 'admin'], $loader);
|
||||||
|
$this->cache->getOrLoad('users', 'tenant-1', ['role' => 'admin', 'page' => 1], $loader);
|
||||||
|
|
||||||
|
self::assertSame(1, $callCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Infrastructure\Middleware;
|
||||||
|
|
||||||
|
use App\Administration\Application\Dto\PaginatedResult;
|
||||||
|
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
|
||||||
|
use App\Administration\Domain\Event\CompteActive;
|
||||||
|
use App\Administration\Domain\Event\InvitationParentActivee;
|
||||||
|
use App\Administration\Domain\Event\InvitationParentEnvoyee;
|
||||||
|
use App\Administration\Domain\Event\InvitationRenvoyee;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurBloque;
|
||||||
|
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||||
|
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Infrastructure\Middleware\PaginatedCacheInvalidationMiddleware;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||||
|
use Symfony\Component\Messenger\Stamp\ReceivedStamp;
|
||||||
|
|
||||||
|
final class PaginatedCacheInvalidationMiddlewareTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private PaginatedQueryCache $cache;
|
||||||
|
private PaginatedCacheInvalidationMiddleware $middleware;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->cache = new PaginatedQueryCache(
|
||||||
|
new TagAwareAdapter(new ArrayAdapter()),
|
||||||
|
);
|
||||||
|
$this->middleware = new PaginatedCacheInvalidationMiddleware($this->cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invalidatesUsersCacheOnUtilisateurInvite(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new UtilisateurInvite(
|
||||||
|
userId: UserId::generate(),
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'ROLE_PROF',
|
||||||
|
firstName: 'Jean',
|
||||||
|
lastName: 'Dupont',
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->middleware->handle(new Envelope($event), $this->createPassthroughStack());
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invalidatesUsersCacheOnCompteActive(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new CompteActive(
|
||||||
|
userId: Uuid::uuid4()->toString(),
|
||||||
|
email: 'test@example.com',
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
role: 'ROLE_PROF',
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
aggregateId: Uuid::uuid4(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->middleware->handle(new Envelope($event), $this->createPassthroughStack());
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invalidatesUsersCacheOnInvitationRenvoyee(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new InvitationRenvoyee(
|
||||||
|
userId: UserId::generate(),
|
||||||
|
email: 'test@example.com',
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->middleware->handle(new Envelope($event), $this->createPassthroughStack());
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invalidatesParentInvitationsCacheOnInvitationParentEnvoyee(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('parent_invitations', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new InvitationParentEnvoyee(
|
||||||
|
invitationId: ParentInvitationId::generate(),
|
||||||
|
studentId: UserId::generate(),
|
||||||
|
parentEmail: new Email('parent@example.com'),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->middleware->handle(new Envelope($event), $this->createPassthroughStack());
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('parent_invitations', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function invalidatesParentInvitationsAndUsersCacheOnInvitationParentActivee(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('parent_invitations', self::TENANT_ID);
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new InvitationParentActivee(
|
||||||
|
invitationId: ParentInvitationId::generate(),
|
||||||
|
studentId: UserId::generate(),
|
||||||
|
parentUserId: UserId::generate(),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->middleware->handle(new Envelope($event), $this->createPassthroughStack());
|
||||||
|
|
||||||
|
$this->assertCacheWasInvalidated('parent_invitations', self::TENANT_ID);
|
||||||
|
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function doesNotInvalidateOnReceivedStamp(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new UtilisateurInvite(
|
||||||
|
userId: UserId::generate(),
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'ROLE_PROF',
|
||||||
|
firstName: 'Jean',
|
||||||
|
lastName: 'Dupont',
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$envelope = new Envelope($event, [new ReceivedStamp('async')]);
|
||||||
|
$this->middleware->handle($envelope, $this->createPassthroughStack());
|
||||||
|
|
||||||
|
$this->assertCacheStillValid('users', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function doesNotInvalidateOnUnrelatedEvent(): void
|
||||||
|
{
|
||||||
|
$this->warmCache('users', self::TENANT_ID);
|
||||||
|
|
||||||
|
$event = new UtilisateurBloque(
|
||||||
|
userId: UserId::generate(),
|
||||||
|
email: 'test@example.com',
|
||||||
|
reason: 'test',
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
occurredOn: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->middleware->handle(new Envelope($event), $this->createPassthroughStack());
|
||||||
|
|
||||||
|
$this->assertCacheStillValid('users', self::TENANT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function warmCache(string $entityType, string $tenantId): void
|
||||||
|
{
|
||||||
|
$this->cache->getOrLoad(
|
||||||
|
$entityType,
|
||||||
|
$tenantId,
|
||||||
|
['page' => 1, 'limit' => 30],
|
||||||
|
static fn (): PaginatedResult => new PaginatedResult(items: ['original'], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCacheWasInvalidated(string $entityType, string $tenantId): void
|
||||||
|
{
|
||||||
|
$result = $this->cache->getOrLoad(
|
||||||
|
$entityType,
|
||||||
|
$tenantId,
|
||||||
|
['page' => 1, 'limit' => 30],
|
||||||
|
static fn (): PaginatedResult => new PaginatedResult(items: ['fresh'], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['fresh'], $result->items, "Cache for {$entityType}/{$tenantId} should have been invalidated");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCacheStillValid(string $entityType, string $tenantId): void
|
||||||
|
{
|
||||||
|
$result = $this->cache->getOrLoad(
|
||||||
|
$entityType,
|
||||||
|
$tenantId,
|
||||||
|
['page' => 1, 'limit' => 30],
|
||||||
|
static fn (): PaginatedResult => new PaginatedResult(items: ['fresh'], total: 1, page: 1, limit: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['original'], $result->items, "Cache for {$entityType}/{$tenantId} should still contain original data");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createPassthroughStack(): StackInterface
|
||||||
|
{
|
||||||
|
$stack = $this->createMock(StackInterface::class);
|
||||||
|
$nextMiddleware = $this->createMock(\Symfony\Component\Messenger\Middleware\MiddlewareInterface::class);
|
||||||
|
|
||||||
|
$stack->method('next')->willReturn($nextMiddleware);
|
||||||
|
$nextMiddleware->method('handle')->willReturnCallback(
|
||||||
|
static fn (Envelope $envelope): Envelope => $envelope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $stack;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { getApiBaseUrl } from '$lib/api/config';
|
import { getApiBaseUrl } from '$lib/api/config';
|
||||||
import { authenticatedFetch } from '$lib/auth';
|
import { authenticatedFetch } from '$lib/auth';
|
||||||
|
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface StudentImageRights {
|
interface StudentImageRights {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,32 +24,27 @@
|
|||||||
{ value: 'not_specified', label: 'Non renseigné' }
|
{ value: 'not_specified', label: 'Non renseigné' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const itemsPerPage = 30;
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let students = $state<StudentImageRights[]>([]);
|
let students = $state<StudentImageRights[]>([]);
|
||||||
let isLoading = $state(true);
|
let isLoading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let successMessage = $state<string | null>(null);
|
let successMessage = $state<string | null>(null);
|
||||||
|
let totalItems = $state(0);
|
||||||
|
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
let filterStatus = $state<string>(page.url.searchParams.get('status') ?? '');
|
let filterStatus = $state<string>(page.url.searchParams.get('status') ?? '');
|
||||||
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
|
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
|
||||||
|
|
||||||
// Derived groups
|
// Derived
|
||||||
let filteredStudents = $derived.by(() => {
|
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
|
||||||
if (!searchTerm) return students;
|
|
||||||
const term = searchTerm.toLowerCase();
|
|
||||||
return students.filter(
|
|
||||||
(s) =>
|
|
||||||
s.firstName.toLowerCase().includes(term) ||
|
|
||||||
s.lastName.toLowerCase().includes(term) ||
|
|
||||||
s.email.toLowerCase().includes(term)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
let authorizedStudents = $derived(
|
let authorizedStudents = $derived(
|
||||||
filteredStudents.filter((s) => s.imageRightsStatus === 'authorized')
|
students.filter((s) => s.imageRightsStatus === 'authorized')
|
||||||
);
|
);
|
||||||
let unauthorizedStudents = $derived(
|
let unauthorizedStudents = $derived(
|
||||||
filteredStudents.filter((s) => s.imageRightsStatus !== 'authorized')
|
students.filter((s) => s.imageRightsStatus !== 'authorized')
|
||||||
);
|
);
|
||||||
|
|
||||||
// Updating state
|
// Updating state
|
||||||
@@ -57,8 +53,8 @@
|
|||||||
|
|
||||||
let loadAbortController: AbortController | null = null;
|
let loadAbortController: AbortController | null = null;
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
untrack(() => loadStudents());
|
loadStudents();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadStudents() {
|
async function loadStudents() {
|
||||||
@@ -71,23 +67,28 @@
|
|||||||
error = null;
|
error = null;
|
||||||
const apiUrl = getApiBaseUrl();
|
const apiUrl = getApiBaseUrl();
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', String(currentPage));
|
||||||
|
params.set('itemsPerPage', String(itemsPerPage));
|
||||||
if (filterStatus) params.set('status', filterStatus);
|
if (filterStatus) params.set('status', filterStatus);
|
||||||
|
if (searchTerm) params.set('search', searchTerm);
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
const url = `${apiUrl}/students/image-rights${query ? `?${query}` : ''}`;
|
const url = `${apiUrl}/students/image-rights?${query}`;
|
||||||
const response = await authenticatedFetch(url, { signal: controller.signal });
|
const response = await authenticatedFetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Erreur lors du chargement des droits à l\'image');
|
throw new Error("Erreur lors du chargement des droits à l'image");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
students = Array.isArray(data) ? data : data['member'] ?? data['hydra:member'] ?? [];
|
students = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
||||||
|
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? students.length;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
students = [];
|
students = [];
|
||||||
|
totalItems = 0;
|
||||||
} finally {
|
} finally {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
|
|
||||||
function updateUrl() {
|
function updateUrl() {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
if (currentPage > 1) params.set('page', String(currentPage));
|
||||||
if (filterStatus) params.set('status', filterStatus);
|
if (filterStatus) params.set('status', filterStatus);
|
||||||
if (searchTerm) params.set('search', searchTerm);
|
if (searchTerm) params.set('search', searchTerm);
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
@@ -105,10 +107,19 @@
|
|||||||
|
|
||||||
function handleSearch(value: string) {
|
function handleSearch(value: string) {
|
||||||
searchTerm = value;
|
searchTerm = value;
|
||||||
|
currentPage = 1;
|
||||||
updateUrl();
|
updateUrl();
|
||||||
|
loadStudents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(newPage: number) {
|
||||||
|
currentPage = newPage;
|
||||||
|
updateUrl();
|
||||||
|
loadStudents();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
|
currentPage = 1;
|
||||||
updateUrl();
|
updateUrl();
|
||||||
loadStudents();
|
loadStudents();
|
||||||
}
|
}
|
||||||
@@ -116,6 +127,7 @@
|
|||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
filterStatus = '';
|
filterStatus = '';
|
||||||
searchTerm = '';
|
searchTerm = '';
|
||||||
|
currentPage = 1;
|
||||||
updateUrl();
|
updateUrl();
|
||||||
loadStudents();
|
loadStudents();
|
||||||
}
|
}
|
||||||
@@ -158,7 +170,7 @@
|
|||||||
: s
|
: s
|
||||||
);
|
);
|
||||||
successMessage = 'Statut mis à jour avec succès.';
|
successMessage = 'Statut mis à jour avec succès.';
|
||||||
setTimeout(() => (successMessage = null), 3000);
|
window.setTimeout(() => (successMessage = null), 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Erreur lors de la mise à jour';
|
error = e instanceof Error ? e.message : 'Erreur lors de la mise à jour';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -178,7 +190,7 @@
|
|||||||
const response = await authenticatedFetch(url);
|
const response = await authenticatedFetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Erreur lors de l\'export');
|
throw new Error("Erreur lors de l'export");
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
@@ -192,9 +204,9 @@
|
|||||||
URL.revokeObjectURL(downloadUrl);
|
URL.revokeObjectURL(downloadUrl);
|
||||||
|
|
||||||
successMessage = 'Export CSV téléchargé.';
|
successMessage = 'Export CSV téléchargé.';
|
||||||
setTimeout(() => (successMessage = null), 3000);
|
window.setTimeout(() => (successMessage = null), 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Erreur lors de l\'export';
|
error = e instanceof Error ? e.message : "Erreur lors de l'export";
|
||||||
} finally {
|
} finally {
|
||||||
isExporting = false;
|
isExporting = false;
|
||||||
}
|
}
|
||||||
@@ -264,14 +276,14 @@
|
|||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Chargement...</p>
|
<p>Chargement...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if students.length === 0}
|
{:else if students.length === 0 && !searchTerm && !filterStatus}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">📷</div>
|
<div class="empty-icon">📷</div>
|
||||||
<h2>Aucun élève inscrit</h2>
|
<h2>Aucun élève inscrit</h2>
|
||||||
<p>Commencez par créer des comptes élèves pour pouvoir gérer leurs autorisations de droit à l'image.</p>
|
<p>Commencez par créer des comptes élèves pour pouvoir gérer leurs autorisations de droit à l'image.</p>
|
||||||
<a class="btn-primary" href="/admin/users">Gérer les utilisateurs</a>
|
<a class="btn-primary" href="/admin/users">Gérer les utilisateurs</a>
|
||||||
</div>
|
</div>
|
||||||
{:else if filteredStudents.length === 0}
|
{:else if students.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">🔍</div>
|
<div class="empty-icon">🔍</div>
|
||||||
<h2>Aucun résultat</h2>
|
<h2>Aucun résultat</h2>
|
||||||
@@ -281,19 +293,13 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="stats-bar">
|
<div class="stats-bar">
|
||||||
<span class="stat">
|
<span class="stat">
|
||||||
<span class="stat-count">{authorizedStudents.length}</span> autorisé{authorizedStudents.length > 1 ? 's' : ''}
|
<span class="stat-count stat-total">{totalItems}</span> élève{totalItems > 1 ? 's' : ''} au total
|
||||||
</span>
|
|
||||||
<span class="stat">
|
|
||||||
<span class="stat-count stat-danger">{unauthorizedStudents.length}</span> non autorisé{unauthorizedStudents.length > 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
<span class="stat">
|
|
||||||
<span class="stat-count stat-total">{filteredStudents.length}</span> total
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if authorizedStudents.length > 0}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Élèves autorisés ({authorizedStudents.length})</h2>
|
<h2>Élèves autorisés ({authorizedStudents.length})</h2>
|
||||||
{#if authorizedStudents.length > 0}
|
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -332,14 +338,12 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<p class="empty-section">Aucun élève autorisé.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if unauthorizedStudents.length > 0}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Élèves non autorisés ({unauthorizedStudents.length})</h2>
|
<h2>Élèves non autorisés ({unauthorizedStudents.length})</h2>
|
||||||
{#if unauthorizedStudents.length > 0}
|
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -378,11 +382,11 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<p class="empty-section">Tous les élèves sont autorisés.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -539,11 +543,6 @@
|
|||||||
|
|
||||||
.stat-count {
|
.stat-count {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-count.stat-danger {
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-count.stat-total {
|
.stat-count.stat-total {
|
||||||
@@ -679,12 +678,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-section {
|
|
||||||
padding: 1rem;
|
|
||||||
color: var(--text-secondary, #999);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.page-header {
|
.page-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user