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:
2026-03-01 14:33:56 +01:00
parent ce05207c64
commit 23dd7177f2
41 changed files with 2854 additions and 1584 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -5,27 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetAllAssignments;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
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 App\Administration\Application\Port\PaginatedAssignmentsReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAllAssignmentsHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private LoggerInterface $logger,
private PaginatedAssignmentsReader $reader,
private PaginatedQueryCache $cache,
) {
}
@@ -34,101 +23,29 @@ final readonly class GetAllAssignmentsHandler
*/
public function __invoke(GetAllAssignmentsQuery $query): PaginatedResult
{
$tenantId = TenantId::fromString($query->tenantId);
$assignments = $this->assignmentRepository->findAllActiveByTenant($tenantId);
// Build lookup maps for users, classes, and subjects
$users = $this->userRepository->findAllByTenant($tenantId);
/** @var array<string, array{firstName: string, lastName: string}> $userNames */
$userNames = [];
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,
limit: $query->limit,
/* @var PaginatedResult<AssignmentWithNamesDto> */
return $this->cache->getOrLoad(
'assignments',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
);
}
/**
* @return array<string, mixed>
*/
private function cacheParams(GetAllAssignmentsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'search' => $query->search,
];
}
}

View File

@@ -5,20 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetClasses;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use App\Administration\Application\Port\PaginatedClassesReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetClassesHandler
{
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
{
$classes = $this->classRepository->findActiveByTenantAndYear(
TenantId::fromString($query->tenantId),
AcademicYearId::fromString($query->academicYearId),
);
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$classes = array_filter(
$classes,
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,
/* @var PaginatedResult<ClassDto> */
return $this->cache->getOrLoad(
'classes',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
academicYearId: $query->academicYearId,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
total: $total,
page: $query->page,
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,
];
}
}

View File

@@ -5,29 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentInvitations;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\Invitation\InvitationStatus;
use App\Administration\Domain\Model\User\UserId;
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 App\Administration\Application\Port\PaginatedParentInvitationsReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetParentInvitationsHandler
{
public function __construct(
private ParentInvitationRepository $invitationRepository,
private UserRepository $userRepository,
private PaginatedParentInvitationsReader $reader,
private PaginatedQueryCache $cache,
) {
}
@@ -36,97 +23,33 @@ final readonly class GetParentInvitationsHandler
*/
public function __invoke(GetParentInvitationsQuery $query): PaginatedResult
{
$tenantId = TenantId::fromString($query->tenantId);
$invitations = $this->invitationRepository->findAllByTenant($tenantId);
if ($query->status !== null) {
$filterStatus = InvitationStatus::tryFrom($query->status);
if ($filterStatus !== null) {
$invitations = array_filter(
$invitations,
static fn ($inv) => $inv->status === $filterStatus,
);
}
}
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,
/* @var PaginatedResult<ParentInvitationDto> */
return $this->cache->getOrLoad(
'parent_invitations',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
status: $query->status,
studentId: $query->studentId,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
/**
* @param iterable<\App\Administration\Domain\Model\Invitation\ParentInvitation> $invitations
*
* @return array<string, array{firstName: string, lastName: string}>
* @return array<string, mixed>
*/
private function loadStudentNames(iterable $invitations): array
private function cacheParams(GetParentInvitationsQuery $query): array
{
$studentIds = [];
foreach ($invitations as $inv) {
$studentIds[(string) $inv->studentId] = true;
}
$names = [];
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;
return [
'page' => $query->page,
'limit' => $query->limit,
'status' => $query->status,
'student_id' => $query->studentId,
'search' => $query->search,
];
}
}

View File

@@ -4,41 +4,50 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsImageRights;
use App\Administration\Domain\Model\User\ImageRightsStatus;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetStudentsImageRightsHandler
{
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(
TenantId::fromString($query->tenantId),
/* @var PaginatedResult<StudentImageRightsDto> */
return $this->cache->getOrLoad(
'students_image_rights',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
status: $query->status,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
);
}
if ($query->status !== null) {
$filterStatus = ImageRightsStatus::tryFrom($query->status);
if ($filterStatus !== null) {
$students = array_filter(
$students,
static fn ($user) => $user->imageRightsStatus === $filterStatus,
);
}
}
return array_values(array_map(
static fn ($user) => StudentImageRightsDto::fromDomain($user),
$students,
));
/**
* @return array<string, mixed>
*/
private function cacheParams(GetStudentsImageRightsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'status' => $query->status,
'search' => $query->search,
];
}
}

View File

@@ -4,11 +4,23 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsImageRights;
use App\Administration\Application\Dto\PaginatedResult;
final readonly class GetStudentsImageRightsQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
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;
}
}

View File

@@ -5,20 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetSubjects;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use App\Administration\Application\Port\PaginatedSubjectsReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetSubjectsHandler
{
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
{
$subjects = $this->subjectRepository->findActiveByTenantAndSchool(
TenantId::fromString($query->tenantId),
SchoolId::fromString($query->schoolId),
);
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$subjects = array_filter(
$subjects,
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,
/* @var PaginatedResult<SubjectDto> */
return $this->cache->getOrLoad(
'subjects',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
schoolId: $query->schoolId,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
total: $total,
page: $query->page,
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,
];
}
}

View File

@@ -5,23 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetUsers;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
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 App\Administration\Application\Port\PaginatedUsersReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetUsersHandler
{
public function __construct(
private UserRepository $userRepository,
private Clock $clock,
private PaginatedUsersReader $reader,
private PaginatedQueryCache $cache,
) {
}
@@ -30,54 +23,33 @@ final readonly class GetUsersHandler
*/
public function __invoke(GetUsersQuery $query): PaginatedResult
{
$users = $this->userRepository->findAllByTenant(
TenantId::fromString($query->tenantId),
);
if ($query->role !== null) {
$filterRole = Role::tryFrom($query->role);
if ($filterRole !== null) {
$users = array_filter(
$users,
static fn ($user) => $user->aLeRole($filterRole),
);
}
}
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,
/* @var PaginatedResult<UserDto> */
return $this->cache->getOrLoad(
'users',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
role: $query->role,
statut: $query->statut,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
total: $total,
page: $query->page,
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,
];
}
}

View File

@@ -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)));
}
}

View File

@@ -9,6 +9,7 @@ use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
@@ -20,6 +21,7 @@ final readonly class AffectationRetiree implements DomainEvent
public UserId $teacherId,
public ClassId $classId,
public SubjectId $subjectId,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}

View File

@@ -9,6 +9,7 @@ use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
@@ -20,6 +21,7 @@ final readonly class EnseignantAffecte implements DomainEvent
public UserId $teacherId,
public ClassId $classId,
public SubjectId $subjectId,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}

View File

@@ -68,6 +68,7 @@ final class TeacherAssignment extends AggregateRoot
teacherId: $assignment->teacherId,
classId: $assignment->classId,
subjectId: $assignment->subjectId,
tenantId: $assignment->tenantId,
occurredOn: $createdAt,
));
@@ -93,6 +94,7 @@ final class TeacherAssignment extends AggregateRoot
teacherId: $this->teacherId,
classId: $this->classId,
subjectId: $this->subjectId,
tenantId: $this->tenantId,
occurredOn: $at,
));
}
@@ -119,6 +121,7 @@ final class TeacherAssignment extends AggregateRoot
teacherId: $this->teacherId,
classId: $this->classId,
subjectId: $this->subjectId,
tenantId: $this->tenantId,
occurredOn: $at,
));
}

View File

@@ -5,12 +5,17 @@ declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsHandler;
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery;
use App\Administration\Infrastructure\Api\Resource\ImageRightsResource;
use App\Administration\Infrastructure\Security\ImageRightsVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
@@ -28,11 +33,8 @@ final readonly class ImageRightsCollectionProvider implements ProviderInterface
) {
}
/**
* @return ImageRightsResource[]
*/
#[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)) {
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.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetStudentsImageRightsQuery(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
tenantId: $tenantId,
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(
static fn ($dto) => ImageRightsResource::fromDto($dto),
$dtos,
$resources = array_map(ImageRightsResource::fromDto(...), $result->items);
return new TraversablePaginator(
new ArrayIterator($resources),
$result->page,
$result->limit,
$result->total,
);
}
}

View File

@@ -6,8 +6,7 @@ namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsHandler;
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery;
use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
use App\Administration\Application\Service\ImageRightsExporter;
use App\Administration\Infrastructure\Security\ImageRightsVoter;
use App\Shared\Application\Port\AuditLogger;
@@ -27,7 +26,7 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final readonly class ImageRightsExportProvider implements ProviderInterface
{
public function __construct(
private GetStudentsImageRightsHandler $handler,
private PaginatedStudentImageRightsReader $reader,
private ImageRightsExporter $exporter,
private AuthorizationCheckerInterface $authorizationChecker,
private TenantContext $tenantContext,
@@ -46,15 +45,14 @@ final readonly class ImageRightsExportProvider implements ProviderInterface
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$query = new GetStudentsImageRightsQuery(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
status: isset($filters['status']) ? (string) $filters['status'] : null,
$dtos = $this->reader->findAll(
$tenantId,
isset($filters['status']) ? (string) $filters['status'] : null,
);
$dtos = ($this->handler)($query);
$csv = $this->exporter->export($dtos);
$this->auditLogger->logExport(

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}