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:
@@ -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;
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\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,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user