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

@@ -3,8 +3,9 @@
import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { untrack } from 'svelte';
import { onMount } from 'svelte';
interface StudentImageRights {
id: string;
@@ -23,32 +24,27 @@
{ value: 'not_specified', label: 'Non renseigné' }
];
const itemsPerPage = 30;
// State
let students = $state<StudentImageRights[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let totalItems = $state(0);
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
// Filters
let filterStatus = $state<string>(page.url.searchParams.get('status') ?? '');
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
// Derived groups
let filteredStudents = $derived.by(() => {
if (!searchTerm) return students;
const term = searchTerm.toLowerCase();
return students.filter(
(s) =>
s.firstName.toLowerCase().includes(term) ||
s.lastName.toLowerCase().includes(term) ||
s.email.toLowerCase().includes(term)
);
});
// Derived
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
let authorizedStudents = $derived(
filteredStudents.filter((s) => s.imageRightsStatus === 'authorized')
students.filter((s) => s.imageRightsStatus === 'authorized')
);
let unauthorizedStudents = $derived(
filteredStudents.filter((s) => s.imageRightsStatus !== 'authorized')
students.filter((s) => s.imageRightsStatus !== 'authorized')
);
// Updating state
@@ -57,8 +53,8 @@
let loadAbortController: AbortController | null = null;
$effect(() => {
untrack(() => loadStudents());
onMount(() => {
loadStudents();
});
async function loadStudents() {
@@ -71,23 +67,28 @@
error = null;
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage));
if (filterStatus) params.set('status', filterStatus);
if (searchTerm) params.set('search', searchTerm);
const query = params.toString();
const url = `${apiUrl}/students/image-rights${query ? `?${query}` : ''}`;
const url = `${apiUrl}/students/image-rights?${query}`;
const response = await authenticatedFetch(url, { signal: controller.signal });
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error('Erreur lors du chargement des droits à l\'image');
throw new Error("Erreur lors du chargement des droits à l'image");
}
const data = await response.json();
students = Array.isArray(data) ? data : data['member'] ?? data['hydra:member'] ?? [];
students = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? students.length;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : 'Erreur inconnue';
students = [];
totalItems = 0;
} finally {
if (!controller.signal.aborted) {
isLoading = false;
@@ -97,6 +98,7 @@
function updateUrl() {
const params = new URLSearchParams();
if (currentPage > 1) params.set('page', String(currentPage));
if (filterStatus) params.set('status', filterStatus);
if (searchTerm) params.set('search', searchTerm);
const query = params.toString();
@@ -105,10 +107,19 @@
function handleSearch(value: string) {
searchTerm = value;
currentPage = 1;
updateUrl();
loadStudents();
}
function handlePageChange(newPage: number) {
currentPage = newPage;
updateUrl();
loadStudents();
}
function applyFilters() {
currentPage = 1;
updateUrl();
loadStudents();
}
@@ -116,6 +127,7 @@
function resetFilters() {
filterStatus = '';
searchTerm = '';
currentPage = 1;
updateUrl();
loadStudents();
}
@@ -158,7 +170,7 @@
: s
);
successMessage = 'Statut mis à jour avec succès.';
setTimeout(() => (successMessage = null), 3000);
window.setTimeout(() => (successMessage = null), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la mise à jour';
} finally {
@@ -178,7 +190,7 @@
const response = await authenticatedFetch(url);
if (!response.ok) {
throw new Error('Erreur lors de l\'export');
throw new Error("Erreur lors de l'export");
}
const blob = await response.blob();
@@ -192,9 +204,9 @@
URL.revokeObjectURL(downloadUrl);
successMessage = 'Export CSV téléchargé.';
setTimeout(() => (successMessage = null), 3000);
window.setTimeout(() => (successMessage = null), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de l\'export';
error = e instanceof Error ? e.message : "Erreur lors de l'export";
} finally {
isExporting = false;
}
@@ -264,14 +276,14 @@
<div class="spinner"></div>
<p>Chargement...</p>
</div>
{:else if students.length === 0}
{:else if students.length === 0 && !searchTerm && !filterStatus}
<div class="empty-state">
<div class="empty-icon">📷</div>
<h2>Aucun élève inscrit</h2>
<p>Commencez par créer des comptes élèves pour pouvoir gérer leurs autorisations de droit à l'image.</p>
<a class="btn-primary" href="/admin/users">Gérer les utilisateurs</a>
</div>
{:else if filteredStudents.length === 0}
{:else if students.length === 0}
<div class="empty-state">
<div class="empty-icon">🔍</div>
<h2>Aucun résultat</h2>
@@ -281,19 +293,13 @@
{:else}
<div class="stats-bar">
<span class="stat">
<span class="stat-count">{authorizedStudents.length}</span> autorisé{authorizedStudents.length > 1 ? 's' : ''}
</span>
<span class="stat">
<span class="stat-count stat-danger">{unauthorizedStudents.length}</span> non autorisé{unauthorizedStudents.length > 1 ? 's' : ''}
</span>
<span class="stat">
<span class="stat-count stat-total">{filteredStudents.length}</span> total
<span class="stat-count stat-total">{totalItems}</span> élève{totalItems > 1 ? 's' : ''} au total
</span>
</div>
<div class="section">
<h2>Élèves autorisés ({authorizedStudents.length})</h2>
{#if authorizedStudents.length > 0}
{#if authorizedStudents.length > 0}
<div class="section">
<h2>Élèves autorisés ({authorizedStudents.length})</h2>
<div class="table-wrapper">
<table>
<thead>
@@ -332,14 +338,12 @@
</tbody>
</table>
</div>
{:else}
<p class="empty-section">Aucun élève autorisé.</p>
{/if}
</div>
</div>
{/if}
<div class="section">
<h2>Élèves non autorisés ({unauthorizedStudents.length})</h2>
{#if unauthorizedStudents.length > 0}
{#if unauthorizedStudents.length > 0}
<div class="section">
<h2>Élèves non autorisés ({unauthorizedStudents.length})</h2>
<div class="table-wrapper">
<table>
<thead>
@@ -378,10 +382,10 @@
</tbody>
</table>
</div>
{:else}
<p class="empty-section">Tous les élèves sont autorisés.</p>
{/if}
</div>
</div>
{/if}
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
</div>
@@ -539,11 +543,6 @@
.stat-count {
font-weight: 700;
color: #166534;
}
.stat-count.stat-danger {
color: #991b1b;
}
.stat-count.stat-total {
@@ -679,12 +678,6 @@
}
}
.empty-section {
padding: 1rem;
color: var(--text-secondary, #999);
font-style: italic;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;