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.
177 lines
5.5 KiB
PHP
177 lines
5.5 KiB
PHP
<?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,
|
|
);
|
|
}
|
|
}
|