# Contrat API générique de listes (EA-020)

Toute liste paginée de l'API répond aux mêmes paramètres :

```
?search=&sort[]=&filters[]=&page=&per_page=
```

Un seul endroit décide comment une liste se cherche, se trie, se filtre et se pagine :
`App\Support\Listing\ListQuery`, piloté par un `App\Support\Listing\ListSchema` déclaré
dans le contrôleur. Avant EA-020, six écrans recopiaient leur propre tri et aucun n'avait
de filtre.

**Endpoint pilote** : `GET /api/{role}/user` (`Admin\UserController@index`).

**Endpoints raccordés** : `GET /api/{role}/user` · `GET /api/invite-list` (EA-014, #23).

---

## Les paramètres

### `search`

Chaîne libre, appliquée en `OR` sur les colonnes déclarées `searchable`.

```
GET /api/admin/user?search=duflot
```

- `%` et `_` saisis par l'utilisateur sont traités comme des caractères, pas comme des
  jokers (`ESCAPE '!'` — voir la note « Pièges » plus bas).
- La recherche est toujours **groupée** : elle ne relâche jamais les conditions de
  périmètre posées en amont par le contrôleur (visibilité par rôle).

### `sort[]`

Liste ordonnée de clés de tri. Trois notations acceptées :

| Notation | Exemple | Sens |
|---|---|---|
| Clé nue | `sort[]=email` | ascendant |
| Préfixe `-` | `sort[]=-created_at` | descendant |
| Suffixe `:` | `sort[]=email:desc` | descendant |

Le tri multiple est appliqué dans l'ordre reçu : `?sort[]=role&sort[]=-created_at`.

**Forme héritée, toujours servie** : `sort[key]=email&sort[value]=desc`. Le front l'envoie
encore ; elle disparaîtra quand tous les écrans seront passés à la DataTable (EA-019).

Une clé non déclarée `sortable` est **ignorée** ; si aucune clé valide ne subsiste, le tri
par défaut du schéma s'applique.

### `filters[]`

| Notation | Exemple |
|---|---|
| Clé/valeur | `filters[role]=manager` |
| Valeurs multiples (tableau) | `filters[role][]=manager&filters[role][]=admin` |
| Valeurs multiples (virgules) | `filters[role]=manager,admin` |
| Forme liste | `filters[]=role:manager` |

Une clé unique produit une égalité, plusieurs valeurs produisent un `IN`. Une clé non
déclarée `filterable` est **ignorée**.

### `page` et `per_page`

`per_page` est **borné par le schéma** (défaut 20, min 5, max 100 sur l'endpoint
utilisateurs). Une valeur hors bornes est ramenée dans l'intervalle, jamais refusée : un
client qui demande 5 000 lignes reçoit 100 lignes, pas une erreur.

Les paramètres courants sont conservés dans les liens de pagination (`withQueryString`).

| Endpoint | défaut | min | max |
|---|---|---|---|
| `GET /api/{role}/user` | 20 | 5 | 100 |
| `GET /api/invite-list` | 10 | 5 | 100 |

**Le défaut ne bouge jamais au raccordement** d'un endpoint : un appelant qui n'envoie pas
`per_page` doit recevoir exactement ce qu'il recevait avant. Changer un défaut, c'est
changer l'affichage de tous les écrans qui consomment la liste, sans que personne l'ait
demandé.

---

## Totaux hors pagination (EA-014)

Un écran qui **regroupe** ses lignes (« 11 invitations pour 2 personnes ») ne peut pas
compter sur la page : dès la deuxième, le décompte serait faux — et il se lirait comme un
total. Le serveur sert donc le total lui-même, **à côté** des clés du paginateur :

```json
{ "data": [ … ], "total": 11, "per_page": 10,
  "totals": { "invitations": 11, "people": 2 } }
```

`totals` est calculé sur **périmètre + recherche + filtres**, sans pagination.

```php
$personnes = (clone $query)->distinct()->count(DB::raw('LOWER(invitation_user.email)'));
```

**`LOWER()` et non `email`** : MySQL rapproche `Sophie@…` de `sophie@…`, SQLite non. Sans
lui, la même personne compte pour deux — et seulement en test.

**Pas besoin de `reorder()` avant l'agrégat**, contrairement à ce qu'on pourrait craindre :
`Builder::setAggregate()` supprime lui-même les `orders` quand la requête n'a pas de
`GROUP BY`. Vérifié sur MySQL 9.6 avec `only_full_group_by` actif — le SQL émis est
identique avec et sans, `select count(distinct LOWER(…)) from …` sans `ORDER BY`. Un
`reorder()` « de précaution » ici serait un no-op que le prochain lecteur croirait porteur.

---

## Filtrer sur un état DÉRIVÉ, pas sur une colonne

`GET /api/invite-list` filtre sur deux états qui n'existent dans aucune colonne :

| Clé | Valeurs | Dérivé de |
|---|---|---|
| `filters[statut]` | `En attente` · `Acceptée` · `Expirée` | `enrolled`, `accepted_at`, `expires_at` |
| `filters[compte]` | `Inexistant` · `En attente` · `Existante` | existence et `is_active` du compte portant l'adresse |

**Règle** : quand un endpoint filtre sur un état dérivé, il doit **servir cet état** dans
la ligne. Sinon l'écran le re-dérive de son côté, et les deux dérivations divergent — on
filtre sur « Expirée » et la colonne affiche « En attente » sur la même ligne. `statut` et
`user_status` sont donc servis, et la colonne s'aligne dessus au lieu de recalculer.

**Corollaire** : les deux écritures de la règle (PHP pour l'affichage, SQL pour le filtre)
vivent **côte à côte** dans le modèle — `InvitationUser::statut()` / `scopeStatuts()`,
`statutCompte()` / `scopeComptes()` — et un test verrouille leur accord : pour chaque
valeur, le filtre sélectionne exactement les lignes qui portent ce statut.

Une **valeur** inconnue est ignorée comme l'est une clé inconnue : le filtre ne pose alors
aucune condition, il ne renvoie pas d'erreur.

---

## Raccorder un nouvel endpoint

```php
use App\Support\Listing\ListQuery;
use App\Support\Listing\ListSchema;

private function listSchema(): ListSchema
{
    return ListSchema::make()
        ->searchable(['courses.title', 'courses.description'])
        ->sortable(['id' => 'courses.id', 'title' => 'courses.title'])
        ->sortableUsing('auteur', fn (Builder $q, string $dir) => $q->join(...)->orderBy(...))
        ->filterable(['is_published' => 'courses.is_published'])
        ->filterableUsing('tag', fn (Builder $q, array $values) => $q->whereHas('tags', fn ($t) => $t->whereIn('name', $values)))
        ->defaultSort('id', 'desc')
        ->perPage(20, 5, 100);
}

public function index(Request $request): JsonResponse
{
    $query = Course::query();          // périmètre par rôle posé ici, en dur
    $courses = ListQuery::paginate($query, $request, $this->listSchema());
    // …
}
```

**Règle de sécurité du schéma** : ce qui n'est pas déclaré n'est pas atteignable. Un
`sort[]=password` ou un `filters[remember_token]=…` n'arrive jamais jusqu'à la base. C'est
la raison pour laquelle le schéma est explicite plutôt que déduit des colonnes de la table.

**Le périmètre par rôle ne passe pas par le schéma.** Il reste posé par le contrôleur sur
la requête avant `ListQuery` : un filtre est un confort d'affichage, un périmètre est une
règle d'accès — les deux ne doivent pas se mélanger.

---

## Export XLSX (EA-022)

Tout endpoint au contrat devient exportable en déclarant ses colonnes :

```php
->exportColumn('first_name', 'Prénom')
->exportColumn('role', 'Rôle', fn (User $u) => $u->getRoleNames()->first())
->exportColumn('temps_passe', 'Temps passé', $resolver, ['admin', 'manager', 'manager_rh'])
```

puis, dans le contrôleur :

```php
public function export(Request $request): BinaryFileResponse
{
    return ListQuery::export(
        $this->scopedUserQuery(),          // le MÊME périmètre que la liste
        $request,
        $this->listSchema(),
        'utilisateurs-'.now()->format('Y-m-d').'.xlsx',
        'Utilisateurs',
    );
}
```

**Le fichier est la vue courante, pas la table** : `ListQuery::export()` rejoue le périmètre
du rôle, la recherche, les filtres et le tri — sans pagination. Un export qui contiendrait
une ligne que l'écran ne montre pas serait une fuite au sens de la matrice F11/Z16.

**Colonnes.** L'ordre de déclaration est l'ordre du fichier. Le 4ᵉ argument
d'`exportColumn` restreint une colonne à certains rôles (F11 : un formateur exporte des
résultats pédagogiques, pas des données d'activité). Le client peut restreindre et
réordonner via `columns[]` — la vue courante — mais **jamais élargir** : une colonne
interdite à son rôle n'apparaît pas, même demandée explicitement. C'est ce paramètre que
la DataTable alimentera depuis les préférences utilisateur (EA-021, EA-023).

Mise en forme : en-têtes FR en gras, booléens en Oui/Non, dates en `JJ/MM/AAAA HH:MM`.

---

## Pièges rencontrés

- **Caractère d'échappement du `LIKE`** : `\` est déspécialisé par MySQL dans les littéraux
  de chaîne, pas par SQLite. Un `ESCAPE '\\'` est donc lu différemment par les deux moteurs
  (et la suite de tests tourne sur SQLite, la production sur MySQL). Le contrat utilise
  `ESCAPE '!'`, sans signification particulière nulle part.
- **Tri sur une relation** : passer par `sortableUsing` avec une jointure explicite et un
  `select('table.*')`, sinon les colonnes jointes polluent la ligne paginée.
- **Concaténer deux colonnes n'est pas portable.** Chercher « prénom nom » d'un coup
  demande un `CONCAT()` en MySQL et un `||` en SQLite — et MySQL lit `||` comme un **OU
  logique**, pas comme une concaténation. Le premier casse la suite de tests, le second la
  passe et renvoie n'importe quoi en production. Le contrat **découpe le terme** sur les
  espaces et exige chaque mot dans l'un des champs : portable, et « Dupont Jean » trouve
  aussi bien que « Jean Dupont ».
- **Rapprocher deux tables par e-mail se fait en `LOWER()` des deux côtés.** SQLite compare
  `=` en tenant compte de la casse, MySQL non : sans `LOWER()`, `Jean@…` ne rejoint pas
  `jean@…` en test mais le rejoint en production.
- **Sens de tri de `is_active`** : conservé inversé sur l'endpoint utilisateurs (« croissant »
  = actifs d'abord), comportement d'origine que le front attend. Le rétablir « à l'endroit »
  retournerait la colonne sans que personne l'ait demandé.

---

## Suites prévues

| Issue | Ce qui s'appuie sur ce contrat |
|---|---|
| EA-019 (#29) | DataTable générique — consomme `search`/`sort[]`/`filters[]`/`per_page` |
| EA-021 (#31) | Préférences de colonnes par utilisateur |
| EA-022 (#32) | Export XLSX : `ListQuery::apply()` rejoue la vue courante sans paginer |
| EA-046, EA-049, EA-052, EA-053 (L4) | Écrans de pilotage — listes, filtres et exports |

Les **valeurs de facettes** (liste des valeurs disponibles par colonne filtrable) ne sont
pas exposées par ce contrat : elles seront ajoutées avec l'écran qui en a besoin (EA-019),
pour ne pas figer une forme avant de savoir ce que la DataTable en fait.
