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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user