From 4d3879156da0267d4430ddbcac7577cf8e157243 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 15 Jun 2026 11:07:59 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(pagination)=20:=20=C3=A9viter=20la=20tr?= =?UTF-8?q?oncature=20silencieuse=20des=20collections=20pagin=C3=A9es=20(L?= =?UTF-8?q?ST-52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Platform pagine par défaut à 30 éléments/page et le helper front extractHydraMembers ne lit que la première page (il ignore hydra:view.next), ce qui tronque silencieusement toute liste de plus de 30 éléments. - Back : paginationEnabled false sur les GetCollection consommées en entier et à volume borné/modéré (Client, Project, User, TaskTag, TaskGroup, TaskStatus, TaskPriority, TaskEffort, Workflow). - Front : nouveau helper fetchAllHydra() qui parcourt toutes les pages ; utilisé pour /notifications (volume non borné, reste paginé côté back). - Doc : règle anti-troncature ajoutée au CLAUDE.md. Déjà protégés (vérifiés) : Task, TimeEntry, TaskDocument, TaskRecurrence, AbsenceRequest/Policy/Balance (paginationEnabled false) et /time_entries/range. --- CLAUDE.md | 1 + frontend/services/notifications.ts | 9 ++++--- frontend/utils/api.ts | 43 ++++++++++++++++++++++++++++++ src/Entity/Client.php | 2 +- src/Entity/Project.php | 2 +- src/Entity/TaskEffort.php | 2 +- src/Entity/TaskGroup.php | 2 +- src/Entity/TaskPriority.php | 2 +- src/Entity/TaskStatus.php | 2 +- src/Entity/TaskTag.php | 2 +- src/Entity/User.php | 1 + src/Entity/Workflow.php | 2 +- 12 files changed, 59 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d00220a..5e7f8d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` - Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`) - 4 espaces d'indentation - MalioSelect : options `{ label: string, value: string | number | null }` — accepte les valeurs **string** (enums string OK, ex `category`/`StatusCategory`), pas seulement `number` (vérifié dans la source `Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis). Largeur via `group-class` (pas de prop `minWidth`/`min-width`). ⚠️ Le `COMPONENTS.md` de la lib est inexact sur ce composant (il indique une clé `text` et une prop `minWidth` inexistantes) : la clé d'affichage réelle est `label`. Ne jamais modifier la lib `malio-layer-ui` depuis ce projet. +- **Pagination API Platform & `extractHydraMembers` (piège de troncature)** : API Platform pagine par défaut à **30 éléments/page**. Le helper `extractHydraMembers()` (`frontend/utils/api.ts`) ne lit **que la première page** (il ignore `hydra:view.next`) → toute liste > 30 éléments est tronquée **silencieusement** (bug LST-51/LST-52). Règle : toute collection consommée via `extractHydraMembers` doit **soit** être servie par une ressource non paginée (`paginationEnabled: false` sur le `GetCollection`, quand le volume est borné/modéré et qu'on veut tout afficher — c'est le cas des référentiels et de Client/Project/User/Task/TimeEntry), **soit** gérer explicitement la pagination via le helper `fetchAllHydra()` (suit toutes les pages, à réserver aux volumes non bornés comme `/notifications`), **soit** passer par une route dédiée bornée (ex `/time_entries/range`). Ne **jamais** lire une seule page d'une collection potentiellement > 30 éléments. ### Composants UI diff --git a/frontend/services/notifications.ts b/frontend/services/notifications.ts index 1417259..c284a7e 100644 --- a/frontend/services/notifications.ts +++ b/frontend/services/notifications.ts @@ -1,13 +1,16 @@ import type { Notification } from './dto/notification' import type { HydraCollection } from '~/utils/api' -import { extractHydraMembers } from '~/utils/api' +import { fetchAllHydra } from '~/utils/api' export function useNotificationService() { const api = useApi() async function getAll(): Promise { - const data = await api.get>('/notifications') - return extractHydraMembers(data) + // La ressource /notifications reste paginée côté back (volume non borné) : + // on suit toutes les pages pour ne pas tronquer la liste à 30 éléments. + return fetchAllHydra(page => + api.get>('/notifications', { page }), + ) } async function markAsRead(id: number): Promise { diff --git a/frontend/utils/api.ts b/frontend/utils/api.ts index 00c033b..6e673ec 100644 --- a/frontend/utils/api.ts +++ b/frontend/utils/api.ts @@ -8,3 +8,46 @@ export type HydraCollection = { export function extractHydraMembers(response: HydraCollection): T[] { return response['hydra:member'] ?? response['member'] ?? [] } + +function extractHydraTotal(response: HydraCollection): number | undefined { + return response['hydra:totalItems'] ?? response['totalItems'] +} + +/** + * Récupère TOUS les éléments d'une collection Hydra paginée en parcourant les pages. + * + * `extractHydraMembers` ne lit que la première page (30 éléments par défaut côté + * API Platform) : toute liste plus longue est tronquée silencieusement. Utiliser + * ce helper dès qu'une collection potentiellement > 30 éléments doit être + * affichée en entier alors que sa ressource back reste paginée. + * + * `fetchPage` reçoit le numéro de page (1-indexé) et doit renvoyer la collection + * Hydra correspondante (passer `page` en query param de l'appel API). + * + * @param maxPages garde-fou anti-boucle infinie (par défaut 1000 pages). + */ +export async function fetchAllHydra( + fetchPage: (page: number) => Promise>, + maxPages = 1000, +): Promise { + const first = await fetchPage(1) + const all = extractHydraMembers(first) + const total = extractHydraTotal(first) + + if (total === undefined) { + return all + } + + let page = 2 + while (all.length < total && page <= maxPages) { + const next = await fetchPage(page) + const members = extractHydraMembers(next) + if (members.length === 0) { + break + } + all.push(...members) + page += 1 + } + + return all +} diff --git a/src/Entity/Client.php b/src/Entity/Client.php index 1a2e6b7..dd782b2 100644 --- a/src/Entity/Client.php +++ b/src/Entity/Client.php @@ -18,7 +18,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 5d58c84..6b5267b 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -25,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post( security: "is_granted('ROLE_ADMIN')", diff --git a/src/Entity/TaskEffort.php b/src/Entity/TaskEffort.php index a656725..04ce397 100644 --- a/src/Entity/TaskEffort.php +++ b/src/Entity/TaskEffort.php @@ -16,7 +16,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskGroup.php b/src/Entity/TaskGroup.php index 753c9d1..8a5ecf8 100644 --- a/src/Entity/TaskGroup.php +++ b/src/Entity/TaskGroup.php @@ -19,7 +19,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskPriority.php b/src/Entity/TaskPriority.php index 48da256..c960977 100644 --- a/src/Entity/TaskPriority.php +++ b/src/Entity/TaskPriority.php @@ -16,7 +16,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskStatus.php b/src/Entity/TaskStatus.php index aa69787..021afdc 100644 --- a/src/Entity/TaskStatus.php +++ b/src/Entity/TaskStatus.php @@ -18,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskTag.php b/src/Entity/TaskTag.php index 537db3a..4f3a864 100644 --- a/src/Entity/TaskTag.php +++ b/src/Entity/TaskTag.php @@ -16,7 +16,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/User.php b/src/Entity/User.php index fd75f39..2bc49c9 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -33,6 +33,7 @@ use Symfony\Component\Serializer\Attribute\Groups; normalizationContext: ['groups' => ['user:list']], ), new GetCollection( + paginationEnabled: false, normalizationContext: ['groups' => ['user:list']], ), new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), diff --git a/src/Entity/Workflow.php b/src/Entity/Workflow.php index 80db9b3..1b3ffff 100644 --- a/src/Entity/Workflow.php +++ b/src/Entity/Workflow.php @@ -21,7 +21,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), From df0fec0272501e3fe73dba880de406c3ebe4c7a8 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 15 Jun 2026 11:21:05 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(notifications)=20:=20pagination=20r?= =?UTF-8?q?=C3=A9elle=20c=C3=B4t=C3=A9=20provider=20pour=20ne=20plus=20tro?= =?UTF-8?q?nquer=20=C3=A0=2030=20(LST-52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationProvider retournait findBy(..., 30) : limite codée en dur, paramètre page ignoré et tableau brut (pas un Paginator). hydra:totalItems valait donc 30 → fetchAllHydra s'arrêtait à la 1re page et les notifications restaient tronquées à 30 malgré le correctif front. - NotificationProvider : vraie pagination Doctrine (Pagination + DoctrinePaginator + TraversablePaginator), totalItems réel et hydra:view.next exposés - NotificationRepository : createUserNotificationsQueryBuilder (filtre user + tri) - fetchAllHydra : ne retronque plus silencieusement quand hydra:totalItems est absent, pagine jusqu'à une page non pleine --- frontend/utils/api.ts | 18 +++++++++++++-- src/Repository/NotificationRepository.php | 11 +++++++++ src/State/NotificationProvider.php | 28 +++++++++++++++++++---- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/frontend/utils/api.ts b/frontend/utils/api.ts index 6e673ec..4384a23 100644 --- a/frontend/utils/api.ts +++ b/frontend/utils/api.ts @@ -33,13 +33,27 @@ export async function fetchAllHydra( const first = await fetchPage(1) const all = extractHydraMembers(first) const total = extractHydraTotal(first) + const pageSize = all.length - if (total === undefined) { + // 1ʳᵉ page vide → collection vide, rien de plus à récupérer. + if (pageSize === 0) { return all } let page = 2 - while (all.length < total && page <= maxPages) { + while (page <= maxPages) { + if (total !== undefined) { + // Total connu : on s'arrête dès qu'on a tout récupéré. + if (all.length >= total) { + break + } + } else if (all.length % pageSize !== 0) { + // Total inconnu (provider custom sans `hydra:totalItems`) : la dernière + // page récupérée n'était pas pleine → fin de collection. On ne s'arrête + // pas en silence sur la 1ʳᵉ page, contrairement à `extractHydraMembers`. + break + } + const next = await fetchPage(page) const members = extractHydraMembers(next) if (members.length === 0) { diff --git a/src/Repository/NotificationRepository.php b/src/Repository/NotificationRepository.php index 76d642d..7dba9a8 100644 --- a/src/Repository/NotificationRepository.php +++ b/src/Repository/NotificationRepository.php @@ -7,7 +7,9 @@ namespace App\Repository; use App\Entity\Notification; use App\Entity\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Security\Core\User\UserInterface; /** * @extends ServiceEntityRepository @@ -19,6 +21,15 @@ class NotificationRepository extends ServiceEntityRepository parent::__construct($registry, Notification::class); } + public function createUserNotificationsQueryBuilder(UserInterface $user): QueryBuilder + { + return $this->createQueryBuilder('n') + ->where('n.user = :user') + ->setParameter('user', $user) + ->orderBy('n.createdAt', 'DESC') + ; + } + public function countUnreadByUser(User $user): int { return (int) $this->createQueryBuilder('n') diff --git a/src/State/NotificationProvider.php b/src/State/NotificationProvider.php index 0a1b8b9..ff872c9 100644 --- a/src/State/NotificationProvider.php +++ b/src/State/NotificationProvider.php @@ -5,11 +5,17 @@ declare(strict_types=1); namespace App\State; use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\Pagination; +use ApiPlatform\State\Pagination\TraversablePaginator; use ApiPlatform\State\ProviderInterface; use App\Entity\Notification; use App\Repository\NotificationRepository; +use ArrayIterator; +use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; use Symfony\Bundle\SecurityBundle\Security; +use function count; + /** * @implements ProviderInterface */ @@ -18,16 +24,28 @@ final readonly class NotificationProvider implements ProviderInterface public function __construct( private Security $security, private NotificationRepository $notificationRepository, + private Pagination $pagination, ) {} - public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object { $user = $this->security->getUser(); - return $this->notificationRepository->findBy( - ['user' => $user], - ['createdAt' => 'DESC'], - 30, + [$page, $offset, $limit] = $this->pagination->getPagination($operation, $context); + + $queryBuilder = $this->notificationRepository + ->createUserNotificationsQueryBuilder($user) + ->setFirstResult($offset) + ->setMaxResults($limit) + ; + + $doctrinePaginator = new DoctrinePaginator($queryBuilder); + + return new TraversablePaginator( + new ArrayIterator(iterator_to_array($doctrinePaginator, false)), + $page, + $limit, + (float) count($doctrinePaginator), ); } } From 7d87af6774a3cb182944f0f3b7bcc1e4b6306330 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Mon, 15 Jun 2026 09:24:14 +0000 Subject: [PATCH 3/3] chore: bump version to v0.4.28 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index b2f1ac6..e9a4b3e 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.4.27' + app.version: '0.4.28'