# Hydrogen API

Documentation des endpoints HTTP exposés par Hydrogen.

> Source de vérité maintenue à la main pendant le développement. À chaque
> ajout/modification de route, ce fichier doit être mis à jour dans le même
> commit que le code.

## Conventions générales

- **Base URL (dev)** : `http://hydrogen.test` (Laragon)
- **Préfixe API** : toutes les routes JSON sont sous `/api`
- **Format** : [JSON:API 1.1](https://jsonapi.org/format/1.1/)
  - `Content-Type` réponse : `application/vnd.api+json`
  - Erreurs : tableau `errors[]` avec `status`, `title`, `detail`, optionnel `source.pointer` et `meta`
- **Authentification** : Bearer token via header
  `Authorization: Bearer <token>`
  Le token est obtenu via `POST /api/auth/login` et a une durée de vie glissante de **30 jours** (renouvelée à chaque requête authentifiée).
- **Codes d'erreur communs** :
  - `400` — corps JSON invalide
  - `401` — token manquant / invalide / expiré
  - `403` — compte banni / action interdite
  - `404` — ressource ou route inconnue
  - `405` — méthode non autorisée
  - `422` — attribut manquant ou invalide
  - `500` — erreur serveur (détails uniquement si `APP_DEBUG=true`)
- **Attribut `level` (ressource `users`)** : entier dérivé de `experience` via l'inverse de la courbe quadratique `xp(N) = F · N · (N-1)` avec `F = USER_LEVEL_FACTOR` (25 par défaut). Formule : `level = floor((F + sqrt(F² + 4·F·experience)) / (2·F))`. Avec `F=25` : L1 ≥ 0 XP, L2 ≥ 50, L3 ≥ 150, L4 ≥ 300, L5 ≥ 500, L6 ≥ 750… Le client ne doit jamais le calculer lui-même : l'attribut est toujours présent dans la ressource `users`.
- **Pagination & objet `links` (JSON:API 1.1)** : toute réponse de type *collection* expose un objet `links` au niveau racine du document :
  ```json
  "links": {
    "self":  "https://api.example/api/users/me/media?limit=20",
    "first": "https://api.example/api/users/me/media?limit=20",
    "prev":  null,
    "next":  "https://api.example/api/users/me/media?cursor=MTcxMjM0…&limit=20"
  }
  ```
  - **Keyset (curseur opaque)** — utilisé par tous les listings ordonnés sur un timestamp + UUID (`media`, `notifications`, `followers`, `following`). Les paramètres sont :
    - `?cursor=<opaque>` : page **suivante** (rows strictement plus anciennes que le curseur).
    - `?before=<opaque>` : page **précédente** (rows strictement plus récentes que le curseur). Mutuellement exclusif avec `?cursor` ; `?before` gagne s'ils sont présents tous les deux.
    - `?limit=<int>` : taille de page (bornée par endpoint, défaut 20).
    - `links.last` n'est **pas** émis (le keyset ne sait pas calculer la dernière page sans full scan).
    - Un curseur malformé renvoie `400 Invalid cursor` (jamais de fallback silencieux).
  - **Offset** — utilisé par les listings backend Meilisearch (`/api/users/search`). Paramètres `?offset=<int>` + `?limit=<int>`. Les cinq liens `self/first/prev/next/last` sont émis (le total estimé permet de calculer `last`).
  - **Membres nuls** : les liens non disponibles (ex : `prev` sur la première page) restent présents avec la valeur `null` — c'est conforme JSON:API et permet au client de distinguer « pas de page précédente » de « endpoint sans pagination ».
  - **Collections non paginées** (`/api/auth/sessions`, `/api/users/me/oauth-identities`, `/api/i18n/locales`) : seul `links.self` est émis.
- **`meta.total` — compte total d'éléments d'une collection** : quand le total est calculable à coût borné (compteur dénormalisé sur `user_stats`, `COUNT(*)` indexé par `user_id`, etc.), il est exposé dans `meta.total` aux côtés de `meta.limit`. Sémantique :
  - **Exact** (entier, `meta.total`) : endpoints keyset adossés à MySQL (`/api/users/me/media`, `/api/users/{id}/media`, `/api/users/me/notifications`, `/api/users/{id}/followers|following` et leurs variantes `me`). Le compte reflète le **même filtre que la requête** (ex : `meta.total` de `/api/users/me/notifications?filter=unread` ne compte que les non-lues).
  - **Estimé** (entier, `meta.totalHits`) : endpoints offset adossés à Meilisearch (`/api/users/search`, `/api/media/nearby`, `/api/media/in-bounds`). Le moteur retourne une estimation rapide ; ne pas s'en servir pour des assertions strictes côté client.
  - **Absent** : sur les collections non paginées (`links.self` only) — le client peut utiliser `data.length`.
- **Format des dates (toutes les ressources JSON:API)** : tout champ date/datetime exposé par l'API publique est un **objet structuré**, jamais une chaîne ISO brute. Forme canonique :
  ```json
  "createdAt": {
    "iso":       "2026-06-14T08:42:13+00:00",
    "formatted": {
      "long":     "14 juin 2026 08:42",
      "short":    "14/06/2026 08:42",
      "dateOnly": "14 juin 2026",
      "time":     "08:42"
    },
    "relative":  "il y a 3 minutes"
  }
  ```
  - **Fuseau** : `iso` est toujours en UTC (`+00:00`). Les rendus `formatted.*` et `relative` sont localisés via la même locale que la ressource (en-tête `Accept-Language` du viewer, normalisée par `LocaleResolverMiddleware`).
  - **`relative`** : généré côté serveur via `Carbon::diffForHumans()`. Le client peut l'afficher tel quel ; aucun calcul à faire pour « il y a X jours ».
  - **`formatted.*`** : utilisent les tokens `LL`/`LLL`/`L LT`/`LT` de Carbon — la sortie suit la locale (FR : `14 juin 2026`, EN : `June 14, 2026`).
  - **Valeur nulle** : l'attribut est `null` (jamais un objet vide). Le client doit donc tester `if (createdAt !== null)` avant d'accéder à `iso`/`formatted`/`relative`.
  - **Champs concernés** : tous les `createdAt`, `updatedAt`, `confirmedAt`, `joinedAt`, `bannedUntil`, `profileCompletedAt`, `birthdate`, `shotAt`, `readAt`, `editedAt`, `deletedAt`, `linkedAt`, `earnedAt`, `expiresAt`, `lastUsedAt`, `cooldownUntil`, `followedAt`, `reactedAt`, etc.
  - **Hors-périmètre** : les **curseurs de pagination** restent des chaînes opaques (encodage interne) ; l'**API admin** (`/admin/*`) reste plate (string ISO simple, pour Talend/Postman).

---

## Table des matières

- [Public](#public)
  - [GET /](#get-)
  - [GET /api/ping](#get-apiping)
- [Sessions web (cookie)](#sessions-web-cookie)
  - [GET /login](#get-login)
  - [POST /login](#post-login)
  - [POST /logout](#post-logout)
  - [POST /auth/oauth/{google,apple,facebook}](#post-authoauthgoogle-post-authoauthapple-post-authoauthfacebook)
- [Internationalisation](#internationalisation)
  - [GET /api/i18n/locales](#get-apii18nlocales)
- [Authentification](#authentification)
  - [POST /api/auth/register](#post-apiauthregister)
  - [POST /api/auth/confirm-email](#post-apiauthconfirm-email)
  - [POST /api/auth/resend-confirmation](#post-apiauthresend-confirmation)
  - [POST /api/auth/forgot-password](#post-apiauthforgot-password)
  - [POST /api/auth/reset-password](#post-apiauthreset-password)
  - [POST /api/auth/oauth/google](#post-apiauthoauthgoogle)
  - [POST /api/auth/oauth/apple](#post-apiauthoauthapple)
  - [POST /api/auth/oauth/facebook](#post-apiauthoauthfacebook)
  - [POST /api/auth/login](#post-apiauthlogin)
  - [GET /api/auth/me](#get-apiauthme)
  - [GET /api/auth/sessions](#get-apiauthsessions)
  - [DELETE /api/auth/sessions/{id}](#delete-apiauthsessionsid)
  - [POST /api/auth/logout](#post-apiauthlogout)
  - [POST /api/auth/logout-all](#post-apiauthlogout-all)
- [Profil utilisateur](#profil-utilisateur)
  - [POST /api/users/me/username](#post-apiusersmeusername)
  - [POST /api/users/me/username/change](#post-apiusersmeusernamechange)
  - [PUT /api/users/me/nickname](#put-apiusersmenickname)
  - [POST /api/users/me/password](#post-apiusersmepassword)
  - [POST /api/users/me/oauth/google](#post-apiusersmeoauthgoogle)
  - [POST /api/users/me/oauth/apple](#post-apiusersmeoauthapple)
  - [POST /api/users/me/oauth/facebook](#post-apiusersmeoauthfacebook)
  - [GET /api/users/me/oauth-identities](#get-apiusersmeoauth-identities)
  - [GET /api/users/me/settings](#get-apiusersmesettings)
  - [PATCH /api/users/me/settings](#patch-apiusersmesettings)
  - [POST /api/users/me/avatar](#post-apiusersmeavatar)
  - [DELETE /api/users/me/avatar](#delete-apiusersmeavatar)
  - [POST /api/users/me/cover](#post-apiusersmecover)
  - [DELETE /api/users/me/cover](#delete-apiusersmecover)
  - [GET /api/users/me/address](#get-apiusersmeaddress)
  - [PATCH /api/users/me/address](#patch-apiusersmeaddress)
  - [DELETE /api/users/me/address](#delete-apiusersmeaddress)
- [RGPD / suppression de compte](#rgpd--suppression-de-compte)
  - [GET /api/users/me/export](#get-apiusersmeexport)
  - [DELETE /api/users/me](#delete-apiusersme)
  - [POST /api/account/deletion/confirm](#post-apiaccountdeletionconfirm)
- [Followers / découverte](#followers--decouverte)
  - [POST /api/users/me/following/{targetUserId}](#post-apiusersmefollowingtargetuserid)
  - [DELETE /api/users/me/following/{targetUserId}](#delete-apiusersmefollowingtargetuserid)
  - [GET /api/users/me/following](#get-apiusersmefollowing)
  - [GET /api/users/me/followers](#get-apiusersmefollowers)
  - [GET /api/users/{userId}/following](#get-apiusersuseridfollowing)
  - [GET /api/users/{userId}/followers](#get-apiusersuseridfollowers)
  - [GET /api/users/{userId}/relationship](#get-apiusersuseridrelationship)
  - [GET /api/users/search](#get-apiuserssearch)
- [Photos / médias](#photos--medias)
  - [POST /api/users/me/media](#post-apiusersmemedia)
  - [GET /api/users/me/media](#get-apiusersmemedia)
  - [DELETE /api/users/me/media/{mediaId}](#delete-apiusersmemediamediaid)
  - [PUT /api/users/me/media/{mediaId}/description](#put-apiusersmemediamediaiddescription)
  - [DELETE /api/users/me/media/{mediaId}/description](#delete-apiusersmemediamediaiddescription)
  - [GET /api/users/{userId}/media](#get-apiusersuseridmedia)
  - [GET /api/media/nearby](#get-apimedianearby)
  - [GET /api/media/in-bounds](#get-apimediain-bounds)
  - [GET /api/media/recommended/nearby](#get-apimediarecommendednearby)
  - [GET /api/media/trending](#get-apimediatrending)
  - [GET /media/{hex}.{ext}](#get-mediahexext)
- [Hashtags média](#hashtags-media)
  - [GET /api/media/hashtags/autocomplete](#get-apimediahashtagsautocomplete)
  - [GET /api/media/hashtags/trending](#get-apimediahashtagstrending)
  - [GET /api/media/hashtags/related](#get-apimediahashtagsrelated)
- [Réactions (likes / dislikes)](#reactions-likes--dislikes)
  - [PUT /api/users/me/media/{mediaId}/reaction](#put-apiusersmemediamediaidreaction)
  - [DELETE /api/users/me/media/{mediaId}/reaction](#delete-apiusersmemediamediaidreaction)
  - [GET /api/media/{mediaId}/likes](#get-apimediamediaidlikes)
  - [GET /api/media/{mediaId}/dislikes](#get-apimediamediaiddislikes)
  - [GET /api/users/me/likes](#get-apiusersmelikes)
  - [GET /api/users/me/dislikes](#get-apiusersmedislikes)
  - [GET /api/users/{userId}/likes](#get-apiusersuseridlikes)
  - [GET /api/users/{userId}/dislikes](#get-apiusersuseriddislikes)
- [Commentaires](#commentaires)
  - [GET /api/media/{mediaId}/comments](#get-apimediamediaidcomments)
  - [POST /api/media/{mediaId}/comments](#post-apimediamediaidcomments)
  - [GET /api/media/comments/{commentId}/thread](#get-apimediacommentscommentidthread)
  - [GET /api/media/comments/{commentId}/replies](#get-apimediacommentscommentidreplies)
  - [PATCH /api/media/comments/{commentId}](#patch-apimediacommentscommentid)
  - [DELETE /api/media/comments/{commentId}](#delete-apimediacommentscommentid)
- [Favoris](#favoris)
  - [GET /api/users/me/bookmarks/collections](#get-apiusersmebookmarkscollections)
  - [POST /api/users/me/bookmarks/collections](#post-apiusersmebookmarkscollections)
  - [PATCH /api/users/me/bookmarks/collections/{collectionId}](#patch-apiusersmebookmarkscollectionscollectionid)
  - [DELETE /api/users/me/bookmarks/collections/{collectionId}](#delete-apiusersmebookmarkscollectionscollectionid)
  - [GET /api/users/me/bookmarks](#get-apiusersmebookmarks)
  - [PUT /api/users/me/media/{mediaId}/bookmark](#put-apiusersmemediamediaidbookmark)
  - [DELETE /api/users/me/media/{mediaId}/bookmark](#delete-apiusersmemediamediaidbookmark)
- [Social feeds](#social-feeds)
  - [POST /api/users/me/social-feeds](#post-apiusersmesocial-feeds)
  - [GET /api/users/me/social-feeds](#get-apiusersmesocial-feeds)
  - [DELETE /api/users/me/social-feeds/{code}](#delete-apiusersmesocial-feedscode)
  - [GET /api/social-feeds/{code}](#get-apisocial-feedscode)
- [Parrainage](#parrainage)
  - [GET /api/users/me/sponsorship](#get-apiusersmesponsorship)
- [Coupons](#coupons)
- [Établissements](#etablissements)
  - [GET /api/establishments](#get-apiestablishments)
  - [GET /api/establishments/search](#get-apiestablishmentssearch)
  - [GET /api/establishments/{id}](#get-apiestablishmentsid)
- [Offres](#offres)
  - [GET /api/offers](#get-apioffers)
  - [GET /api/offers/search](#get-apioffersearch)
  - [GET /api/offers/{id}](#get-apioffersid)
  - [POST /api/offers/{id}/clicks](#post-apioffersidclicks)
  - [GET /go/offer/{id}](#get-goofferid)
  - [GET /go/offer/{id}/media/{mediaId}](#get-goofferidmediamediaid)
- [Villes](#villes)
  - [GET /api/cities](#get-apicities)
  - [GET /api/cities/search](#get-apicitiessearch)
  - [GET /api/cities/{id}](#get-apicitiesid)
- [Marques](#marques)
  - [GET /api/brands](#get-apibrands)
- [Pays](#pays)
  - [GET /api/countries](#get-apicountries)
  - [GET /api/countries/search](#get-apicountriessearch)
  - [GET /api/countries/{code}](#get-apicountriescode)
- [Régions](#regions)
  - [GET /api/regions](#get-apiregions)
  - [GET /api/regions/search](#get-apiregionssearch)
  - [GET /api/regions/{code}](#get-apiregionscode)
- [Sous-régions](#sous-regions)
  - [GET /api/subregions](#get-apisubregions)
  - [GET /api/subregions/search](#get-apisubregionssearch)
  - [GET /api/subregions/{code}](#get-apisubregionscode)
- [Thèmes / centres d'intérêt](#themes--centres-dinteret)
  - [GET /api/topics](#get-apitopics)
  - [GET /api/users/me/topics](#get-apiusersmetopics)
  - [PUT /api/users/me/topics](#put-apiusersmetopics)
- [Badges (gamification)](#badges-gamification)
  - [GET /api/badges](#get-apibadges)
  - [GET /api/users/me/badges](#get-apiusersmebadges)
  - [GET /api/users/{userId}/badges](#get-apiusersuseridbadges)
- [Titres (gamification)](#titres-gamification)
- [Signalements (modération)](#signalements-moderation)
  - [POST /api/media/{mediaId}/reports](#post-apimediamediaidreports)
  - [POST /api/users/{userHex}/reports](#post-apiusersuserhexreports)
  - [POST /api/media/comments/{commentId}/reports](#post-apimediacommentscommentidreports)
- [Notifications](#notifications)
  - [GET /api/users/me/notifications](#get-apiusersmenotifications)
  - [GET /api/users/me/notifications/unread-count](#get-apiusersmenotificationsunread-count)
  - [PATCH /api/users/me/notifications/{id}/read](#patch-apiusersmenotificationsidread)
  - [POST /api/users/me/notifications/mark-all-read](#post-apiusersmenotificationsmark-all-read)
  - [DELETE /api/users/me/notifications/{id}](#delete-apiusersmenotificationsid)
  - [GET /api/users/me/notifications/preferences](#get-apiusersmenotificationspreferences)
  - [PATCH /api/users/me/notifications/preferences](#patch-apiusersmenotificationspreferences)
- [Newsletter](#newsletter)
  - [POST /api/newsletter/subscribe](#post-apinewslettersubscribe)

---

## Public

### `GET /`

Page d'accueil web (Twig). Renvoie du HTML, pas du JSON. Passe par la pile de middleware web (cf. [Sessions web (cookie)](#sessions-web-cookie)) — la template a accès aux globals `viewer`, `csrf_token`, `locale`.

- **Auth** : aucune
- **Action** : [HomeAction](../src/Http/Action/HomeAction.php)
- **Réponse** : `200 OK` (HTML)

---

### `GET /api/ping`

Healthcheck + identité du framework. Retourne un objet JSON simple (pas au format JSON:API — héritage du tout premier endpoint, conservé volontairement pour que les sondes de monitoring (uptime / load balancer) restent indépendantes du format métier).

C'est aussi la **seule surface publique qui expose la version d'Hydrogen** : aucun header HTTP `X-Hydrogen-Version` n'est émis sur les autres endpoints (choix sécurité — ne pas divulguer la version au monde sur chaque requête). Les ops qui ont besoin de la corréler avec un incident lisent ici.

- **Auth** : aucune
- **Action** : [PingAction](../src/Http/Action/Api/PingAction.php)
- **Réponse `200`** :

```json
{
  "status":    "ok",
  "timestamp": "2026-06-10T12:34:56+00:00",
  "version":   "0.1.0",
  "codename":  "Protium"
}
```

- **`version`** : SemVer du framework. Source : fichier `VERSION` à la racine, miroré dans `Hydrogen::VERSION`. Tant qu'on est en `0.x`, les minor bumps peuvent porter des breaking changes ; la garantie "no breaking without major" s'active à partir de `1.0.0`.
- **`codename`** : nom de release. Suit la séquence des isotopes de l'hydrogène (`Protium` → `Deuterium` → `Tritium` → …).

---

## Sessions web (cookie)

Les pages HTML servies par Hydrogen partagent les **mêmes lignes `user_session`** que l'API — seul le **transport** change : `Authorization: Bearer <token>` pour l'API, **cookie** pour le web. Pratiquement, ça veut dire :

- Un même utilisateur peut être connecté simultanément côté site et côté app mobile, chaque session apparaîtra dans `GET /api/auth/sessions`.
- La révocation d'une session web depuis l'app mobile (et inversement) fonctionne sans code spécial.
- Le throttle anti-bruteforce est partagé : 5 échecs sur `/login` ou `POST /api/auth/login` ferment l'IP/email côté API ET côté web.

### Cookies émis

| Cookie                | HttpOnly | Secure (prod) | SameSite | Path | Rôle |
|-----------------------|----------|---------------|----------|------|------|
| `hydrogen_session`*   | oui      | oui           | Lax*     | `/`  | Porte le bearer token de session ; sliding `Max-Age` aligné sur `user_session.expires_at` (30 jours). |
| `csrf`*               | non      | oui           | Strict   | `/`  | Token CSRF (64 hex chars) du double-submit cookie. Lu par le JS pour être renvoyé. |

\* noms et politiques configurables : `WEB_SESSION_COOKIE_NAME`, `WEB_COOKIE_SECURE`, `WEB_COOKIE_SAMESITE`, `WEB_COOKIE_DOMAIN`, `CSRF_COOKIE_NAME`.

### Pipeline middleware

```
request → WebSession → Locale → Csrf → TwigGlobals → action
```

- **WebSession** : lit le cookie de session, appelle `SessionService::authenticate()`, réémet le cookie avec le nouveau `expires_at` (sliding). Sur cookie invalide : passe en anonyme **et** efface le cookie.
- **Locale** : choisit la locale (settings utilisateur si connecté, sinon `Accept-Language`).
- **Csrf** : sur `POST`/`PUT`/`PATCH`/`DELETE`, exige soit un champ `_csrf` dans le corps, soit l'en-tête `X-CSRF-Token`. Comparaison `hash_equals`. Rejet `403` plain text en cas de désaccord ou de cookie absent/malformé.
- **TwigGlobals** : publie `viewer` (objet `User` ou `null`), `csrf_token` (string), `locale` dans toutes les templates Twig.

### Variables d'environnement

| Variable                   | Défaut             | Effet                                                                                                  |
|----------------------------|--------------------|--------------------------------------------------------------------------------------------------------|
| `WEB_SESSION_COOKIE_NAME`  | `hydrogen_session` | Nom du cookie de session.                                                                              |
| `WEB_COOKIE_SECURE`        | `false`            | `true` en prod (HTTPS). Concerne les deux cookies (session + CSRF).                                    |
| `WEB_COOKIE_SAMESITE`      | `Lax`              | Politique SameSite du cookie de session. `Strict` si pas besoin de GET cross-site authentifié.         |
| `WEB_COOKIE_DOMAIN`        | *(vide)*           | Attribut `Domain` explicite. Vide = host-only (recommandé).                                            |
| `CSRF_COOKIE_NAME`         | `csrf`             | Nom du cookie CSRF.                                                                                    |

### Bandeau de consentement cookies

Le partial [partials/cookie-consent.twig](../templates/partials/cookie-consent.twig) s'inclut à la fin du `<body>` de toute page web qui doit afficher le bandeau (`home.twig`, `auth/login.twig`, etc.). Il s'appuie sur la lib [orestbida/cookieconsent v3](https://github.com/orestbida/cookieconsent) chargée depuis jsdelivr (CSS + UMD pinned au tag `v3.0.1`).

**Catégories** :

| Slug         | Décochable ? | Couvre                                                                                  |
|--------------|--------------|------------------------------------------------------------------------------------------|
| `necessary`  | non          | `hydrogen_session`, `csrf`, le cookie de mémorisation du consentement lui-même.          |
| `analytics`  | oui          | Google Analytics 4 (`_ga`, `_ga_*`) — chargé UNIQUEMENT après acceptation explicite.     |

> Légalement (RGPD/CNIL), les cookies strictement nécessaires ne requièrent PAS de consentement. La banner reste affichée pour information sur la catégorie `necessary` ET pour recueillir le consentement obligatoire de la catégorie `analytics`.

**Google Analytics 4** : pilote via `GOOGLE_ANALYTICS_ID` (ex. `G-XXXXXXX`). Vide ⇒ aucun snippet GA n'est rendu. Sinon, les deux balises `<script>` (chargement de `gtag.js` + appel `gtag('config', ...)`) sont émises avec `type="text/plain"` et `data-category="analytics"` ; le browser ne les exécute PAS au parsing. CookieConsent v3 surveille ces nœuds et ne réécrit leur `type` en `text/javascript` qu'à partir de l'acceptation explicite de la catégorie — refus / pas de décision = GA ne se charge jamais, aucun cookie posé, aucun hit envoyé. `anonymize_ip: true` est forcé.

**Localisation** : les libellés sont stockés dans `resources/lang/<locale>/cookies.php` (catalogue racine partagé entre toutes les pages — pas sous `pages.*` puisque la banner traverse toutes les pages). Sections : `consent.*`, `preferences.*`, `categories.<slug>.*`.

**Lien de réouverture** : tout élément doté de l'attribut `data-cc="show-preferencesModal"` rouvre la modale (à utiliser dans un futur footer ou une page Confidentialité).

**Réémission** : la clé `revision` (entier) côté Twig est à incrémenter dès qu'une catégorie est ajoutée ou retirée — la banner sera alors réaffichée aux visiteurs qui avaient déjà répondu.

**Ajouter un script soumis au consentement** : le jour où un outil d'analytics est intégré, sa balise `<script>` doit porter `type="text/plain"` + `data-category="analytics"` (convention CookieConsent v3). Le script ne s'exécute alors qu'à partir de l'acceptation explicite.

---

### Protection contre les redirections ouvertes

Tous les endpoints qui consomment un paramètre `return` (`?return=...` ou champ `return` dans le corps) le filtrent via `ReturnUrlSanitizer::pick()`. Sont **acceptés uniquement** :
- les chemins relatifs commençant par `/` (ex : `/profile`, `/media/abc?from=home`).

Sont **rejetés** :
- les URL absolues (`http://...`).
- les chemins protocol-relative (`//evil.com/...`).
- les valeurs vides ou non-string.

En cas de rejet, fallback sur le default fourni (typiquement `/`).

---

### `GET /login`

Affiche le formulaire de connexion (Twig). Si l'utilisateur est déjà authentifié, redirige immédiatement vers `?return=` (sanitisé) ou `/`.

- **Auth** : aucune (mais redirige si déjà connecté)
- **Action** : [ShowLoginAction](../src/Http/Action/Web/Auth/ShowLoginAction.php)
- **Query params** :
  - `error` (optionnel) — code d'erreur affiché par la template (`missing_fields`, `invalid_credentials`, `banned`, `email_not_confirmed`, `throttled`, `unknown`).
  - `return` (optionnel) — chemin où rediriger après login (sanitisé).
- **Réponse** : `200 OK` (HTML) ou `302` si déjà connecté.

---

### `POST /login`

Soumission du formulaire de connexion web. Mêmes règles métier que `POST /api/auth/login` (throttle, ban, email confirmé). Succès : pose le cookie de session, redirige vers `?return=` ou `/`. Échec : redirige vers `/login?error=<code>&return=<return>`.

- **Auth** : aucune
- **CSRF** : requis (champ `_csrf` ou en-tête `X-CSRF-Token`)
- **Action** : [SubmitLoginAction](../src/Http/Action/Web/Auth/SubmitLoginAction.php)
- **Corps (form-urlencoded)** :
  - `email` (string, requis)
  - `password` (string, requis)
  - `_csrf` (string, requis)
  - `return` (string, optionnel)
- **Réponse `302`** :
  - Succès : `Location: <return>` + `Set-Cookie: hydrogen_session=...`
  - Échec : `Location: /login?error=<code>[&return=<return>]`
- **Codes d'erreur** (paramètre `error` du redirect) :
  - `missing_fields` — email ou password vide.
  - `invalid_credentials` — combinaison invalide.
  - `banned` — compte banni.
  - `email_not_confirmed` — email non confirmé.
  - `throttled` — trop de tentatives.
  - `unknown` — erreur inattendue (devrait être inatteignable).
- **Réponse `403`** : `CSRF token missing or invalid.` (en cas d'échec du middleware CSRF).

---

### `POST /logout`

Révoque la session web courante (supprime la ligne `user_session`) ET efface le cookie. Idempotent — un visiteur anonyme reçoit aussi un `302 /`. Les autres sessions du même utilisateur (autres appareils, app mobile, API) ne sont **pas** affectées : utilisez `POST /api/auth/logout-all` pour ça.

- **Auth** : facultative (l'action ne casse pas si la session est déjà absente)
- **CSRF** : requis
- **Action** : [LogoutAction (web)](../src/Http/Action/Web/Auth/LogoutAction.php)
- **Corps (form-urlencoded)** :
  - `_csrf` (string, requis)
  - `return` (string, optionnel)
- **Réponse `302`** : `Location: <return>` (default `/`) + `Set-Cookie: hydrogen_session=; Max-Age=0`.

---

### `POST /auth/oauth/google`, `POST /auth/oauth/apple`, `POST /auth/oauth/facebook`

Pendants **web** des endpoints OAuth de l'API (`POST /api/auth/oauth/*`). Le navigateur a obtenu un token via le SDK JS du fournisseur (Google Identity Services / Sign in with Apple JS / Facebook JS SDK), puis le poste à l'un de ces trois endpoints. La logique métier est partagée avec l'API (`OAuthLoginService`) — seul le **transport** change : pas de JSON envelope, on ouvre une session web (cookie) et on redirige.

Le formulaire HTML est rendu par `templates/auth/login.twig` ; les boutons fournisseurs ne s'affichent que si l'env correspondante est non vide :

| Provider | Env requise(s)                                                   | Bouton si vide ? |
|----------|------------------------------------------------------------------|------------------|
| Google   | `GOOGLE_OAUTH_CLIENT_ID`                                         | masqué           |
| Apple    | `APPLE_OAUTH_WEB_CLIENT_ID` + `APPLE_OAUTH_WEB_REDIRECT_URI`     | masqué           |
| Facebook | `FACEBOOK_APP_ID`                                                | masqué           |

- **Auth** : aucune
- **CSRF** : requis (champ `_csrf` posé par le formulaire caché correspondant)
- **Actions** :
  - Google → [WebGoogleOAuthAction](../src/Http/Action/Web/Auth/OAuth/WebGoogleOAuthAction.php)
  - Apple → [WebAppleOAuthAction](../src/Http/Action/Web/Auth/OAuth/WebAppleOAuthAction.php)
  - Facebook → [WebFacebookOAuthAction](../src/Http/Action/Web/Auth/OAuth/WebFacebookOAuthAction.php)
- **Corps (form-urlencoded)** :
  - Google : `idToken` (string, requis) + `_csrf` + `return?`
  - Apple : `idToken` (string, requis) + `_csrf` + `return?`
  - Facebook (classic) : `flow=classic` + `accessToken` + `_csrf` + `return?`
  - Facebook (limited) : `flow=limited` + `idToken` + `_csrf` + `return?`
- **Réponse `302`** :
  - Succès : `Location: <return>` + `Set-Cookie: hydrogen_session=...`
  - Échec : `Location: /login?error=<code>[&return=<return>]`
- **Codes d'erreur** (paramètre `error` du redirect, mappés sur `pages.login.error.*`) :
  - `oauth_missing_token` — body sans `idToken`/`accessToken`/`flow` valide.
  - `oauth_token_invalid` — vérification JWKS/Graph KO.
  - `oauth_link_refused` — un compte existe déjà pour cet email mais l'auto-link a été refusé (règles C3 pour Google ; toujours refusé pour Apple/Facebook — E.a strict).
  - `oauth_email_missing` — Facebook uniquement (D.a) : l'utilisateur a refusé la permission `email`.
  - `banned` — compte banni.

> Les règles d'auto-link, de placeholder username et de bannissement sont strictement identiques à celles des endpoints API correspondants — voir `POST /api/auth/oauth/google`, `apple`, `facebook` plus bas.

---

## Authentification

### `POST /api/auth/register`

Crée un compte utilisateur. Déclenche l'envoi d'un email contenant un lien de confirmation. Le compte est créé en base avec `status = 1`, `user_type = 1`, `confirmed_at = NULL` — l'utilisateur **ne peut pas se connecter tant qu'il n'a pas confirmé son email** (`POST /api/auth/confirm-email`).

- **Auth** : aucune
- **Action** : [RegisterAction](../src/Http/Action/Api/Auth/RegisterAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{
  "username":        "havoc",
  "email":           "user@example.com",
  "password":        "MySuperPassw0rd!",
  "sponsorshipCode": "A3K7QM",
  "couponId":        "WELCOME2026"
}
```

`sponsorshipCode` est **optionnel** (omission, `null` ou chaîne vide = pas de
parrain). Si fourni, il doit matcher un code existant de la table
`sponsorship` — sinon `422` (cf. codes d'erreur). Voir
[section Parrainage](#parrainage) pour le détail du format.

`couponId` est **optionnel** et **non bloquant** : un coupon inconnu, expiré
ou déjà épuisé ne fait JAMAIS échouer l'inscription. Le résultat de la
tentative est exposé dans `meta.coupon` sur la réponse `201`. Voir
[section Coupons](#coupons) pour le détail.

#### Politiques de validation

**Username** ([UsernamePolicy](../src/Domain/User/UsernamePolicy.php)) :
- 3 à 32 caractères
- ASCII : `[a-z0-9._-]` uniquement (forcé en minuscules à la persistance)
- Doit commencer et finir par un caractère alphanumérique
- Pas de séparateurs consécutifs (`..`, `__`, `-_`, …)
- Unicité case-insensitive

**Password** ([PasswordPolicy](../src/Domain/Auth/PasswordPolicy.php)) :
- 12 à 72 octets (72 = limite bcrypt)
- Au moins : 1 minuscule, 1 majuscule, 1 chiffre, 1 caractère non alphanumérique
- Pas de NUL byte, pas d'espace en début/fin
- Ne doit pas contenir le username ni la partie locale de l'email

**Email** : `filter_var(..., FILTER_VALIDATE_EMAIL)` + max 255 caractères + unicité.

- **Réponse `201`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username":    "havoc",
      "email":       "user@example.com",
      "userType":    1,
      "status":      1,
      "joinedAt":    "2026-06-06T12:34:56+00:00",
      "isConfirmed": false
    }
  },
  "meta": {
    "confirmationEmailSent": true,
    "coupon": {
      "applied":  true,
      "couponId": "WELCOME2026",
      "rewards":  [{ "type": "experience", "value": 100 }]
    }
  }
}
```

`meta.coupon` n'est présent que si le client a fourni `couponId`. Sur échec :

```json
"coupon": {
  "applied":  false,
  "couponId": "WELCOME2026",
  "reason":   "coupon.expired",
  "message":  "Ce coupon a expiré."
}
```

- **Réponse `400`** — corps non-JSON / non-objet
- **Réponse `422`** — un ou plusieurs `errors[]` avec :
  - `source.pointer = /data/attributes/<field>`
  - `meta.code` typé pour i18n côté frontend
  - Codes possibles :
    - `username.tooShort`, `username.tooLong`, `username.invalidCharacters`, `username.invalidBoundary`, `username.consecutiveSeparators`, `username.reserved`, `username.alreadyTaken`
    - `email.invalidFormat`, `email.alreadyTaken`
    - `password.tooShort`, `password.tooLong`, `password.missingLowercase`, `password.missingUppercase`, `password.missingDigit`, `password.missingSpecial`, `password.invalidCharacters`, `password.invalidBoundary`, `password.containsUsername`, `password.containsEmail`
    - `sponsorship.codeInvalid` (forme du code incorrecte : longueur, caractères hors alphabet), `sponsorship.codeNotFound` (code bien formé mais inconnu de la table `sponsorship`)

#### Usernames réservés

Le code `username.reserved` est renvoyé quand le username demandé figure dans la blocklist statique [`config/username_blocklist.php`](../config/username_blocklist.php) (noms système/rôles, identité de marque, routes techniques), chargée dans [UsernameBlocklist](../src/Domain/User/UsernameBlocklist.php). La vérification s'applique partout où [UsernamePolicy](../src/Domain/User/UsernamePolicy.php) est utilisée (inscription, complétion de profil, changement de username). Le match porte sur la forme normalisée **et** sur une forme sans séparateurs (`.`/`_`/`-`), de sorte que `admin` bloque aussi `a.d.m.i.n`. La liste évolue par PR ; aucune table DB en V1.

#### Note sur l'énumération

Les erreurs `username.alreadyTaken` et `email.alreadyTaken` sont explicites — un attaquant peut donc tester l'existence d'un email. Choix assumé : les usernames sont publics dans une app sociale, et un endpoint d'« availability check » sera de toute façon nécessaire pour l'UX du formulaire. À durcir si le produit devient sensible.

#### Mailer en dev

Tant qu'un vrai mailer n'est pas branché, l'email est écrit dans `var/logs/mail.log` ([LogMailer](../src/Infrastructure/Mail/LogMailer.php)). Pour récupérer le token de confirmation pendant les tests :

```bash
tail -n 30 var/logs/mail.log
```

---

### `POST /api/auth/confirm-email`

Consomme le token reçu par email et marque l'utilisateur comme confirmé (`user.confirmed_at = NOW()`). Le token est à usage unique et expire après 24h.

- **Auth** : aucune
- **Action** : [ConfirmEmailAction](../src/Http/Action/Api/Auth/ConfirmEmailAction.php)
- **Request body** :

```json
{ "token": "<base64url-token>" }
```

(également accepté en forme JSON:API : `data.attributes.token`)

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username":    "havoc",
      "email":       "user@example.com",
      "isConfirmed": true,
      "confirmedAt": "2026-06-06T13:00:00+00:00"
    }
  }
}
```

- **Réponse `400`** — token inconnu, expiré, ou déjà utilisé (même message pour les trois cas afin d'éviter un oracle)
- **Réponse `422`** — attribut `token` manquant

- **Notes** :
  - Une nouvelle demande de confirmation pour le même user invalide automatiquement tous les tokens précédents non utilisés (`invalidatePendingForUser`).
  - Le token brut n'est jamais stocké : seul son SHA-256 (32 octets) est en base ([table `email_confirmation`](../database/migrations/2026_06_06_140000_create_email_confirmation_table.sql)).

---

### `POST /api/auth/resend-confirmation`

Renvoie un email de confirmation pour un compte **existant et non encore confirmé**.

- **Auth** : **publique** (l'utilisateur n'a pas pu se connecter puisqu'il n'est pas confirmé)
- **Action** : [ResendConfirmationAction](../src/Http/Action/Api/Auth/ResendConfirmationAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "email": "user@example.com" }
```

#### Comportement anti-énumération

L'endpoint **renvoie toujours la même réponse `202 Accepted`**, peu importe que :

- l'email soit inconnu ;
- le compte soit déjà confirmé (`confirmed_at IS NOT NULL`) ;
- une demande précédente ait été faite il y a moins de `cooldownSeconds` (cooldown actif) ;
- l'envoi de l'email ait réellement eu lieu.

Cela empêche un attaquant d'inférer l'existence d'un compte (ou son état) à partir du status code ou du corps de la réponse.

#### Rate-limit ([ResendConfirmationService](../src/Domain/Auth/ResendConfirmationService.php))

- **Cooldown 60 s** par compte. Calcul : `MAX(email_confirmation.created_at) WHERE user_id = X`. Si la dernière demande date d'il y a < 60 s, l'envoi est silencieusement supprimé (toujours `202`).
- Si l'envoi a lieu, **toutes les confirmations pending du user sont invalidées** (`used_at = NOW()`) avant l'émission du nouveau token — un seul token actif à la fois (logique de [`EmailConfirmationService::issueFor()`](../src/Domain/Auth/EmailConfirmationService.php)).

- **Réponse `202`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "confirmationResends",
    "id":   "pending",
    "attributes": {
      "message": "If this email is registered and not yet confirmed, a new confirmation link has been sent."
    }
  },
  "meta": { "cooldownSeconds": 60 }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `422`** — `email` manquant ou vide

- **Notes** :
  - Le token est généré comme à l'inscription (32 octets CSPRNG, base64url, hash SHA-256 stocké) et expire après `EmailConfirmationService::LIFETIME_HOURS` (24h).
  - L'email est envoyé via [`ConfirmationEmailSender`](../src/Domain/Auth/ConfirmationEmailSender.php) — même template que l'inscription pour cohérence.
  - L'ID retourné est volontairement la chaîne `"pending"` (et non un UUID) : ne pas exposer d'identifiant de ressource créée afin de ne pas confirmer/infirmer l'existence du compte.

---

### `POST /api/auth/forgot-password`

Émet un email de réinitialisation de mot de passe pour un compte existant.

- **Auth** : **publique**
- **Action** : [ForgotPasswordAction](../src/Http/Action/Api/Auth/ForgotPasswordAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "email": "user@example.com" }
```

#### Comportement anti-énumération

L'endpoint **renvoie toujours `202 Accepted`** avec le même corps, peu importe que :

- l'email soit inconnu ;
- le compte soit OAuth-only (sans mot de passe réel — autorisé : permet d'ajouter un mot de passe) ;
- le compte ne soit pas encore confirmé (autorisé : la consommation du token confirmera l'email implicitement) ;
- une demande précédente ait été faite il y a moins de `cooldownSeconds` (cooldown actif) ;
- l'envoi de l'email ait réellement eu lieu.

#### Rate-limit ([PasswordResetRequestService](../src/Domain/Auth/PasswordResetRequestService.php))

- **Cooldown 60 s** par compte. Calcul : `MAX(password_reset.created_at) WHERE user_id = X`. Si la dernière demande date d'il y a < 60 s, l'envoi est silencieusement supprimé.
- À chaque envoi réussi, **toutes les demandes pending du user sont invalidées** (`used_at = NOW()`) — un seul token actif à la fois.

- **Réponse `202`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "passwordResetRequests",
    "id":   "pending",
    "attributes": {
      "message": "If this email is registered, a password-reset link has been sent."
    }
  },
  "meta": { "cooldownSeconds": 60 }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `422`** — `email` manquant ou vide

- **Notes** :
  - Le token est 32 octets CSPRNG, base64url, hash SHA-256 stocké dans `password_reset.token_hash` (BINARY(32) UNIQUE).
  - Durée de vie : `PasswordResetService::LIFETIME_HOURS = 1` heure.
  - L'email est envoyé via [`PasswordResetEmailSender`](../src/Domain/Auth/PasswordResetEmailSender.php) qui rend le template Twig [`password_reset.twig`](../templates/mails/password_reset.twig). Le lien pointe vers `APP_URL + /reset-password?token=...`.

---

### `POST /api/auth/reset-password`

Consomme un token de réinitialisation et applique un nouveau mot de passe.

- **Auth** : **publique** (le user est précisément en train de récupérer son accès)
- **Action** : [ResetPasswordAction](../src/Http/Action/Api/Auth/ResetPasswordAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{
  "token":       "<plain token reçu par email>",
  "newPassword": "MyNewPassw0rd!"
}
```

#### Effets de bord

Sur succès, [`PasswordResetService::consume()`](../src/Domain/Auth/PasswordResetService.php) effectue dans l'ordre :

1. **Remplace** le mot de passe (bcrypt cost 12, met à jour `password_set_at`).
2. **Confirme l'email** si le compte ne l'était pas (`confirmed_at = NOW()`) — la possession du token prouve le contrôle de la boîte. `meta.emailConfirmedByReset = true` dans ce cas.
3. **Révoque toutes les sessions** du user (`DELETE FROM user_session WHERE user_id = X`). L'utilisateur devra se reconnecter partout. `meta.sessionsRevoked` donne le compte.
4. **Marque le token consommé** (`used_at = NOW()`) — non rejouable.

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "passwordResets",
    "id":   "<user-uuid>",
    "attributes": {
      "changedAt": "2026-06-07T11:00:00+00:00"
    }
  },
  "meta": {
    "sessionsRevoked":       2,
    "emailConfirmedByReset": false
  }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `401`** — token rejeté. `meta.code` :
  - `passwordReset.tokenInvalid` — inconnu
  - `passwordReset.tokenExpired` — au-delà de 1h
  - `passwordReset.tokenUsed` — déjà consommé
- **Réponse `422`** — `token` ou `newPassword` manquant, ou échec de la [PasswordPolicy](../src/Domain/Auth/PasswordPolicy.php). Codes : `password.tooShort`, `password.tooLong`, `password.missingLowercase`, `password.missingUppercase`, `password.missingDigit`, `password.missingSpecial`, `password.invalidCharacters`, `password.invalidBoundary`, `password.containsUsername`, `password.containsEmail`

- **Notes** :
  - Aucun token de session n'est émis — l'utilisateur doit se reconnecter via `/auth/login`. C'est volontaire (le reset peut résulter d'un compromis, on force une auth complète).
  - Pour un compte OAuth-only, le reset **crée** son premier mot de passe — il pourra ensuite se connecter par mot de passe **ou** par Google (au choix).

---

### `POST /api/auth/oauth/google`

Échange un **Google ID Token** (obtenu côté frontend après le sign-in Google) contre un bearer token de session Hydrogen. Selon l'état du compte, l'endpoint peut **connecter** un utilisateur existant, **lier** une identité Google à un compte existant, ou **créer** un nouveau compte.

- **Auth** : aucune
- **Action** : [GoogleLoginAction](../src/Http/Action/Api/Auth/OAuth/GoogleLoginAction.php)
- **Pré-requis serveur** : variable d'env `GOOGLE_OAUTH_CLIENT_ID` (Client ID OAuth récupéré dans la [Google Cloud Console](https://console.cloud.google.com/apis/credentials)).
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "idToken": "<google id_token>" }
```

#### Vérification du token

[GoogleIdTokenVerifier](../src/Domain/Auth/OAuth/GoogleIdTokenVerifier.php) :
1. Récupère les JWKS Google (`https://www.googleapis.com/oauth2/v3/certs`), avec cache disque TTL **1h** sous `var/cache/oauth/google_jwks.json`.
2. Vérifie la signature RS256, `iss` (`accounts.google.com` ou `https://accounts.google.com`), `aud` (= `GOOGLE_OAUTH_CLIENT_ID`), `exp` (avec 60s de skew).
3. Extrait `sub`, `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `locale`.

#### Logique de résolution ([OAuthLoginService](../src/Domain/Auth/OAuth/OAuthLoginService.php))

| Cas | Condition | Résultat | Code HTTP | `meta.oauthOutcome` |
| --- | --------- | -------- | --------- | ------------------- |
| Identité Google déjà liée | (provider, sub) existe en `user_oauth_identity` | sign-in du user lié | `200` | `existingIdentity` |
| Auto-link (C3) | email Google = email d'un user existant, **et** ce user a `confirmed_at IS NOT NULL`, **et** `email_verified = true` côté Google | lien créé puis sign-in | `200` | `linkedToExistingUser` |
| Auto-link refusé | email match un user, mais une des deux conditions C3 manque | `409 Conflict`, l'utilisateur doit se connecter par mot de passe et lier Google manuellement plus tard | `409` | — |
| Nouveau compte | aucune correspondance | création d'un user avec **username placeholder** (`g_XXXXXXXX`, 4 octets aléatoires en hex), `confirmed_at` = NOW(), mot de passe inutilisable (bcrypt aléatoire), `profile_completed_at = NULL` | `201` | `registered` |
| Banni | user trouvé mais `bannedUntil` futur | refus | `403` | — |

- **Réponse `200` / `201`** — même forme que `POST /api/auth/login` plus :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username":           "g_3f2a91b0",
      "email":              "user@gmail.com",
      "confirmedAt":        "2026-06-06T12:34:56+00:00",
      "profileCompletedAt": null,
      "isConfirmed":        true,
      "isBanned":           false
      /* + tous les autres champs habituels */
    }
  },
  "meta": {
    "token":           "<base64url-token>",
    "sessionId":       "<session-uuid>",
    "expiresAt":       "2026-07-06T12:34:56+00:00",
    "oauthOutcome":    "registered",
    "profileComplete": false
  }
}
```

- **Réponse `401`** — `Invalid Google ID token` (signature invalide, expiré, mauvais `iss`/`aud`, JWKS injoignable, etc. — message volontairement non détaillé)
- **Réponse `403`** — `Account banned`, `meta.bannedUntil`
- **Réponse `409`** — `Account exists with this email`, `meta.code = "oauth.linkRefused"` (un compte existe mais l'auto-link est refusé)
- **Réponse `422`** — `idToken` manquant ou vide

#### Notes

- Les comptes créés via Google ont un **username placeholder** (`g_<8-hex>`) et `profile_completed_at = NULL`. Un endpoint dédié (à venir) permettra à l'utilisateur de choisir son vrai username.
- Le mot de passe stocké est un bcrypt d'octets aléatoires CSPRNG — `password_verify` échoue toujours, donc impossible de se connecter via `/api/auth/login` tant que l'utilisateur n'a pas explicitement défini un mot de passe.
- La table `user_oauth_identity` ([migration](../database/migrations/2026_06_06_150000_create_user_oauth_identity_table.sql)) stocke `(provider, provider_user_id)` UNIQUE — un même compte Google ne peut être lié qu'à un seul user Hydrogen.

#### Matrice des providers OAuth

Récapitulatif des différences entre les trois providers supportés. Chaque cellule reflète une décision du framework Hydrogen, pas seulement une capacité du provider.

| Capacité                                                  | Google                          | Apple                                         | Facebook                                              |
| --------------------------------------------------------- | ------------------------------- | --------------------------------------------- | ----------------------------------------------------- |
| Type de jeton accepté                                     | OIDC ID Token (JWT)             | OIDC ID Token (JWT)                           | Access token opaque **OU** OIDC ID Token (JWT)        |
| Flux côté frontend                                        | un seul (`idToken`)             | un seul (`idToken`)                           | deux : `classic` (web/Android) ou `limited` (iOS ≥13) |
| `email` fourni                                            | toujours                        | toujours                                      | **optionnel** (l'utilisateur peut refuser le scope)   |
| `email_verified` signal côté provider                     | oui (booléen)                   | oui (`"true"`/`"false"` ou booléen, coercé)   | **non**                                               |
| Auto-link sur email existant (C3, sign-in)                | **oui** si confirmé + verified  | **non, jamais** (E.a strict)                  | **non, jamais** (E.a strict)                          |
| Lien manuel depuis le compte connecté                     | oui                             | oui                                           | oui                                                   |
| `email_is_relay` possible                                 | non                             | **oui** (`@privaterelay.appleid.com`)         | non                                                   |
| Prefix du username placeholder à la création              | `g_<8-hex>`                     | `a_<8-hex>`                                   | `f_<8-hex>`                                           |
| Variables d'env requises                                  | `GOOGLE_OAUTH_CLIENT_ID`        | `APPLE_OAUTH_CLIENT_IDS` (liste)              | `FACEBOOK_APP_ID`, `FACEBOOK_APP_SECRET`              |
| JWKS                                                      | `googleapis.com/oauth2/v3/certs` | `appleid.apple.com/auth/keys`                | `facebook.com/.well-known/oauth/openid/jwks/`         |

**Politique E.a (strict)** — Apple et Facebook **ne sont jamais auto-liés** à un compte Hydrogen existant qui aurait le même email. Raison : Apple Private Relay réduit l'assurance d'identité (l'utilisateur peut relayer un email vers n'importe quelle boîte) et Facebook ne renvoie aucun `email_verified`. Si un compte existe déjà avec cet email, la réponse est `409 oauth.linkRefused` — le client doit demander une connexion par mot de passe puis appeler `POST /api/users/me/oauth/<provider>` depuis l'espace authentifié.

---

### `POST /api/auth/oauth/apple`

Échange un **Apple ID Token** (issu de Sign in with Apple) contre un bearer token de session Hydrogen.

- **Auth** : aucune
- **Action** : [AppleLoginAction](../src/Http/Action/Api/Auth/OAuth/AppleLoginAction.php)
- **Pré-requis serveur** : variable d'env `APPLE_OAUTH_CLIENT_IDS` (liste **séparée par des virgules** des Services ID / Bundle ID acceptés ; Apple permet à un seul Developer Team de signer pour plusieurs `aud`, tous valides pour le même `sub`).
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "idToken": "<apple id_token>" }
```

#### Vérification du token

[AppleIdTokenVerifier](../src/Domain/Auth/OAuth/AppleIdTokenVerifier.php) :
1. Récupère les JWKS Apple (`https://appleid.apple.com/auth/keys`), cache disque TTL **1h** sous `var/cache/oauth/apple_jwks.json`.
2. Vérifie la signature RS256, `iss = https://appleid.apple.com`, `aud ∈ APPLE_OAUTH_CLIENT_IDS`, `exp` (skew 60s).
3. Extrait `sub`, `email`, `email_verified` (coercé en booléen — Apple envoie parfois `"true"`/`"false"` sous forme de string).

#### Logique de résolution

Identique à Google **sauf** que l'**auto-link est désactivé (E.a strict)** : un user Hydrogen existant avec le même email reçoit toujours `409 oauth.linkRefused`, jamais `linkedToExistingUser`.

| Cas | Résultat | Code | `meta.oauthOutcome` |
| --- | -------- | ---- | ------------------- |
| Identité Apple déjà liée | sign-in du user lié | `200` | `existingIdentity` |
| Email match user existant | refus auto-link (E.a) | `409 oauth.linkRefused` | — |
| Nouveau compte | création (`username = a_XXXXXXXX`, `confirmed_at` = NOW(), mot de passe inutilisable) | `201` | `registered` |
| Banni | refus | `403` | — |

- **Réponse `200` / `201`** — même forme que Google plus `meta.emailIsRelay: true|false` (drapeau Private Relay).
- **Réponse `401`** — id_token non vérifiable
- **Réponse `403`** — `Account banned`
- **Réponse `409`** — `oauth.linkRefused`
- **Réponse `422`** — `idToken` manquant

#### Apple Private Relay

Quand l'utilisateur choisit « Hide My Email » au moment du consentement, Apple émet un alias `<random>@privaterelay.appleid.com`. Cet alias est traité comme un email normal côté Hydrogen (envois fonctionnent, MX d'Apple), mais :
- la colonne `user_oauth_identity.email_is_relay = 1` est positionnée,
- la meta de la réponse expose `emailIsRelay: true`,
- l'application cliente doit informer l'utilisateur que désactiver le forwarding lui ferait perdre l'accès au compte (clef de traduction `auth.oauth.apple.relayNotice`).

---

### `POST /api/auth/oauth/facebook`

Échange un jeton Facebook contre un bearer token de session Hydrogen. **Deux flux** sont supportés, sélectionnés par le champ `flow` du body.

- **Auth** : aucune
- **Action** : [FacebookLoginAction](../src/Http/Action/Api/Auth/OAuth/FacebookLoginAction.php)
- **Pré-requis serveur** : `FACEBOOK_APP_ID` et `FACEBOOK_APP_SECRET` (le secret reste sur le serveur, utilisé pour construire l'`app_token = <id>|<secret>` consommé par `debug_token`).
- **Request body** :

```json
// Flux classique (web SDK, Android)
{ "flow": "classic", "accessToken": "<facebook access token>" }

// Flux limited (iOS ≥ 13)
{ "flow": "limited", "idToken": "<facebook OIDC id_token>" }
```

#### Vérification du token ([FacebookVerifier](../src/Domain/Auth/OAuth/FacebookVerifier.php))

- **Flux `classic`** : POST `https://graph.facebook.com/v19.0/debug_token?input_token=…&access_token=<app_id>|<app_secret>` puis GET `/v19.0/me?fields=id,email`. Validations : `is_valid = true`, `app_id` correspond à `FACEBOOK_APP_ID`, `expires_at` strictement dans le futur **ou égal à `0`** (long-lived tokens), `profile.id` égal au `user_id` retourné par `debug_token`.
- **Flux `limited`** : JWKS `https://www.facebook.com/.well-known/oauth/openid/jwks/` (cache TTL 1h), `iss = https://www.facebook.com`, `aud = FACEBOOK_APP_ID`, skew 60s.

Dans les deux cas, l'email est **optionnel** — si l'utilisateur a refusé le scope `email`, la sortie normalisée [FacebookProfile](../src/Domain/Auth/OAuth/FacebookProfile.php) a `email = null`.

#### Logique de résolution

| Cas | Résultat | Code | `meta.oauthOutcome` |
| --- | -------- | ---- | ------------------- |
| Pas d'email retourné (`email = null`) | refus, code `oauth.facebook.emailMissing` | `422` | — |
| Identité Facebook déjà liée | sign-in | `200` | `existingIdentity` |
| Email match user existant | refus auto-link (E.a) | `409 oauth.linkRefused` | — |
| Nouveau compte | création (`username = f_XXXXXXXX`) | `201` | `registered` |
| Banni | refus | `403` | — |

- **Réponse `401`** — `Invalid Facebook token` (signature/issuer/audience/expiration, ou `debug_token` refuse)
- **Réponse `403`** — `Account banned`
- **Réponse `409`** — `oauth.linkRefused`
- **Réponse `422`** — `flow` manquant/invalide, ou `accessToken`/`idToken` manquant selon le flow, ou `oauth.facebook.emailMissing`

#### Notes

- Facebook **ne fournit aucun signal `email_verified`** sur ses APIs publiques. Hydrogen part du principe que l'email Facebook **n'est pas attesté** — d'où la politique stricte (E.a) et le refus d'auto-link sur email existant.
- Le name/profile.name de Facebook n'est **pas** récupéré : à l'inscription, on ne demande que `id` + `email`, et l'utilisateur complétera son profil ensuite (politique B.a).

---

### `POST /api/auth/login`

Échange un couple `email` + `password` contre un bearer token de session.

- **Auth** : aucune
- **Action** : [LoginAction](../src/Http/Action/Api/Auth/LoginAction.php)
- **Request body** — deux formes acceptées :

**Forme plate :**

```json
{
  "email": "user@example.com",
  "password": "secret"
}
```

**Forme JSON:API :**

```json
{
  "data": {
    "type": "credentials",
    "attributes": {
      "email": "user@example.com",
      "password": "secret"
    }
  }
}
```

- **Réponse `200`** — session créée :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username": "havoc",
      "nickname": "Havoc",
      "email": "user@example.com",
      "displayName": "Havoc",
      "userType": "member",
      "status": "active",
      "isVerified": false,
      "experience": 0,
      "level": 1,
      "levelProgress": 0.00,
      "isConfirmed": true,
      "isBanned": false
      /* + name, firstname, sex, birthdate, bio, joinedAt, ... */
    }
  },
  "meta": {
    "token": "<base64url-token>",
    "sessionId": "<session-uuid>",
    "expiresAt": "2026-07-06T12:34:56+00:00"
  }
}
```

- **Réponse `400`** — corps non-JSON ou non-objet
- **Réponse `401`** — `Invalid credentials` (email inconnu ou mauvais mot de passe — message volontairement ambigu pour ne pas faciliter l'énumération)
- **Réponse `403`** — `Account banned`, `meta.bannedUntil` indique la date de fin (ou `null` si permanent) **ou** `Email not confirmed`, `meta.code = "email.notConfirmed"` (compte créé mais email pas encore validé via `POST /api/auth/confirm-email`)
- **Réponse `422`** — un ou plusieurs `errors[]` avec `source.pointer = /data/attributes/email` ou `/data/attributes/password`
- **Réponse `429`** — `Too many login attempts`. Header `Retry-After: <seconds>` + `meta.retryAfterSeconds` + `meta.triggeredBy` (`"ip"` ou `"email"`). Voir [Rate limiting](#rate-limiting) ci-dessous.

- **Notes** :
  - Le mot de passe est re-hashé automatiquement si le coût bcrypt actuel (`12`) ne correspond pas au hash en base.
  - Le token brut n'est **jamais** stocké en base — seul son SHA-256 (32 octets) l'est.

#### Rate limiting

Sliding window de **15 minutes**, deux buckets indépendants évalués en cascade (email d'abord, puis IP) :

| Bucket | Seuil       | Effet                                                                 |
| ------ | ----------- | --------------------------------------------------------------------- |
| email  | 5 échecs    | Bloque toute tentative pour cet email pendant le reste de la fenêtre. |
| ip     | 20 échecs   | Bloque toute tentative depuis cette IP pendant le reste de la fenêtre.|

- Seuls les **échecs** (`401 Invalid credentials`) incrémentent les compteurs. `400`/`422`/`403` ne comptent pas.
- Un login **réussi** vide le bucket email du compte (slate propre pour l'utilisateur légitime) ; le bucket IP reste — protège contre le credential spray multi-comptes.
- Réponse bloquée : `429` + header HTTP `Retry-After: <secondes>` + `meta.retryAfterSeconds` (même valeur) + `meta.triggeredBy` (`"email"` ou `"ip"`).

```json
{
  "jsonapi": { "version": "1.1" },
  "errors": [
    {
      "status": "429",
      "title":  "Too many login attempts",
      "detail": "Login is temporarily blocked. Try again later.",
      "meta": {
        "retryAfterSeconds": 612,
        "triggeredBy": "email"
      }
    }
  ]
}
```

Limites définies dans [LoginThrottle](../src/Domain/Auth/LoginThrottle.php) (`MAX_PER_EMAIL`, `MAX_PER_IP`, `WINDOW_MINUTES`). Stockage : table `login_attempt` ([migration](../database/migrations/2026_06_06_130000_create_login_attempt_table.sql)).

---

### `GET /api/auth/me`

Retourne l'utilisateur courant à partir du bearer token.

- **Auth** : **requise** (Bearer)
- **Action** : [MeAction](../src/Http/Action/Api/Auth/MeAction.php)
- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username": "havoc",
      "nickname": "Havoc",
      "email": "user@example.com",
      "displayName": "Havoc",
      "userType": "member",
      "status": "active",
      "isVerified": false,
      "experience": 0,
      "level": 1,
      "levelProgress": 0.00,
      "isConfirmed": true,
      "isBanned": false
    }
  }
}
```

- **Réponse `401`** — token absent / mal formé / expiré / révoqué
- **Effet de bord** : chaque appel rafraîchit `last_used_at` et fait glisser `expires_at` de +30 jours.

---

### `GET /api/auth/sessions`

Liste toutes les sessions **actives** (non expirées) de l'utilisateur courant, triées par dernier usage décroissant. Sert à alimenter une page « Mes sessions actives » avec un bouton de révocation par session.

- **Auth** : **requise** (Bearer)
- **Action** : [ListSessionsAction](../src/Http/Action/Api/Auth/ListSessionsAction.php)
- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    {
      "type": "userSessions",
      "id": "<session-uuid-1>",
      "attributes": {
        "createdAt":  "2026-06-06T10:00:00+00:00",
        "lastUsedAt": "2026-06-06T12:30:00+00:00",
        "expiresAt":  "2026-07-06T12:30:00+00:00",
        "userAgent":  "Mozilla/5.0 (...)",
        "ipAddress":  "127.0.0.1",
        "isCurrent":  true
      }
    },
    {
      "type": "userSessions",
      "id": "<session-uuid-2>",
      "attributes": {
        "createdAt":  "2026-06-05T08:15:00+00:00",
        "lastUsedAt": "2026-06-05T20:00:00+00:00",
        "expiresAt":  "2026-07-05T20:00:00+00:00",
        "userAgent":  "Hydrogen-iOS/1.0",
        "ipAddress":  "2a01:e35:...",
        "isCurrent":  false
      }
    }
  ],
  "meta": { "count": 2 }
}
```

- **Réponse `401`** — token absent / invalide
- **Notes** :
  - `ipAddress` est rendu en format lisible (`inet_ntop` sur les octets stockés en `VARBINARY(16)`), IPv4 ou IPv6.
  - `isCurrent` flag la session associée au bearer token de la requête en cours — utile pour griser un bouton « révoquer » dans l'UI.
  - L'appel lui-même fait glisser `expiresAt` de la session courante (effet de bord normal du middleware d'auth).

---

### `DELETE /api/auth/sessions/{id}`

Révoque **une session précise** par son UUID. La session doit appartenir à l'utilisateur courant — sinon `404` (volontaire : ne fuite pas l'existence des sessions d'autres comptes). Si l'`{id}` cible la session courante, le token utilisé devient immédiatement invalide.

- **Auth** : **requise** (Bearer)
- **Action** : [RevokeSessionAction](../src/Http/Action/Api/Auth/RevokeSessionAction.php)
- **Path params** :
  - `id` *(string, UUID)* — id de la session à révoquer (typiquement obtenu via `GET /api/auth/sessions`).
- **Request body** : aucun
- **Réponse `204`** — révoquée avec succès, pas de contenu
- **Réponse `400`** — `{id}` n'est pas un UUID valide
- **Réponse `404`** — aucune session avec cet id n'appartient au user courant (id inconnu **ou** appartient à un autre user — même réponse pour ne pas révéler l'existence)
- **Réponse `401`** — token absent / invalide
- **Notes** :
  - Pour révoquer **toutes les autres sessions** d'un coup, utiliser plutôt `POST /api/auth/logout-all` (mais celui-ci tue aussi la session courante).
  - L'UI typique appelle cet endpoint depuis un bouton « Révoquer » sur chaque ligne de la liste rendue par `GET /api/auth/sessions`.

---

### `POST /api/auth/logout`

Révoque **uniquement la session courante** (celle associée au token utilisé).

- **Auth** : **requise** (Bearer)
- **Action** : [LogoutAction](../src/Http/Action/Api/Auth/LogoutAction.php)
- **Request body** : aucun
- **Réponse `204`** — pas de contenu
- **Réponse `401`** — token absent / invalide

---

### `POST /api/auth/logout-all`

Révoque **toutes les sessions** de l'utilisateur courant (déconnexion de tous les appareils). Le token utilisé pour cet appel est lui-même invalidé.

- **Auth** : **requise** (Bearer)
- **Action** : [LogoutAllAction](../src/Http/Action/Api/Auth/LogoutAllAction.php)
- **Request body** : aucun
- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "sessionRevocations",
    "id": "<user-uuid>",
    "attributes": {
      "revokedCount": 3
    }
  }
}
```

- **Réponse `401`** — token absent / invalide
- **Cas d'usage typiques** : bouton « Se déconnecter de tous les appareils », rotation de mot de passe, suspicion de compromission.

---

## Profil utilisateur

### Attribut `sex` (clé i18n)

Le champ `sex` exposé sur la ressource `users` n'est plus un entier brut mais un **slug i18n stable** :

| Slug API | Code interne (DB `user.sex`) | Clé i18n complète       |
|----------|------------------------------|-------------------------|
| `null`   | `NULL` (non renseigné)       | _(omis)_                |
| `male`   | `0`                          | `users.sex.male`        |
| `female` | `1`                          | `users.sex.female`      |
| `other`  | `2`                          | `users.sex.other`       |

Le client lit `attributes.sex === "male"` puis résout le label localisé via son bundle i18n (`users.sex.male` → « Homme » / « Male » / …). La colonne DB reste un INT (0/1/2) — c'est de la sérialisation pure. Changer un slug est un **breaking change** API.

### `POST /api/users/me/username`

Permet à l'utilisateur courant de remplacer son username **placeholder** par un vrai username, et marque son profil comme complété (`profile_completed_at = NOW()`). Endpoint destiné en priorité aux comptes créés via Google OAuth (qui démarrent avec un username `g_<8-hex>` et `profile_completed_at = NULL`).

- **Auth** : **requise** (Bearer)
- **Action** : [SetUsernameAction](../src/Http/Action/Api/Users/SetUsernameAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "username": "havoc" }
```

- **Validation** : mêmes règles que [`POST /api/auth/register`](#post-apiauthregister) (voir [UsernamePolicy](../src/Domain/User/UsernamePolicy.php)).

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username":           "havoc",
      "email":              "user@gmail.com",
      "isConfirmed":        true,
      "profileCompletedAt": "2026-06-07T10:00:00+00:00"
    }
  }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `401`** — token absent / invalide
- **Réponse `409`** — `Profile already completed`, `meta.code = "profile.alreadyCompleted"`. Pour modifier un username déjà confirmé, utiliser [`POST /api/users/me/username/change`](#post-apiusersmeusernamechange).
- **Réponse `422`** — `username` manquant ou validation échouée. Codes possibles :
  - `username.tooShort`, `username.tooLong`, `username.invalidCharacters`, `username.invalidBoundary`, `username.consecutiveSeparators`, `username.reserved`, `username.alreadyTaken`

- **Notes** :
  - L'update est atomique (`username` + `profile_completed_at` + `updated_at` dans le même `UPDATE`).
  - Le username est normalisé (minuscules, trim) avant validation.
  - L'assignation est aussi enregistrée dans `username_history` (utilisée par `/username/change` pour le cooldown et la quarantaine).

---

### `POST /api/users/me/username/change`

Change le username d'un compte **dont le profil est déjà complété** (`profile_completed_at IS NOT NULL`). Pour les comptes OAuth dont le profil n'est pas encore complété (username placeholder `g_<hex>`), utiliser [`POST /api/users/me/username`](#post-apiusersmeusername).

- **Auth** : **requise** (Bearer)
- **Action** : [ChangeUsernameAction](../src/Http/Action/Api/Users/ChangeUsernameAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "username": "havoc2" }
```

#### Règles ([UsernameChangeService](../src/Domain/User/UsernameChangeService.php))

Vérifications dans l'ordre, refus dès le premier échec :

1. **Profil complété** — sinon `409 username.profileIncomplete`. Utiliser l'endpoint de complétion du profil à la place.
2. **Format** — règles de [UsernamePolicy](../src/Domain/User/UsernamePolicy.php) (3–32 caractères, `[a-z0-9._-]`, bornes alphanumériques, pas de séparateurs consécutifs).
3. **Différent du username actuel** — sinon `409 username.sameAsCurrent`.
4. **Cooldown 30 jours** — au moins 30 jours doivent s'être écoulés depuis la dernière assignation de username pour cet utilisateur (toute la chaîne, pas seulement l'actuel) — sinon `409 username.onCooldown` avec `meta.cooldownUntil` (ISO-8601).
5. **Quarantaine 90 jours** — si ce username a été libéré par **un autre utilisateur** il y a moins de 90 jours, il est en quarantaine — sinon `409 username.quarantined`. **Exception** : l'utilisateur courant peut reprendre un username qu'il a lui-même tenu auparavant, sans attendre la quarantaine.
6. **Disponibilité immédiate** — le username ne doit pas être détenu actuellement par un autre utilisateur — sinon `409 username.alreadyTaken`.

#### Effet de bord

L'update est atomique (transaction) : `user.username` + `user.updated_at` mis à jour, **et** `username_history` reçoit deux opérations :
- la ligne ouverte (où `released_at IS NULL`) est fermée avec `released_at = NOW()` ;
- une nouvelle ligne ouverte est insérée avec `assigned_at = NOW()`.

`profile_completed_at` n'est pas touché.

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "usernameChanges",
    "id": "<user-uuid>",
    "attributes": {
      "previousUsername": "havoc",
      "username":         "havoc2",
      "cooldownDays":     30
    }
  }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `401`** — token absent / invalide
- **Réponse `409`** — code dans `meta.code` :
  - `username.profileIncomplete`
  - `username.sameAsCurrent`
  - `username.onCooldown` (avec `meta.cooldownUntil` et `meta.cooldownDays`)
  - `username.quarantined` (avec `meta.quarantineDays`)
  - `username.alreadyTaken`
- **Réponse `422`** — `username` manquant ou format invalide. Codes : `username.tooShort`, `username.tooLong`, `username.invalidCharacters`, `username.invalidBoundary`, `username.consecutiveSeparators`, `username.reserved`

- **Notes** :
  - Les durées (`COOLDOWN_DAYS = 30`, `QUARANTINE_DAYS = 90`) sont définies en constantes sur [`UsernameChangeService`](../src/Domain/User/UsernameChangeService.php).
  - Le cooldown se calcule sur **la dernière assignation toutes valeurs confondues** (MAX `assigned_at`) — un user qui change pour `X` puis veut changer pour `Y` doit attendre 30 jours.

---

### `PUT /api/users/me/nickname`

Définit ou efface le **nickname** de l'utilisateur courant : le nom d'**affichage** libre, modifiable à volonté et **non unique** (contrairement au `username`, handle unique encadré). Auth requise.

Règle d'affichage : `displayName = nickname` quand il est renseigné, sinon `username` (voir [`User::displayName()`](../src/Domain/User/User.php)). Un nickname vide fait donc retomber l'affichage sur le username.

**Corps** (JSON plat ou `data.attributes`), la clé `nickname` doit être **présente** :

```json
{ "nickname": "Jane Doe" }   // définit
{ "nickname": "" }           // efface (retombe sur le username)
{ "nickname": null }         // efface
```

**Validation** ([NicknamePolicy](../src/Domain/User/NicknamePolicy.php)) — le nickname est volontairement permissif (lettres de tout script, chiffres, espaces, ponctuation/symboles, emojis) :
- normalisation : trim + espaces internes multiples réduits à un seul ; chaîne vide ⇒ `null` (efface) ;
- longueur 1..32 (colonne `user.nickname` = `VARCHAR(32)`) ;
- pas de caractères de contrôle / invisibles (retours ligne, zero-width…).

**Réponse `200`** — la ressource `users` complète (mêmes attributs que [`GET /api/auth/me`](#get-apiauthme)), donc le client voit immédiatement le nouveau `displayName`.

- **Réponse `400`** — corps non-JSON.
- **Réponse `401`** — token absent / invalide.
- **Réponse `422`** — clé `nickname` absente, type invalide (ni string ni null), ou format invalide. Codes : `nickname.tooShort`, `nickname.tooLong`, `nickname.invalidCharacters`.

- **Notes** :
  - **Pas de contrainte d'unicité** : deux utilisateurs peuvent partager un même nickname ; seul le `username` est unique.
  - La blocklist de noms réservés ([`username_blocklist.php`](../config/username_blocklist.php)) ne s'applique **pas** au nickname (choix produit : liberté d'affichage). À activer ici si la lutte contre l'usurpation par nom d'affichage devient nécessaire.

---

### `POST /api/users/me/password`

Définit ou change le mot de passe de l'utilisateur courant.

- **Auth** : **requise** (Bearer)
- **Action** : [SetPasswordAction](../src/Http/Action/Api/Users/SetPasswordAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{
  "currentPassword": "MyOldPassw0rd!",
  "newPassword":     "MyNewPassw0rd!"
}
```

- `currentPassword` est **requis** si l'utilisateur a déjà un mot de passe (`password_set_at IS NOT NULL`), **omis sinon** (compte OAuth qui n'en a jamais défini).
- `newPassword` est toujours requis et doit respecter la [PasswordPolicy](../src/Domain/Auth/PasswordPolicy.php) (mêmes règles que `/auth/register`).

#### Effet de bord

Lors d'un changement réussi, **toutes les autres sessions sont révoquées** (déconnexion de tous les appareils sauf celui en cours). Le token courant reste valide.

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "passwordChanges",
    "id": "<user-uuid>",
    "attributes": {
      "changedAt":             "2026-06-07T10:30:00+00:00",
      "otherSessionsRevoked":  3,
      "hadPasswordBefore":     true
    }
  }
}
```

- **Réponse `401`** :
  - `meta.code = "password.currentRequired"` — `currentPassword` manquant alors qu'il était attendu
  - `meta.code = "password.currentInvalid"` — `currentPassword` incorrect
- **Réponse `409`** — `meta.code = "password.sameAsCurrent"` (le nouveau mot de passe est identique à l'ancien)
- **Réponse `422`** — `newPassword` absent ou échec de la PasswordPolicy. Codes : `password.tooShort`, `password.tooLong`, `password.missingLowercase`, `password.missingUppercase`, `password.missingDigit`, `password.missingSpecial`, `password.invalidCharacters`, `password.invalidBoundary`, `password.containsUsername`, `password.containsEmail`

- **Notes** :
  - Le hash est bcrypt cost=12 (même paramètres que `/auth/register`).
  - `password_set_at` est mis à NOW() — utile pour audit ou logique métier ultérieure ("forcer renouvellement après N mois").
  - La colonne `password` reste NOT NULL même pour les comptes OAuth (qui stockent un hash bcrypt d'octets aléatoires inutilisable). Pour distinguer "a un vrai mot de passe" : `password_set_at IS NOT NULL` / `User::hasPassword()`.

---

### `POST /api/users/me/oauth/google`

Lie une identité Google **au compte courant déjà authentifié**. Utile pour résoudre le cas `409 oauth.linkRefused` retourné par `POST /api/auth/oauth/google` (auto-link refusé par les règles C3) : l'utilisateur se connecte d'abord par mot de passe, puis confirme volontairement le rapprochement.

- **Auth** : **requise** (Bearer)
- **Action** : [LinkGoogleAction](../src/Http/Action/Api/Users/OAuth/LinkGoogleAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "idToken": "<google id_token>" }
```

#### Règles de linkage ([OAuthLinkService](../src/Domain/Auth/OAuth/OAuthLinkService.php))

Vérifications dans l'ordre, refus dès le premier échec :

1. **Email vérifié côté Google** (`email_verified = true`) — sinon `403 oauth.providerEmailNotVerified`.
2. **Email Google = email du compte courant** — sinon `409 oauth.emailMismatch`. Empêche un attaquant de coller son Google sur le compte d'une victime, et empêche une victime trompée de lier le Google d'un attaquant.
3. **Pas déjà une identité Google sur ce compte** — sinon `409 oauth.alreadyLinkedToThisUser`. Un seul compte Google par user.
4. **Le `sub` Google n'est pas déjà lié à un autre user** — sinon `409 oauth.alreadyLinkedToAnotherUser`. La contrainte UNIQUE `(provider, provider_user_id)` garantit aussi cette règle au niveau base.

- **Réponse `201`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "oauthIdentities",
    "id": "<identity-uuid>",
    "attributes": {
      "provider":    "google",
      "emailAtLink": "user@gmail.com",
      "createdAt":   "2026-06-07T10:45:00+00:00"
    }
  }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `401`** — id_token non vérifiable (signature, iss, aud, exp...)
- **Réponse `403`** — `oauth.providerEmailNotVerified`
- **Réponse `409`** — un des trois codes : `oauth.emailMismatch`, `oauth.alreadyLinkedToThisUser`, `oauth.alreadyLinkedToAnotherUser`
- **Réponse `422`** — `idToken` manquant

- **Notes** :
  - Pas d'émission de session — l'utilisateur est déjà connecté avec son token Hydrogen, on ne fait que créer la liaison.
  - Une future requête `POST /api/auth/oauth/google` avec le même id_token signera le user via le chemin `existingIdentity` (200).

---

### `POST /api/users/me/oauth/apple`

Lie une identité Apple au compte courant déjà authentifié. C'est le chemin de récupération après un `409 oauth.linkRefused` retourné par `POST /api/auth/oauth/apple` (rappel : Apple n'est **jamais** auto-lié, politique E.a stricte).

- **Auth** : **requise** (Bearer)
- **Action** : [LinkAppleAction](../src/Http/Action/Api/Users/OAuth/LinkAppleAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "idToken": "<apple id_token>" }
```

#### Règles de linkage

Vérifications dans l'ordre, refus dès le premier échec :

1. **`email_verified` Apple** doit être vrai — sinon `403 oauth.providerEmailNotVerified`.
2. **Email Apple = email du compte courant** — sinon `409 oauth.emailMismatch`. Une alias Private Relay (`@privaterelay.appleid.com`) est traité comme un email normal pour la comparaison stricte.
3. **Pas déjà une identité Apple sur ce compte** — sinon `409 oauth.alreadyLinkedToThisUser`.
4. **Le `sub` Apple n'est pas déjà lié à un autre user** — sinon `409 oauth.alreadyLinkedToAnotherUser`.

- **Réponse `201`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "oauthIdentities",
    "id": "<identity-uuid>",
    "attributes": {
      "provider":     "apple",
      "emailAtLink":  "abc123@privaterelay.appleid.com",
      "emailIsRelay": true,
      "createdAt":    "2026-06-07T10:45:00+00:00"
    }
  }
}
```

- **Réponses d'erreur** : `400`, `401`, `403`, `409` (trois codes), `422` — mêmes formes que pour Google avec `provider: "apple"` dans les messages.

---

### `POST /api/users/me/oauth/facebook`

Lie une identité Facebook au compte courant déjà authentifié. Identique en intention à `oauth/apple` mais accepte les deux flows Facebook.

- **Auth** : **requise** (Bearer)
- **Action** : [LinkFacebookAction](../src/Http/Action/Api/Users/OAuth/LinkFacebookAction.php)
- **Request body** :

```json
// Flux classique
{ "flow": "classic", "accessToken": "<facebook access token>" }

// Flux limited
{ "flow": "limited", "idToken": "<facebook OIDC id_token>" }
```

#### Règles de linkage

1. **Facebook a fourni un email** — sinon `422 oauth.facebook.emailMissing`. Pas de signal `email_verified` côté Facebook (cf. matrice), donc l'étape Google « providerEmailNotVerified » est sans objet ici.
2. **Email Facebook = email du compte courant** — sinon `409 oauth.emailMismatch`.
3. **Pas déjà une identité Facebook sur ce compte** — sinon `409 oauth.alreadyLinkedToThisUser`.
4. **Le `sub` Facebook n'est pas déjà lié à un autre user** — sinon `409 oauth.alreadyLinkedToAnotherUser`.

- **Réponse `201`** — même forme avec `provider: "facebook"`, `emailIsRelay: false`.
- **Réponse `401`** — token non vérifiable.
- **Réponse `409`** — codes `oauth.emailMismatch` / `oauth.alreadyLinkedToThisUser` / `oauth.alreadyLinkedToAnotherUser`.
- **Réponse `422`** — `flow` invalide, token manquant pour le flux choisi, ou `oauth.facebook.emailMissing`.

---

### `GET /api/users/me/oauth-identities`

Liste toutes les identités OAuth liées au compte courant. Renvoie une **collection JSON:API**, possiblement vide pour un compte créé par mot de passe sans lien OAuth.

- **Auth** : **requise** (Bearer)
- **Action** : [ListOAuthIdentitiesAction](../src/Http/Action/Api/Users/OAuth/ListOAuthIdentitiesAction.php)
- **Pas de body** (GET)

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    {
      "type": "oauthIdentities",
      "id":   "<identity-uuid>",
      "attributes": {
        "provider":     "google",
        "emailAtLink":  "user@gmail.com",
        "emailIsRelay": false,
        "linkedAt":     "2026-06-07T10:45:00+00:00"
      }
    }
  ],
  "meta": { "count": 1 }
}
```

- **Réponse `401`** — token absent / invalide

- **Notes** :
  - Tri par `created_at ASC` (plus ancienne liaison en premier).
  - `provider_user_id` (le `sub` du provider) n'est **pas** exposé — il n'a aucun usage côté client.
  - `emailAtLink` est l'email retourné par le provider **au moment de la liaison** (peut différer de l'email actuel du user).
  - `emailIsRelay` vaut `true` uniquement pour les identités Apple créées avec un alias Private Relay (`@privaterelay.appleid.com`). Toujours `false` pour Google et Facebook.

---

### `GET /api/users/me/settings`

Retourne le snapshot complet des préférences de l'utilisateur courant : les valeurs par défaut **fusionnées** avec les éventuels overrides stockés en base. Le client n'a jamais à gérer le cas « préférence pas encore définie » — chaque clé connue est toujours présente.

- **Auth** : **requise** (Bearer)
- **Action** : [GetSettingsAction](../src/Http/Action/Api/Users/GetSettingsAction.php)
- **Pas de body** (GET)

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "userSettings",
    "id":   "<user-uuid>",
    "attributes": {
      "locale":                "fr-FR",
      "timezone":              "Europe/Paris",
      "theme":                 "system",
      "currency":              "EUR",
      "profileVisibility":     "public",
      "notificationsEmail":    true,
      "notificationsPush":     true,
      "showSensitiveContent":  false,
      "emailVisibleOnProfile": false
    }
  }
}
```

- **Réponse `401`** — token absent / invalide

#### Catalogue des clés

Définies dans [`UserSettingRegistry`](../src/Domain/User/Preference/UserSettingRegistry.php) :

| Clé                      | Type     | Défaut          | Valeurs autorisées |
| ------------------------ | -------- | --------------- | ------------------ |
| `locale`                 | locale   | `fr-FR`         | locale **effectivement supportée** par l'app (voir [GET /api/i18n/locales](#get-apii18nlocales)) |
| `timezone`               | timezone | `Europe/Paris`  | identifiant IANA (`DateTimeZone::listIdentifiers()`) |
| `theme`                  | enum     | `system`        | `light`, `dark`, `system` |
| `currency`               | currency | `EUR`           | code ISO 4217 présent dans la table `currency` |
| `profileVisibility`      | enum     | `public`        | `public`, `private` |
| `notificationsEmail`     | bool     | `true`          | — |
| `notificationsPush`      | bool     | `true`          | — |
| `showSensitiveContent`   | bool     | `false`         | — |
| `emailVisibleOnProfile`  | bool     | `false`         | — |

---

### `PATCH /api/users/me/settings`

Met à jour partiellement les préférences de l'utilisateur courant.

- **Auth** : **requise** (Bearer)
- **Action** : [UpdateSettingsAction](../src/Http/Action/Api/Users/UpdateSettingsAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) — toutes les clés sont **optionnelles**, seules les clés présentes sont touchées :

```json
{
  "theme":              "dark",
  "notificationsEmail": false
}
```

#### Sémantique « tout ou rien »

Si **une seule** clé/valeur échoue à la validation, **aucune** écriture n'a lieu et la réponse `422` énumère toutes les erreurs. Le client ne se retrouve jamais avec un état mi-appliqué.

#### Stockage « overrides only » (D.c)

La table `user_preference` ne stocke **que** les overrides — pas les défauts.
- Si la valeur écrite **égale** le défaut actuel, la ligne correspondante est **supprimée**.
- Sinon, elle est upsertée (`INSERT … ON DUPLICATE KEY UPDATE`).

Conséquence : changer un défaut dans le code propage automatiquement à tous les comptes qui n'ont pas d'override explicite.

- **Réponse `200`** — snapshot complet **post-update** + `meta.appliedKeys` listant les clés réellement écrites/supprimées :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "userSettings",
    "id":   "<user-uuid>",
    "attributes": {
      "locale":                "fr-FR",
      "timezone":              "Europe/Paris",
      "theme":                 "dark",
      "currency":              "EUR",
      "profileVisibility":     "public",
      "notificationsEmail":    false,
      "notificationsPush":     true,
      "showSensitiveContent":  false,
      "emailVisibleOnProfile": false
    }
  },
  "meta": { "appliedKeys": ["theme", "notificationsEmail"] }
}
```

- **Réponse `400`** — corps non-JSON ou non-objet
- **Réponse `422`** — un ou plusieurs `errors[]` avec `source.pointer = /data/attributes/<key>` et `meta.code` parmi :
  - `setting.unknown` — clé inconnue du registry
  - `setting.expectedBool` — la valeur d'une clé booléenne n'est pas un `true`/`false`
  - `setting.invalidEnumValue` — valeur absente de la liste autorisée
  - `setting.invalidLocale` — locale non supportée par l'app (cf. [GET /api/i18n/locales](#get-apii18nlocales))
  - `setting.invalidTimezone` — identifiant IANA inconnu
  - `setting.invalidCurrency` — code ISO 4217 inconnu de la table `currency`

- **Notes** :
  - Un body vide (`{}`) est accepté et retourne `200` avec `appliedKeys: []`.
  - Les booléens sont strictement typés : les chaînes `"true"`/`"false"` sont rejetées (`setting.expectedBool`).
  - Le ramassage des erreurs est exhaustif : tous les champs invalides sont signalés en un seul appel, pas seulement le premier.

---

### `POST /api/users/me/avatar`

Téléverse (ou remplace) l'avatar de l'utilisateur authentifié.

- **Auth** : requise (Bearer)
- **Action** : [UploadAvatarAction](../src/Http/Action/Api/Users/UploadAvatarAction.php)
- **Body** : `multipart/form-data` avec un unique champ fichier `avatar`.
  - Formats acceptés : JPEG, PNG, WebP, GIF, HEIC, HEIF.
  - Taille maximale : `AVATAR_MAX_UPLOAD_BYTES` (par défaut 256 000 octets / 256 ko).
- **Pipeline** ([AvatarUploadService](../src/Domain/User/Avatar/AvatarUploadService.php)) :
  1. Validation (taille, code d'erreur PSR-7, non vide).
  2. Décodage Imagick — un fichier non décodable ⇒ `422 invalidImage`.
  3. Whitelist du format source. Animations (GIF/HEIF) : seule la **première frame** est conservée.
  4. Auto-orientation EXIF puis **strip** complet des métadonnées (GPS, appareil, etc. — privacy).
  5. Redimension `bestfit` à `AVATAR_MAX_DIMENSION` (256px par défaut ; pas d'upscale).
  6. Encodage WebP qualité `AVATAR_WEBP_QUALITY` (80 par défaut, `webp:method=6`).
  7. Écriture atomique (`tmp + rename`) sous `<AVATAR_STORAGE_PATH>/user/AA/BB/CC/<uuid-hex>/avatar.webp` (3 niveaux de dossiers ventilés à partir des 6 premiers caractères hex de l'UUID).
  8. Mise à jour de `user.avatar_updated_at` (cache-buster).
  9. Re-hydratation de l'utilisateur.

- **Réponse `200 OK`** : ressource `users` complète avec `avatarUrl` actualisée (et son cache-buster `?v=<timestamp>`).

```json
{
  "data": {
    "type": "users",
    "id": "<uuid>",
    "attributes": {
      "avatarUrl": "http://hexatrip-static.dev.com/user/c2/1e/78/c21e7856eb524c8cb9a7786a2f80ce7e/avatar.webp?v=1812345678",
      "hasAvatar": true,
      "…": "…"
    }
  }
}
```

- **Réponses d'erreur** (mapping `AvatarErrorCode` → HTTP) :
  - `400` `avatar.uploadFailed` — échec côté transport multipart
  - `413` `avatar.tooLarge` — fichier > `AVATAR_MAX_UPLOAD_BYTES`
  - `415` `avatar.unsupportedFormat` — format hors whitelist
  - `422` `avatar.empty` — pas de fichier ou octets vides (`source.pointer = /data/attributes/avatar`)
  - `422` `avatar.invalidImage` — octets non décodables comme image
  - `500` `avatar.encodingFailed` — Imagick a refusé de produire le WebP
  - `500` `avatar.storageWriteFailed` — écriture disque échouée

- **Notes** :
  - L'avatar est **toujours écrit à la même URL** (`avatar.webp`). C'est `?v=<unix-ts>` (basé sur `avatar_updated_at`) qui force l'invalidation cache navigateur/CDN entre deux uploads.
  - Quand l'utilisateur n'a pas d'avatar, `avatarUrl` pointe vers `<AVATAR_PUBLIC_URL>/user/default-avatar.webp` (pas de cache-buster — c'est un fichier ops).

---

### `DELETE /api/users/me/avatar`

Supprime l'avatar de l'utilisateur authentifié (fichier sur disque + timestamp en base). L'utilisateur retombe sur l'avatar par défaut partagé.

- **Auth** : requise (Bearer)
- **Action** : [DeleteAvatarAction](../src/Http/Action/Api/Users/DeleteAvatarAction.php)
- **Body** : aucun
- **Idempotent** : appelé sur un compte qui n'a déjà plus d'avatar, retourne quand même `200`.
- **Réponse `200 OK`** : ressource `users` complète avec `avatarUrl` ré-orientée vers le default avatar et `hasAvatar: false`.

---

### `POST /api/users/me/cover`

Téléverse (ou remplace) la cover de profil de l'utilisateur authentifié.

- **Auth** : requise (Bearer)
- **Action** : [UploadCoverAction](../src/Http/Action/Api/Users/UploadCoverAction.php)
- **Body** : `multipart/form-data` avec un unique champ fichier `cover`.
  - Formats acceptés : JPEG, PNG, WebP, GIF, HEIC, HEIF.
  - Taille maximale : `COVER_MAX_UPLOAD_BYTES` (par défaut 600 000 octets / 600 ko).
- **Pipeline** ([CoverUploadService](../src/Domain/User/Cover/CoverUploadService.php)) : identique à l'upload d'avatar, à deux différences près :
  - Redimension `bestfit` à l'intérieur de `COVER_MAX_WIDTH × COVER_MAX_HEIGHT` (par défaut 1500 × 500 px), **aspect ratio conservé** — une image 3000 × 800 sera réduite à 1500 × 400 (pas d'upscale, pas de crop).
  - Le fichier est écrit dans le **même dossier ventilé que l'avatar**, mais sous le nom `cover.webp` :
    `<AVATAR_STORAGE_PATH>/user/AA/BB/CC/<uuid-hex>/cover.webp`.
- **Effet de bord** : `user.cover_updated_at` est mis à jour (cache-buster URL).

- **Réponse `200 OK`** : ressource `users` complète avec `coverUrl` actualisée (et son cache-buster `?v=<timestamp>`).

```json
{
  "data": {
    "type": "users",
    "id": "<uuid>",
    "attributes": {
      "coverUrl": "http://hexatrip-static.dev.com/user/c2/1e/78/c21e7856eb524c8cb9a7786a2f80ce7e/cover.webp?v=1812345678",
      "hasCover": true,
      "…": "…"
    }
  }
}
```

- **Réponses d'erreur** (mapping `CoverErrorCode` → HTTP) :
  - `400` `cover.uploadFailed` — échec côté transport multipart
  - `413` `cover.tooLarge` — fichier > `COVER_MAX_UPLOAD_BYTES`
  - `415` `cover.unsupportedFormat` — format hors whitelist
  - `422` `cover.empty` — pas de fichier ou octets vides (`source.pointer = /data/attributes/cover`)
  - `422` `cover.invalidImage` — octets non décodables comme image
  - `500` `cover.encodingFailed` — Imagick a refusé de produire le WebP
  - `500` `cover.storageWriteFailed` — écriture disque échouée

- **Notes** :
  - Comme pour l'avatar, l'URL est toujours `cover.webp` ; c'est `?v=<unix-ts>` qui force l'invalidation cache.
  - Sans cover, `coverUrl` pointe vers `<AVATAR_PUBLIC_URL>/user/default-cover.webp` (sans cache-buster).

---

### `DELETE /api/users/me/cover`

Supprime la cover de l'utilisateur authentifié (fichier sur disque + timestamp en base). L'utilisateur retombe sur la cover par défaut partagée.

- **Auth** : requise (Bearer)
- **Action** : [DeleteCoverAction](../src/Http/Action/Api/Users/DeleteCoverAction.php)
- **Body** : aucun
- **Idempotent** : appelé sur un compte qui n'a déjà plus de cover, retourne quand même `200`.
- **Réponse `200 OK`** : ressource `users` complète avec `coverUrl` ré-orientée vers la default cover et `hasCover: false`.

---

## QR code de profil

QR code stylisé (PNG) encodant l'URL de la page profil publique `/@{username}`, avec l'avatar de l'utilisateur en son centre. Donnée **publique**.

### `GET /qrcode/{username}.png`

- **Auth** : aucune (gating identique à la page profil : `404` si l'utilisateur n'existe pas, n'est pas confirmé, est banni ou supprimé).
- **Action** : [ShowUserProfileQrcodeAction](../src/Http/Action/Web/ShowUserProfileQrcodeAction.php)
- **Réponse `200 OK`** : `image/png`, `Cache-Control: public, max-age=86400`, `ETag` ; renvoie `304` sur `If-None-Match` concordant.
- **Génération** : à la volée puis mise en cache disque, la clé de cache embarquant la version de l'avatar — remplacer/supprimer l'avatar régénère le QR.

### Attribut `qrcodeUrl`

L'URL de ce QR code est exposée comme attribut **public** `qrcodeUrl` sur **toutes** les API qui renvoient un utilisateur :

- ressource JSON:API `users` (`/api/auth/me`, `/api/auth/login`, `/api/auth/register`, `/api/users/*`, …),
- bloc public [`author`](#auteur-public-author-sur-la-ressource-medias) inliné sur les ressources `medias`,
- back-office (`GET /admin/users`, `GET /admin/users/{hex}`).

Format : `{APP_URL}/qrcode/{username}.png`. Un cache-buster `?v=<timestamp>` (issu de `avatar_updated_at`) n'est ajouté que si l'utilisateur a un avatar.

---

## Adresse postale (privée)

Adresse postale stockée dans la table `user_address` (clé primaire = `user_id`, donc **0 ou 1** adresse par utilisateur). Données strictement **privées** : aucun endpoint public ni paramétré ne les expose ; seules les 3 routes ci-dessous, toutes derrière l'`AuthenticationMiddleware`, opèrent — et toujours sur l'utilisateur authentifié, jamais sur un id transmis dans l'URL.

Champs exposés aujourd'hui (les colonnes `city_id` et `region` du schéma sont délibérément ignorées en attendant la fonctionnalité d'autocomplétion ville) :

| Attribut       | Type           | Max | Notes |
|----------------|----------------|-----|-------|
| `unitNumber`   | string \| null | 10  | Appartement / étage / lot. |
| `streetNumber` | string \| null | 10  | Numéro de voie. |
| `addressLine1` | string \| null | 150 | Ligne principale (rue, voie). |
| `addressLine2` | string \| null | 150 | Complément (bâtiment, résidence). |
| `postalCode`   | string \| null | 10  | Code postal. |
| `updatedAt`    | string \| null | —   | ISO 8601, `null` tant qu'aucun update n'a eu lieu. |
| `createdAt`    | string \| null | —   | ISO 8601, `null` si l'utilisateur n'a jamais rempli son adresse. |

**Sémantique des mutations** :
- `PATCH` est strictement partiel : un champ absent n'est pas touché.
- Un champ explicitement `null` (ou chaîne vide après trim) **efface** la colonne (= NULL en base).
- `DELETE` retire la ligne entière (retour à "aucune adresse"). Idempotent.

**Validation** : tout-ou-rien. La moindre erreur sur un champ rejette le PATCH complet en `422` (mirror de `PATCH /users/me/settings`).

---

### `GET /api/users/me/address`

- **Auth** : requise (Bearer)
- **Action** : [GetMyAddressAction](../src/Http/Action/Api/Users/GetMyAddressAction.php)
- **Réponse `200 OK`** — forme stable même quand aucune ligne n'existe (tous les champs sont alors `null`) :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "userAddresses",
    "id":   "<userUuid>",
    "attributes": {
      "unitNumber":   "3A",
      "streetNumber": "12",
      "addressLine1": "rue de Rivoli",
      "addressLine2": null,
      "postalCode":   "75001",
      "updatedAt":    "2026-06-07T12:34:56+00:00",
      "createdAt":    "2026-06-07T10:00:00+00:00"
    }
  }
}
```

---

### `PATCH /api/users/me/address`

Crée la ligne si elle n'existe pas encore (upsert implicite via `ON DUPLICATE KEY UPDATE`).

- **Auth** : requise (Bearer)
- **Action** : [UpdateMyAddressAction](../src/Http/Action/Api/Users/UpdateMyAddressAction.php)
- **Body** (flat ou `data.attributes`) :

```json
{ "addressLine1": "12 rue de Rivoli", "postalCode": "75001" }
```

- **Effacer un champ** : envoyer `null` ou `""` :

```json
{ "addressLine2": null }
```

- **Erreurs `422`** (par champ, avec `meta.code`) :
  - `address.unknownField`         — clé hors whitelist
  - `address.expectedStringOrNull` — valeur non scalaire (number, array, bool…)
  - `address.tooLong`              — dépasse la longueur maxi de la colonne
- **Erreur `400`** : corps non-JSON ou non-objet.
- **Réponse `200 OK`** : snapshot complet post-update + `meta.appliedKeys` (clés camelCase effectivement écrites).

---

### `DELETE /api/users/me/address`

- **Auth** : requise (Bearer)
- **Action** : [DeleteMyAddressAction](../src/Http/Action/Api/Users/DeleteMyAddressAction.php)
- **Idempotent** : `204` même si l'utilisateur n'avait pas d'adresse.
- **Réponse `204 No Content`** (corps vide).

---

## RGPD / suppression de compte

Trois endpoints couvrent les droits RGPD self-service de l'utilisateur : **portabilité** (export de ses données) et **droit à l'effacement** (suppression de compte). La suppression suit un modèle **soft-delete + période de grâce** : le compte est marqué supprimé immédiatement (et désactivé), puis effacé physiquement par un cron au-delà de la fenêtre de grâce. Tant que la grâce n'est pas écoulée, **se reconnecter réactive automatiquement le compte** (la réactivation est centralisée dans `SessionService::create()`).

| Variable d'environnement | Défaut | Rôle |
|--------------------------|--------|------|
| `ACCOUNT_DELETION_GRACE_DAYS` | `30` | Jours pendant lesquels un compte soft-deleted reste restaurable avant purge irréversible. |
| `ACCOUNT_DELETION_TOKEN_TTL_HOURS` | `24` | Validité du token e-mail de confirmation (comptes OAuth sans mot de passe). |
| `ACCOUNT_PURGE_BATCH_SIZE` | `50` | Comptes traités par tick de `bin/account-purge.php`. |
| `ACCOUNT_PURGE_MEDIA_BATCH_SIZE` | `100` | Médias effacés par page lors de la purge d'un compte. |

**Purge** (`bin/account-purge.php`, à planifier quotidiennement) : pour chaque compte dont la grâce est écoulée, tous les médias possédés sont supprimés (fichiers + DB + Meilisearch via `MediaDeleteService`), la ligne `user` est **anonymisée** (e-mail/username scrambés, champs PII nullés, `purged_at` horodaté) plutôt que physiquement supprimée — pour éviter un CASCADE FK non maîtrisé sur une table `user` seedée en externe — et le document Meilisearch `users` est retiré. L'opération est idempotente.

**Limitation connue** : pendant la période de grâce, le document Meilisearch `users` n'est **pas** retiré (il appartient à un pipeline externe et ne pourrait pas être recréé en cas de réactivation) ; seul l'authentification est bloquée. Le document n'est supprimé qu'à la purge définitive.

---

### `GET /api/users/me/export`

Export de portabilité RGPD : renvoie l'intégralité des données de l'utilisateur authentifié sous forme de fichier JSON téléchargeable (et **non** une ressource JSON:API — la charge utile est une archive, pas une entité d'API).

- **Auth** : requise (Bearer)
- **Action** : [ExportMyDataAction](../src/Http/Action/Api/Users/ExportMyDataAction.php)
- **Réponse `200 OK`** :
  - `Content-Type: application/json; charset=utf-8`
  - `Content-Disposition: attachment; filename="hydrogen-export-<userHex>-<YYYYMMDD>.json"`
- **Structure du corps** :

```json
{
  "generatedAt": "2026-06-16T12:00:00+00:00",
  "profile":  { "id": "…", "username": "…", "email": "…", "...": "tous les champs du profil" },
  "activity": { "followers": 0, "following": 0, "medias": 0, "albums": 0, "comments": 0 },
  "medias":   [ { "id": "…", "name": "…", "type": "image", "isPublished": true, "latitude": null, "longitude": null, "shotAt": null, "createdAt": "…" } ]
}
```

---

### `DELETE /api/users/me`

Demande de suppression du compte authentifié.

- **Auth** : requise (Bearer)
- **Action** : [RequestAccountDeletionAction](../src/Http/Action/Api/Users/RequestAccountDeletionAction.php)
- **Corps** (optionnel, plat ou JSON:API) :

```json
{ "currentPassword": "<mot de passe actuel>" }
```

- **Comportement selon le type de compte** :
  - **Compte avec mot de passe** : `currentPassword` est **obligatoire**. Le compte est soft-deleted immédiatement et toutes ses sessions sont révoquées.
  - **Compte OAuth-only** (sans mot de passe) : aucun mot de passe à vérifier ; un e-mail de confirmation contenant un lien à usage unique est envoyé. La suppression n'est effective qu'après [`POST /api/account/deletion/confirm`](#post-apiaccountdeletionconfirm).
- **Réponses** :
  - `200 OK` — compte soft-deleted (`status: "deleted"`). Si déjà en attente de suppression : `meta.alreadyPending = true`.
  - `202 Accepted` — e-mail de confirmation envoyé (compte OAuth-only).
  - `401 Unauthorized` — `currentPassword` manquant (`meta.code = "account.passwordRequired"`) ou invalide (`meta.code = "account.invalidPassword"`).

---

### `POST /api/account/deletion/confirm`

Confirme une suppression de compte via le token reçu par e-mail — chemin de **fallback** pour les comptes OAuth-only sans mot de passe.

- **Auth** : aucune (le token autorise l'action). **POST** volontaire (jamais GET) afin qu'un prefetch de lien par un scanner d'e-mail ne déclenche pas une suppression irréversible.
- **Action** : [ConfirmAccountDeletionAction](../src/Http/Action/Api/Account/ConfirmAccountDeletionAction.php)
- **Corps** (plat ou JSON:API) :

```json
{ "token": "<token base64url issu du lien e-mail>" }
```

- **Réponses** :
  - `200 OK` — compte soft-deleted, période de grâce démarrée (`status: "deleted"`, `confirmedAt`).
  - `410 Gone` — token inconnu, expiré ou déjà utilisé (`meta.code = "account.deletionTokenInvalid"`).
  - `422 Unprocessable Entity` — `token` absent du corps.

---

## Followers / découverte

Les compteurs `num_user_follower` / `num_user_followed` sont maintenus **côté MySQL** par les triggers `t_add_follower` et `t_delete_follower` attachés à `user_follow`. Hydrogen n'écrit jamais directement dans `user_stats` ; à la place, après chaque INSERT/DELETE sur `user_follow`, le service de follow pousse une mise à jour partielle vers l'index Meilisearch `users` (paramétrable via `MEILISEARCH_USERS_INDEX`, défaut `users_dev`) — best-effort, n'annule jamais l'écriture SQL.

**Pagination** : keyset opaque sur `(followed_at, peerId)`. Paramètres `?cursor=…` (page suivante), `?before=…` (page précédente), `?limit=<1..100>` (défaut `20`). La navigation se fait via l'objet `links` JSON:API (`self/first/prev/next`) — voir [conventions générales](#conventions-générales). Un curseur malformé renvoie `400`.

**Ressource `users`** : la ressource utilisateur retournée dans ces listings inclut, en plus des attributs habituels, `followersCount`, `followingCount`, `mediaCount`, `albumCount`, `commentsCount`, `likesReceivedCount`, `viewsCount`, `impressionsCount` (lus depuis `user_stats`). `likesReceivedCount` est un compteur **monotone** (jamais décrémenté) du cumul des `j'aime` reçus sur l'ensemble des médias de l'utilisateur, maintenu par le trigger SQL `trg_media_reaction_ai_likes_received`. L'attribut `followedAt` reflète la date de mise en relation (ISO 8601). `viewsCount` et `impressionsCount` sont maintenus de manière asynchrone — voir [Compteurs de profil (views + impressions)](#compteurs-de-profil-views--impressions).

**Flags de relation (viewer authentifié)** : quand la requête transporte un Bearer valide, chaque ressource `users` de ces listings porte trois booléens calculés en deux requêtes batch (une par direction) :

| Flag | Signification |
|------|---------------|
| `isFollowedByMe` | le viewer suit ce pair |
| `isFollowingMe`  | ce pair suit le viewer (réciprocité entrante) |
| `isMutual`       | les deux à la fois (dérivé) |

Sur les endpoints **publics** `/users/{userId}/*` sans Bearer, ces trois flags sont **omis** (pas de contexte viewer) — ils ne sont jamais `null`, simplement absents. Pour interroger la relation avec **un seul** utilisateur sans paginer une liste, voir [`GET /api/users/{userId}/relationship`](#get-apiusersuseridrelationship).

---

### `POST /api/users/me/following/{targetUserId}`

Suit la cible.

- **Auth** : requise (Bearer)
- **Action** : [FollowUserAction](../src/Http/Action/Api/Users/FollowUserAction.php)
- **Path** : `{targetUserId}` UUID canonique (8-4-4-4-12).
- **Idempotent** : si la relation existe déjà, `200` quand même, aucun compteur n'est bumped.
- **Règles métier** :
  - Auto-follow interdit (`422` `follow.selfFollow`)
  - Cible inexistante (`404` `follow.targetNotFound`)
  - Cible bannie (`403` `follow.targetBanned`)
  - Acteur non confirmé (`403` `follow.actorNotConfirmed`)
  - Acteur banni (`403` `follow.actorBanned`)
  - **Rate limit** (`429` `follow.rateLimited`) : au-delà de `FOLLOW_MAX_PER_WINDOW` nouveaux follows (défaut **30**) sur une fenêtre glissante de `FOLLOW_WINDOW_MINUTES` minute(s) (défaut **1**), la réponse renvoie `429` avec l'en-tête `Retry-After` (secondes) et `meta.retryAfterSeconds`. Seuls les follows **réellement créés** consomment le budget — les re-follows idempotents et les unfollows ne comptent pas.
- **Réponse `200 OK`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "userFollows",
    "id":   "<followerUuid>:<targetUuid>",
    "attributes": {
      "followerId": "0193f4a2-…",
      "targetId":   "0193f4b0-…"
    }
  }
}
```

---

### `DELETE /api/users/me/following/{targetUserId}`

Cesse de suivre la cible.

- **Auth** : requise (Bearer)
- **Action** : [UnfollowUserAction](../src/Http/Action/Api/Users/UnfollowUserAction.php)
- **Idempotent** : appelé sur une relation inexistante, retourne quand même `200` (aucun compteur impacté).
- **Réponse `200 OK`** : ressource `userFollows` avec `unfollowed: true`.

---

### `GET /api/users/me/following`

Liste paginée des utilisateurs que l'utilisateur authentifié suit (`followed_at DESC`).

- **Auth** : requise (Bearer)
- **Action** : [ListMyFollowingAction](../src/Http/Action/Api/Users/ListMyFollowingAction.php)
- **Query** : `?cursor=<opaque>` (page suivante), `?before=<opaque>` (page précédente), `?limit=<1..100>` (défaut `20`)
- **Réponse `200 OK`** : `data[]` de ressources `users` (avec `followedAt`, `followersCount`, `followingCount`, et les flags de relation `isFollowedByMe = true` / `isFollowingMe` / `isMutual`), navigation via l'objet `links`. `meta` : `{ "limit": 20, "total": <user_stats.following> }` — le compteur exact est lu sur `user_stats` (maintenu par triggers MySQL), donc gratuit.

---

### `GET /api/users/me/followers`

Liste paginée des utilisateurs qui suivent l'utilisateur authentifié.

- **Auth** : requise (Bearer)
- **Action** : [ListMyFollowersAction](../src/Http/Action/Api/Users/ListMyFollowersAction.php)
- **Query** : idem.
- **Réponse `200 OK`** : `data[]` de ressources `users` ; `isFollowedByMe` indique si je suis aussi ce follower (réciprocité), `isFollowingMe = true` par définition (ce sont mes followers) et `isMutual = isFollowedByMe`. `meta.total = <user_stats.followers>` (compteur dénormalisé).

---

### `GET /api/users/{userId}/following`

Liste publique des utilisateurs que `{userId}` suit.

- **Auth** : aucune
- **Action** : [ListUserFollowingAction](../src/Http/Action/Api/Users/ListUserFollowingAction.php)
- **Path** : `{userId}` UUID canonique.
- **Erreurs** : `422` si UUID invalide, `404` si l'utilisateur n'existe pas.
- **Réponse `200 OK`** : ressources `users` **sans** `isFollowedByMe` (pas de viewer authentifié). `meta.total = <user_stats.following>` de l'anchor.

---

### `GET /api/users/{userId}/followers`

Liste publique des utilisateurs qui suivent `{userId}`.

- **Auth** : aucune
- **Action** : [ListUserFollowersAction](../src/Http/Action/Api/Users/ListUserFollowersAction.php)
- Mêmes erreurs / forme de réponse que ci-dessus. `meta.total = <user_stats.followers>` de l'anchor.

---

### `GET /api/users/{userId}/relationship`

Sonde **en un seul appel** l'état de la relation entre le viewer authentifié et `{userId}` — évite au client de différ deux listings pour connaître la réciprocité.

- **Auth** : requise (Bearer) — la réponse est relative au viewer.
- **Action** : [GetUserRelationshipAction](../src/Http/Action/Api/Users/GetUserRelationshipAction.php)
- **Path** : `{userId}` UUID canonique.
- **Erreurs** : `422` si UUID invalide, `404` si l'utilisateur n'existe pas.
- **Cas particulier** : relation avec soi-même → `isSelf = true`, tous les flags `false` (aucune ligne `user_follow` n'existe), pas de lookup.
- **Réponse `200 OK`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "relationships",
    "id":   "<viewerUuid>:<targetUuid>",
    "attributes": {
      "targetId":       "0193f4b0-…",
      "isFollowedByMe": true,
      "isFollowingMe":  false,
      "isMutual":       false,
      "isSelf":         false
    }
  }
}
```

---

### `GET /api/users/search`

Recherche / découverte d'utilisateurs via l'index Meilisearch (`MEILISEARCH_USERS_INDEX`).

- **Auth** : aucune (si la requête transporte malgré tout un Bearer valide, `isFollowedByMe` est calculé pour le viewer).
- **Action** : [SearchUsersAction](../src/Http/Action/Api/Users/SearchUsersAction.php)
- **Query** :
  - `?q=<texte>` : full-text optionnel sur `username` / `nickname`. Vide = pur browse.
  - `?sort=popular` → tri par `stats.num_user_follower:desc`
  - `?sort=recent`  → tri par `joined_at:desc`
  - sinon : pertinence Meilisearch.
  - `?limit=<1..100>` (défaut `20`), `?offset=<int>` (défaut `0`).
- **Pipeline** : Meilisearch retourne des `id` (UUID hex), Hydrogen ré-hydrate les entités `User` et `UserStats` depuis MySQL pour garantir une forme `users` strictement identique aux autres endpoints (avatar/cover résolus, `isBanned/isConfirmed` à jour…).
- **Réponse `200 OK`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [ { "type": "users", "id": "…", "attributes": { … } } ],
  "links": {
    "self":  "https://api.example/api/users/search?limit=20&q=alice",
    "first": "https://api.example/api/users/search?limit=20&q=alice",
    "prev":  null,
    "next":  "https://api.example/api/users/search?limit=20&offset=20&q=alice",
    "last":  "https://api.example/api/users/search?limit=20&offset=40&q=alice"
  },
  "meta": {
    "totalHits": 42,
    "limit":     20,
    "offset":    0,
    "query":     "alice"
  }
}
```

- **Pagination** : offset-based (Meilisearch). Navigation via `links.{self,first,prev,next,last}` — `links.last` est disponible ici (contrairement aux endpoints keyset) car `meta.totalHits` permet de le calculer.
- **`503`** : si Meilisearch est indisponible/inconnu (l'erreur API est propagée dans `errors[].detail`).

---

## Photos / médias

Pipeline de gestion des photos uploadées par les utilisateurs **confirmés**.
Les images vivent dans `hxa.media` (1 ligne = 1 photo), accompagnées de
trois tables annexes sur la connexion back-office (`hxa_bo`) :

- `media_meta` : `mime_type`, `size`, `width`, `height`, `brand`, `model`
- `media_exif` : blob JSON des sections EXIF/GPS/IFD0
- `media_perceptual_hash` : pHash 16 hex éclaté en 4 shards CHAR(4) pour
  rendre la recherche de doublons indexable (filtre MySQL grossier, puis
  Hamming complet en PHP).

Le fichier WebP canonique et son compagnon blurhash 16 px sont écrits
sous `MEDIA_STORAGE_PATH` ; l'original (JPEG/PNG/HEIC/…) est archivé
intact sous `MEDIA_ORIGINALS_PATH`. Le chemin est ventilé sur les 3
premières paires d'octets du hex de l'UUID :

```
<root>/AA/BB/CC/<32-hex>.webp
<root>/AA/BB/CC/<32-hex>-blurhash.webp
<root-originals>/AA/BB/CC/<32-hex>.<ext-source>
```

Un job AI externe (Talend) reprend les uploads, tague la photo et flippe
`is_published = 1` quand elle est validée. Tant qu'`is_published = 0`,
la photo n'est visible **que pour son propriétaire** (`GET /api/users/me/media`)
et reste exclue des listings publics.

---

### `POST /api/users/me/media`

Téléverse **1 à N photos** (max `MEDIA_MAX_PER_REQUEST`, défaut 10) pour
l'utilisateur authentifié.

- **Auth** : requise (Bearer) **+ email confirmé** (`confirmed_at IS NOT NULL`).
  Un compte non confirmé reçoit `403 userNotVerified`.
- **Action** : [UploadMediaAction](../src/Http/Action/Api/Users/UploadMediaAction.php)
- **Body** : `multipart/form-data` avec le champ `media` répété, OU `media[]`.
  - Formats acceptés : JPEG, PNG, WebP, GIF, HEIC, HEIF.
  - Taille max par fichier : `MEDIA_MAX_UPLOAD_BYTES` (défaut 12 Mo).
  - Optionnel — `description` (string, max `MEDIA_DESCRIPTION_MAX_LENGTH`,
    défaut 1024 caractères). Texte libre rédigé par l'utilisateur,
    appliqué **à tous les fichiers du POST** (même sémantique que les
    hashtags — un POST = une rafale cohérente). Trimé côté serveur ; une
    chaîne vide ou blanche est ignorée (= aucune description). Sur
    dépassement, le POST entier est rejeté `422 descriptionTooLong`
    avant la première écriture. Persistée dans `media_description`
    (table 1-1) et incluse dans l'index Meilisearch via `MediaIndexService`.
  - Optionnel — `hashtags[]` (répété, ex. `hashtags[]=sunset&hashtags[]=beach`)
    OU `hashtags` en CSV (`hashtags=sunset,beach`). **Le même jeu s'applique
    à TOUS les fichiers du POST** (les uploads en batch partagent un thème
    en pratique — pas de granularité par fichier en V1). Pipeline silencieux :
    chaque token est normalisé via [HashtagNormalizer](../src/Domain/Media/Hashtag/HashtagNormalizer.php)
    (strip `#`, décomposition NFD, lower, `[a-z0-9_]`, longueur dans
    `[MEDIA_HASHTAG_MIN_LEN, MEDIA_HASHTAG_MAX_LEN]`), les slugs bannis
    (`config/hashtag_blocklist.php`) sont écartés, et le surplus au-delà de
    `MEDIA_HASHTAGS_MAX` (défaut 30) est tronqué dans l'ordre de saisie. Le
    set effectivement persisté est renvoyé en `meta.hashtags` (paires
    `{slug, display}`).
- **Pipeline** ([MediaUploadService](../src/Domain/Media/MediaUploadService.php)),
  appliqué **par fichier**, halt-on-first-fail, un fichier mauvais n'aborte
  pas ses voisins :
  1. Validation pré-décode (taille, code PSR-7, non vide).
  2. Décodage Imagick + whitelist du format.
  3. **pHash perceptuel** + scan des hashes existants de l'utilisateur ;
     un candidat à distance de Hamming ≤ `MEDIA_SIMILARITY_HAMMING_THRESHOLD`
     (défaut 5) est rejeté `duplicateImage`.
  4. Extraction EXIF (sections EXIF/GPS/IFD0 — pas FILE/COMPUTED qui sont
     synthétisées par PHP). GPS → conversion rational→decimal → appel à
     `geo.locate(lat, lng)` qui renvoie `city_id` (cascade ville → région
     → pays côté SP). En présence de GPS, l'**Open Location Code**
     (Plus Code) est aussi calculé via
     [vectorial1024/open-location-code-php](https://github.com/Vectorial1024/open-location-code-php)
     et persisté sur `media.open_location_code` (longueur normale, 10
     chiffres significatifs + séparateur, ex. `8FW4V942+JV` pour Paris) ;
     resté `null` quand l'EXIF n'a pas de GPS. Sans EXIF, `is_manual = 1`.
  5. Auto-orient + **strip** complet (privacy : on conserve les EXIF dans
     `media_exif` mais on les vire du WebP livré).
  6. Resize `bestfit` à 2400 px max (pas d'upscale).
  7. Encodage WebP (qualité 85, `method=6`).
  8. Blurhash 4×3 composants → string `CHAR(30)` + petit WebP 16 px peint
     pixel par pixel pour servir de tile CSS.
  9. Écriture atomique (`tmp + rename`) du WebP + blurhash + original.
  10. Inserts `media`, `media_meta`, `media_exif` (si EXIF présent),
      `media_perceptual_hash`. Sur échec DB, les fichiers déjà écrits
      sont supprimés (best-effort rollback).
  11. Enqueue dans `work.media_to_describe` (PK `media_id BINARY(16)`,
      `INSERT IGNORE` idempotent) pour que le worker IA out-of-band génère
      la description du media. L'appel est **dans** le bloc try protégé
      par le rollback fichiers : si la queue est down, on préfère échouer
      l'upload plutôt que de livrer un media qui ne sera jamais décrit.
  12. Best-effort push Meilisearch `media_dev` via
      [MediaIndexService](../src/Domain/Media/MediaIndexService.php) — le
      service agrège le `media`, sa `media_description` (table 1-1 sur
      `hxa`), et tout futur side-data avant d'envoyer un document complet.
      Toute mutation ultérieure du media (édition de description, flip
      `is_published` par l'IA, mise à jour de score…) doit appeler
      `reindex($id)` pour rester en phase avec l'index. Jamais rollback
      MySQL sur échec d'indexation.
  13. **Gamification** — `XP_PER_MEDIA_UPLOAD` (défaut **50**) est ajouté à
      `user.experience` via un UPDATE atomique, et une ligne est insérée
      dans `user_transaction` (type=1 Experience, `user_emitter_id` NULL
      car c'est une self-action) dans la **même** transaction DB. Le
      `level` exposé dans la ressource `users` est recalculé à la lecture
      (voir [Conventions générales](#conventions-générales)). Avec les
      valeurs par défaut, un premier upload fait passer L1 → L2.

- **Réponse `200 OK`** (au moins un succès, ou `422` si tous ont échoué) :
  collection JSON:API `medias`, **partiel** par construction. Le front
  corrèle chaque ressource avec son fichier source via
  `meta.originalName`. Les fichiers refusés sont en `meta.errors[]`
  (pas dans `errors[]` racine).

```json
{
  "data": [
    {
      "type": "medias",
      "id": "5cc2a02b-f014-4207-9808-7229781aab14",
      "attributes": {
        "type": "photo",
        "cityId": null,
        "blurHash": "L4Aw…",
        "blurhashUrl": "http://hexatrip-static.dev.com/media/5c/c2/a0/5cc2…ab14-blurhash.webp",
        "url":         "http://hexatrip-static.dev.com/media/5c/c2/a0/5cc2…ab14.webp",
        "latitude": null,
        "longitude": null,
        "openLocationCode": null,
        "width": 800,
        "height": 600,
        "orientation": "landscape",
        "shotAt": "2026-06-07T12:34:56+00:00",
        "isManual": true,
        "isPublished": false,
        "status": "pending",
        "description": "Coucher de soleil sur la plage de Biarritz.",
        "createdAt": "2026-06-07T12:34:56+00:00",
        "updatedAt": null
      },
      "meta": { "originalName": "photo.jpg" }
    }
  ],
  "meta": {
    "accepted": 1,
    "rejected": 1,
    "errors": [
      { "originalName": "dup.jpg", "code": "duplicateImage", "title": "Une image similaire existe déjà." }
    ],
    "hashtags": [
      { "slug": "sunset", "display": "Sunset" },
      { "slug": "beach",  "display": "beach"  }
    ],
    "limits": { "maxPerRequest": 10 }
  }
}
```

- **Codes d'erreur par fichier** (mapping `MediaErrorCode` → i18n key
  `media.<code>`) :
  - `missing`, `uploadFailed`, `empty`, `tooLarge`, `tooMany`
  - `invalidImage`, `unsupportedFormat`
  - `duplicateImage`
  - `encodingFailed`, `blurhashFailed`, `storageWriteFailed`

- **`422 descriptionTooLong`** : `description` dépasse
  `MEDIA_DESCRIPTION_MAX_LENGTH`. Renvoyé au format `errors[]` racine,
  **avant** la boucle d'upload (fast-fail global, aucun fichier n'est traité).

- **`403 userNotVerified`** : compte non confirmé (renvoyé au format
  `errors[]` racine, pas par-fichier).

---

### `GET /api/users/me/media`

Liste les médias de l'utilisateur authentifié, **plus récent d'abord**.
Contrairement à l'endpoint public, retourne aussi les photos
`is_published = 0` (en attente de validation par le pipeline AI). C'est ici
que le front **sonde** l'attribut `status` pour suivre l'avancement du
traitement de chaque upload (voir ci-dessous).

- **Auth** : requise (Bearer)
- **Action** : [ListMyMediaAction](../src/Http/Action/Api/Users/ListMyMediaAction.php)
- **Query** :
  - `limit` (int, 1..50, défaut 20)
  - `cursor` (opaque base64url) — page suivante (rows plus anciennes que le curseur)
  - `before` (opaque base64url) — page précédente (rows plus récentes que le curseur) ; gagne sur `cursor` si les deux sont fournis
  - Format du curseur : `base64url("<unix-ts>.<uuid-hex>")` — cursor partagé avec les autres listings paginés (notifs, follows).
- **Réponse `200 OK`** : collection JSON:API `medias`, ordre
  `(created_at DESC, id DESC)`, comparaison de tuple côté SQL pour gérer
  les égalités de timestamp. Navigation via l'objet `links` racine (`self/first/prev/next`).

```json
{
  "data": [ /* …ressources medias… */ ],
  "links": {
    "self":  "https://api.example/api/users/me/media?limit=20",
    "first": "https://api.example/api/users/me/media?limit=20",
    "prev":  null,
    "next":  "https://api.example/api/users/me/media?cursor=MTcxMjM0…&limit=20"
  },
  "meta": { "limit": 20, "total": 47 }
}
```

- `meta.total` : nombre **exact** de médias de l'utilisateur authentifié (publiés ET non-publiés), `COUNT(*)` indexé sur `user_id`.
- **`400 Invalid cursor`** si `cursor` ou `before` est mal formé.

---

### `DELETE /api/users/me/media/{mediaId}`

Supprime définitivement un média **possédé par l'utilisateur authentifié**.

- **Auth** : requise (Bearer)
- **Action** : [DeleteMyMediaAction](../src/Http/Action/Api/Users/DeleteMyMediaAction.php)
- **Effet** ([MediaDeleteService](../src/Domain/Media/MediaDeleteService.php)) :
  1. Unlink du WebP publié + blurhash WebP.
  2. Unlink de l'original archivé (`glob "<hex>.*"` — l'extension d'origine
     n'est pas stockée séparément, mais l'archive ne contient qu'un seul
     fichier portant ce hex).
  3. Delete dans `media_meta`, `media_exif`, `media_perceptual_hash`.
  4. Delete dans `hxa.media`.
  5. Best-effort delete du document Meilisearch.

  Le cache Glide (`MEDIA_CACHE_PATH`) **n'est pas purgé** : la source
  étant absente, Glide retournera 404 sur la prochaine requête et l'ops
  vide le cache hors-bande.

- **Réponses** :
  - `204 No Content` : suppression effectuée.
  - `403 forbidden` : le média existe mais appartient à un autre user.
  - `404 notFound` : aucun média avec cet id (ou déjà supprimé — le 2e
    appel sur le même id renvoie 404, ce n'est pas idempotent au sens
    HTTP strict).
  - `422` : `mediaId` n'est pas un UUID.

---

### `PUT /api/users/me/media/{mediaId}/description`

Met à jour (ou crée) la **description libre** d'un média possédé par
l'utilisateur authentifié. Texte simple, max
`MEDIA_DESCRIPTION_MAX_LENGTH` caractères (défaut 1024).

- **Auth** : requise (Bearer).
- **Action** : [SetMediaDescriptionAction](../src/Http/Action/Api/Users/SetMediaDescriptionAction.php).
- **Body** : `application/json`
  ```json
  { "description": "Coucher de soleil sur la plage de Biarritz." }
  ```
  - Le serveur **trime** la valeur avant validation.
  - Une chaîne **vide** ou exclusivement blanche est traitée comme une
    suppression implicite (= équivalente à un
    `DELETE /api/users/me/media/{mediaId}/description`) : la ligne
    `media_description` est supprimée et le document Meilisearch est
    réindexé sans description.
- **Effet** :
  1. Upsert dans `media_description` (PK `media_id`, table 1-1).
  2. Réindexation Meilisearch via
     [MediaIndexService::reindex](../src/Domain/Media/MediaIndexService.php)
     pour que la description soit visible côté recherche full-text.
- **Réponses** :
  - `204 No Content` : description écrite (ou supprimée si vide).
  - `403 forbidden` : le média existe mais appartient à un autre user.
  - `404 notFound` : aucun média avec cet id.
  - `422 descriptionTooLong` : longueur > `MEDIA_DESCRIPTION_MAX_LENGTH`.
    Source-pointer `/data/attributes/description`.
  - `422` : `mediaId` n'est pas un UUID, ou body JSON invalide.

---

### `DELETE /api/users/me/media/{mediaId}/description`

Supprime la description d'un média possédé par l'utilisateur authentifié.
**Idempotent** : renvoie `204` même si aucune description n'était
enregistrée (pas de `404` au 2ᵉ appel).

- **Auth** : requise (Bearer).
- **Action** : [DeleteMediaDescriptionAction](../src/Http/Action/Api/Users/DeleteMediaDescriptionAction.php).
- **Effet** :
  1. Delete de la ligne `media_description` (no-op si absente).
  2. Réindexation Meilisearch (description = `null` dans le document).
- **Réponses** :
  - `204 No Content` : ligne supprimée ou déjà absente.
  - `403 forbidden` : le média existe mais appartient à un autre user.
  - `404 notFound` : aucun média avec cet id (la garde owner-only
    prévaut sur l'idempotence — un id inexistant reste un 404).
  - `422` : `mediaId` n'est pas un UUID.

---

### `GET /api/users/{userId}/media`

Liste **publique** des médias d'un utilisateur — restreinte à
`is_published = 1` (les uploads en attente AI restent invisibles aux
autres utilisateurs).

- **Auth** : aucune
- **Action** : [ListUserMediaAction](../src/Http/Action/Api/Users/ListUserMediaAction.php)
- **Query** : identique à `GET /api/users/me/media` (`limit`, `cursor`, `before`).
- **Réponses** :
  - `200 OK` + collection JSON:API `medias` (vide si l'user n'a aucune
    photo publiée). `meta.total` donne le nombre **exact** de médias publiés de l'utilisateur ciblé (`COUNT(*) WHERE user_id = ? AND is_published = 1`).
  - `404 User not found` si l'id n'existe pas.
  - `422 Invalid user id` si `userId` n'est pas un UUID.

---

### `GET /api/media/nearby`

Découverte géographique : retourne les médias **publiés** situés dans un rayon `distance` (en mètres) autour d'un point GPS, **triés du plus proche au plus éloigné**. Adossé à Meilisearch (filtre `_geoRadius`, tri `_geoPoint:asc`).

- **Auth** : aucune.
- **Action** : [SearchNearbyMediaAction](../src/Http/Action/Api/Media/SearchNearbyMediaAction.php).
- **Query (obligatoires)** :
  - `lat` : float dans `[-90, 90]`
  - `lng` : float dans `[-180, 180]`
  - `distance` : entier positif (mètres), borné par `MEDIA_NEARBY_MAX_DISTANCE_METERS` (défaut **100 000 m = 100 km**).
- **Query (optionnels)** :
  - `q` : full-text sur `name` + `description` (vide = pur browse géo)
  - `hashtags` : répété (`hashtags[]=sunset&hashtags[]=beach`) OU CSV (`hashtags=sunset,beach`). Les tokens passent par [HashtagNormalizer](../src/Domain/Media/Hashtag/HashtagNormalizer.php) — `#Sunset!` matche bien le facet `sunset`. **OR** entre les slugs : un média est retenu s'il porte AU MOINS UN des hashtags. La liste effectivement appliquée est renvoyée dans `meta.hashtags`.
  - `orientation` : `landscape`, `portrait`, `square` ou `panorama` (whitelist stricte ; toute autre valeur → `422`). Filtre exact sur le facet `orientation` du document Meili. Absent = toutes orientations. La valeur effectivement appliquée est renvoyée dans `meta.orientation` (ou `null`).
  - `limit` : 1..50 (défaut 20)
  - `offset` : 0+ (défaut 0)
- **Filtrage Meilisearch** : `_geoRadius(lat, lng, distance) AND is_published = true` (+ `AND hashtags IN [...]` et/ou `AND orientation = '...'` si fournis). Une seconde garantie est appliquée côté MySQL au moment de la ré-hydratation (`publishedOnly: true`) pour neutraliser un éventuel document Meili obsolète.
- **Pipeline** : Meilisearch renvoie des `id` ordonnés par distance croissante, Hydrogen ré-hydrate les entités `Media` depuis MySQL via `MediaRepository::findManyByIds()` → mêmes ressources `medias` que les autres endpoints, avec un attribut supplémentaire :
  - `attributes.distanceMeters` : distance en mètres au point de référence (lu depuis le `_geoDistance` que Meili calcule lors du tri par `_geoPoint`).

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    {
      "type": "medias",
      "id":   "0193…",
      "attributes": {
        "name": "Pont du Gard",
        "url": "http://hexatrip-static.dev.com/media/…",
        "latitude": 43.9476,
        "longitude": 4.5354,
        "distanceMeters": 248.7,
        "...": "…"
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20",
    "first": "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20",
    "prev":  null,
    "next":  "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20&offset=20",
    "last":  "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20&offset=80"
  },
  "meta": {
    "totalHits": 95,
    "limit":     20,
    "offset":    0,
    "center":    { "lat": 43.95, "lng": 4.54 },
    "distance":  2000,
    "query":     ""
  }
}
```

- **Erreurs** :
  - `422` : `lat`/`lng`/`distance` manquant, non numérique, hors bornes, ou `distance > MEDIA_NEARBY_MAX_DISTANCE_METERS`.
  - `503 Search backend unavailable` : Meilisearch indisponible ou index mal configuré (voir prérequis ci-dessous) — l'erreur API est propagée dans `errors[].detail`.

- **Prérequis index Meilisearch** (one-shot, à appliquer côté ops sur l'index `MEILISEARCH_MEDIA_INDEX`) :
  - `filterableAttributes` doit contenir `_geo` ET `is_published`
  - `sortableAttributes` doit contenir `_geo`
  - Exemple (curl) :
    ```bash
    curl -X PATCH "$MEILI/indexes/$INDEX/settings" -H "Authorization: Bearer $KEY" \
      -H "Content-Type: application/json" \
      -d '{"filterableAttributes":["_geo","is_published","user_id"],"sortableAttributes":["_geo","shot_at","created_at"]}'
    ```
  - Les documents poussés par [MeilisearchMediaSync](../src/Infrastructure/Meilisearch/MeilisearchMediaSync.php) émettent automatiquement l'objet `_geo: {lat, lng}` quand les deux coordonnées sont connues côté domaine ; un média sans GPS n'apparaîtra simplement pas dans les résultats de cet endpoint.

---

### `GET /api/media/in-bounds`

Découverte par **rectangle géographique** (bounding box). Retourne les médias **publiés** dont la position GPS tombe dans le rectangle décrit par les quatre coins, sans tri par distance (le rectangle n'a pas de centre canonique — l'ordre suit la pertinence de `q` ou l'ordre d'index). Pensé pour des UI carto qui pan/zoom et veulent peupler le viewport courant (équivalent de `map.getBounds()` côté Leaflet/Mapbox).

- **Auth** : optionnelle. Quand un Bearer token est présent, les `viewerReaction` sont peuplées (sinon `null`).
- **Action** : [SearchMediaInBoundsAction](../src/Http/Action/Api/Media/SearchMediaInBoundsAction.php).
- **Query (obligatoires)** :
  - `north` : float dans `(-90, 90]`
  - `south` : float dans `[-90, 90)`, **strictement** `< north`
  - `east`  : float dans `[-180, 180]`
  - `west`  : float dans `[-180, 180]`, `<= east` (le wrap au méridien 180 n'est PAS supporté — le client doit envoyer deux requêtes s'il en a besoin)
- **Query (optionnels)** :
  - `q` : full-text sur `name` + `description`
  - `hashtags` : répété OU CSV (mêmes règles que `/media/nearby`).
  - `orientation` : `landscape`, `portrait`, `square` ou `panorama` (mêmes règles que `/media/nearby`).
  - `limit` : 1..50 (défaut 20)
  - `offset` : 0+ (défaut 0)
- **Filtrage Meilisearch** : `_geoBoundingBox([north, east], [south, west]) AND is_published = true` (+ `AND hashtags IN [...]` et/ou `AND orientation = '...'` si fournis). Convention Meilisearch : `[top-right_lat, top-right_lng], [bottom-left_lat, bottom-left_lng]`. Comme `/media/nearby`, une seconde garantie est appliquée côté MySQL au moment de la ré-hydratation (`publishedOnly: true`).

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    { "type": "medias", "id": "0193…", "attributes": { "name": "…", "latitude": 43.9476, "longitude": 4.5354, "...": "…" } }
  ],
  "links": {
    "self":  "https://api.example/api/media/in-bounds?north=44.0&south=43.9&east=4.6&west=4.5&limit=20",
    "first": "…",
    "prev":  null,
    "next":  "…",
    "last":  "…"
  },
  "meta": {
    "totalHits": 137,
    "limit":     20,
    "offset":    0,
    "bbox":      { "north": 44.0, "south": 43.9, "east": 4.6, "west": 4.5 },
    "query":     ""
  }
}
```

- **Erreurs** :
  - `422` : un des quatre paramètres manquant, non numérique, hors bornes ; `south >= north` ; `west > east`.
  - `503 Search backend unavailable` : Meilisearch indisponible ou index mal configuré.

- **Prérequis index Meilisearch** : identiques à `/media/nearby` côté filtres (`_geo` et `is_published` dans `filterableAttributes`). `_geoBoundingBox` n'a PAS besoin que `_geo` soit dans `sortableAttributes` (aucun tri par distance ici), mais le partager avec `/media/nearby` ne pose aucun problème.

---

### `GET /api/media/recommended/nearby`

Recommandation personnalisée « médias près de chez vous ». Variante de `/media/nearby` où **le centre géographique et le filtre thématique sont dérivés du viewer** plutôt qu'épelés dans l'URL. Tri du plus proche au plus éloigné (`_geoPoint:asc`).

- **Auth** : optionnelle. Connecté = centre déduit du profil + personnalisation par topics ; anonyme = `lat`+`lng` obligatoires, pas de personnalisation.
- **Action** : [RecommendedNearbyMediaAction](../src/Http/Action/Api/Media/RecommendedNearbyMediaAction.php).
- **Résolution du centre** (par priorité) :
  1. `lat` + `lng` explicites (fix GPS du téléphone) — priment toujours. Fournis ensemble ou pas du tout.
  2. sinon, la **ville de naissance** du viewer (`user.birthplaceCityId`) résolue en coordonnées via l'index Meili `cities` ([CitySummaryResolver](../src/Domain/City/CitySummaryResolver.php)).
  - Aucun des deux → `422` (impossible de recommander « près d'ici » sans « ici »).
- **Personnalisation par topics** (`personalize`, défaut **on**) : quand le viewer suit des topics ET que ces topics sont mappés vers des slugs de hashtags dans la table `topic_hashtag`, les résultats sont restreints aux médias portant **au moins un** de ces slugs (`hashtags IN [...]`). Sans viewer / sans mapping / `personalize=0` → filtre omis, l'endpoint dégrade proprement vers une simple recherche par rayon. Les slugs effectivement appliqués sont renvoyés dans `meta.topics`.
- **Query (optionnels)** :
  - `lat`, `lng` : décimaux, ensemble ou pas du tout (override du fallback ville de naissance).
  - `distance` : entier positif (mètres), défaut **et** plafond = `MEDIA_NEARBY_MAX_DISTANCE_METERS` (100 km).
  - `personalize` : `0`/`false`/`no`/`off` désactive le filtre topics→hashtags.
  - `limit` : 1..50 (défaut 20) ; `offset` : 0+ (défaut 0).
- **Filtrage Meilisearch** : `_geoRadius(lat, lng, distance) AND is_published = true` (+ `AND hashtags IN [...]` si personnalisation active). Ré-hydratation MySQL `publishedOnly: true` comme `/media/nearby`. Chaque ressource porte `attributes.distanceMeters`.
- **meta** : `totalHits`, `limit`, `offset`, `center: { lat, lng, source }` (`source` = `explicit` | `birthplace`), `distance`, `personalize`, `topics: [...]`.
- **Erreurs** :
  - `422 Invalid center` : `lat` sans `lng` (ou inverse).
  - `422 No usable location` : aucun `lat`/`lng` et pas de ville de naissance exploitable.
  - `422` : `lat`/`lng` hors bornes, `distance` non positif ou `> MEDIA_NEARBY_MAX_DISTANCE_METERS`.
  - `503 Search backend unavailable` : Meilisearch indisponible ou index mal configuré.
- **Prérequis index Meilisearch** : identiques à `/media/nearby` (`_geo` + `is_published` filterables, `_geo` sortable). La personnalisation requiert l'attribut `hashtags` filterable.

---

### `GET /api/media/trending`

« Tendances », éventuellement cadrées sur un pays. Modèle de ranking volontairement simple (pas de job nocturne) : médias **publiés** créés dans une **fenêtre récente**, triés par `likes_count` décroissant (tie-break `created_at` desc). Le champ `score` (qualité IA) n'est **pas** utilisé : il mesure la qualité intrinsèque, pas l'engagement, et n'est pas sortable.

- **Auth** : optionnelle. Connecté = fallback pays via ville de naissance + personnalisation topics ; anonyme = `?country` explicite ou mondial.
- **Action** : [TrendingMediaAction](../src/Http/Action/Api/Media/TrendingMediaAction.php).
- **Résolution du pays** (par priorité) :
  1. `country` explicite (ISO 3166-1 alpha-2, insensible à la casse).
  2. sinon, le pays de la **ville de naissance** du viewer.
  3. sinon, **mondial** (aucun filtre pays).
- **Personnalisation par topics** : identique à `/media/recommended/nearby` (`personalize`, défaut on ; `meta.topics`).
- **Query (optionnels)** :
  - `country` : ISO alpha-2. Vide → chaîne de fallback ci-dessus.
  - `windowDays` : entier 1..`MEDIA_TRENDING_MAX_WINDOW_DAYS` (défaut **365**), défaut `MEDIA_TRENDING_WINDOW_DAYS` (**30**). Fenêtre de fraîcheur (`created_at >= now - windowDays * 86400`).
  - `personalize` : `0`/`false`/`no`/`off` désactive le filtre topics→hashtags.
  - `limit` : 1..50 (défaut 20) ; `offset` : 0+ (défaut 0).
- **Filtrage Meilisearch** : `is_published = true AND created_at >= <since>` (+ `AND country_id = 'XX'` si pays résolu, + `AND hashtags IN [...]` si personnalisation active). Tri `["likes_count:desc", "created_at:desc"]`. Ré-hydratation MySQL `publishedOnly: true`.
- **meta** : `totalHits`, `limit`, `offset`, `country` (ISO uppercase ou `null`), `countrySource` (`explicit` | `birthplace` | `null`), `windowDays`, `personalize`, `topics: [...]`.
- **Erreurs** :
  - `422 Invalid country` : `country` non conforme à ISO alpha-2.
  - `422 Invalid windowDays` : `windowDays` non entier.
  - `503 Search backend unavailable` : Meilisearch indisponible ou index mal configuré.
- **Prérequis index Meilisearch** : `is_published`, `created_at`, `country_id` et `hashtags` filterables ; `likes_count` et `created_at` sortables.

---

## Hashtags média

Système de hashtags **libres**, **apposés par l'utilisateur** au moment du
`POST /api/users/me/media`. Stockage sur la table `media_hashtag` (paire
`media_id` / `slug` + `display` dénormalisé + `position`), et indexation
comme **facet** Meilisearch sur l'attribut `hashtags` du document média —
ce qui permet de tout faire (autocomplete, trending, related, filtre dans
`/media/nearby` et `/media/in-bounds`) côté index sans entité hashtag
canonique en MySQL. Le slug est l'identifiant API ; le `display` n'est
conservé que pour la restitution sur la ressource `medias`.

Pipeline d'écriture (cf. [MediaHashtagSyncService](../src/Domain/Media/Hashtag/MediaHashtagSyncService.php)) :

```
raw user list
  → HashtagNormalizer (strip `#`, NFD, lower, [a-z0-9_], min/max len)
  → HashtagBlocklist  (silent drop, seed config/hashtag_blocklist.php)
  → cap MEDIA_HASHTAGS_MAX (truncate keep order)
  → MediaHashtagRepository::syncForMedia()
```

**Synonymes** : Meilisearch résout symétriquement les paires déclarées dans
`config/hashtag_synonyms.php` — une recherche `q=sunset` matche les médias
portant `#sundown` ou `#dusk` si la map les y associe. Les slugs canoniques
restent inchangés sur les documents (donc `trending` / `related` comptent
chaque slug pour ce qu'il vaut). La map est statique, éditable par PR,
poussée via :

```
php bin/media-meili-apply-settings.php
```

qui applique aussi les `filterableAttributes` / `searchableAttributes` /
`typoTolerance.disableOnAttributes` (la typo-tolérance est désactivée sur
`hashtags` — `#suns3t` ne doit pas matcher `#sunset`).

**Ressource `media-hashtags`** : type JSON:API émis par les 3 endpoints
ci-dessous. `id = slug`.

```json
{
  "type": "media-hashtags",
  "id":   "sunset",
  "attributes": { "slug": "sunset", "mediaCount": 12453 }
}
```

**Variables d'env** :
- `MEDIA_HASHTAGS_MAX` (défaut 30) — cap par média à l'upload.
- `MEDIA_HASHTAG_MIN_LEN` (défaut 2) — longueur slug min.
- `MEDIA_HASHTAG_MAX_LEN` (défaut 50) — longueur slug max.
- `MEDIA_HASHTAG_AUTOCOMPLETE_MAX_LIMIT` (défaut 20).
- `MEDIA_HASHTAG_TRENDING_MAX_LIMIT`     (défaut 20).
- `MEDIA_HASHTAG_RELATED_MAX_LIMIT`      (défaut 20).

---

### `GET /api/media/hashtags/autocomplete`

Suggère des slugs commençant par un préfixe. Utilise `facetSearch` de
Meilisearch sur l'attribut `hashtags`, restreint aux médias **publiés**.

- **Auth** : aucune.
- **Action** : [AutocompleteMediaHashtagsAction](../src/Http/Action/Api/Media/Hashtag/AutocompleteMediaHashtagsAction.php).
- **Query** :
  - `q` (obligatoire) : préfixe (1..`MEDIA_HASHTAG_MAX_LEN` chars). Normalisé
    avant la requête — l'utilisateur peut taper `#Sunset!`. Un `q` vide
    déclenche `422 Missing query` ; un `q` qui dégénère en slug vide après
    normalisation (uniquement de la ponctuation) renvoie une collection vide
    plutôt que `422` (UI silencieuse pendant la frappe).
  - `limit` (optionnel, 1..`MEDIA_HASHTAG_AUTOCOMPLETE_MAX_LIMIT`, défaut 10).
- **Réponse `200 OK`** : collection JSON:API `media-hashtags`. `meta.prefix`
  contient le préfixe **normalisé** appliqué.

```json
{
  "data": [
    { "type": "media-hashtags", "id": "sunset",   "attributes": { "slug": "sunset",   "mediaCount": 12453 } },
    { "type": "media-hashtags", "id": "sundown",  "attributes": { "slug": "sundown",  "mediaCount":   234 } },
    { "type": "media-hashtags", "id": "sunshine", "attributes": { "slug": "sunshine", "mediaCount":    89 } }
  ],
  "links": { "self": "https://api.example/api/media/hashtags/autocomplete?q=sun&limit=10" },
  "meta":  { "total": 3, "prefix": "sun", "limit": 10 }
}
```

- **Erreurs** :
  - `422 Missing query` : `q` absent ou trim vide.
  - `503 Search backend unavailable` : Meilisearch KO.

---

### `GET /api/media/hashtags/trending`

Histogramme live des hashtags les plus portés par les médias **publiés**,
trié par `mediaCount` desc puis `slug` asc (tiebreaker déterministe).
Calculé via `facetDistribution` sur une requête vide — pas de table
matérialisée, pas de job nocturne. Filtre géographique optionnel pour
basculer "tendances mondiales" ↔ "tendances autour de moi".

- **Auth** : aucune.
- **Action** : [TrendingMediaHashtagsAction](../src/Http/Action/Api/Media/Hashtag/TrendingMediaHashtagsAction.php).
- **Query** :
  - `limit` (optionnel, 1..`MEDIA_HASHTAG_TRENDING_MAX_LIMIT`, défaut 10).
  - Trio géo (tout-ou-rien — une présence partielle = `422`) :
    - `lat` : float `[-90, 90]`
    - `lng` : float `[-180, 180]`
    - `distance` : entier > 0, en mètres.
- **Réponse `200 OK`** : collection JSON:API `media-hashtags`. `meta.geo`
  reflète le trio appliqué quand il est présent.

```json
{
  "data": [
    { "type": "media-hashtags", "id": "sunset", "attributes": { "slug": "sunset", "mediaCount": 12453 } },
    { "type": "media-hashtags", "id": "beach",  "attributes": { "slug": "beach",  "mediaCount":  9821 } }
  ],
  "links": { "self": "https://api.example/api/media/hashtags/trending?limit=10" },
  "meta":  { "total": 2, "limit": 10 }
}
```

Avec le filtre géo :

```json
{
  "meta": {
    "total": 2,
    "limit": 10,
    "geo":   { "lat": 43.95, "lng": 4.54, "distance": 5000 }
  }
}
```

- **Erreurs** :
  - `422 Invalid geo filter` : trio géo partiel, non numérique, hors bornes,
    ou `distance <= 0`.
  - `503 Search backend unavailable` : Meilisearch KO.

---

### `GET /api/media/hashtags/related`

Co-occurrence : "parmi les médias publiés portant `#sunset`, quels AUTRES
hashtags reviennent le plus ?". `facetDistribution` sur une requête filtrée
par le slug d'ancrage. L'ancrage lui-même est retiré du résultat (il
dominerait toujours), et les slugs bannis le sont aussi.

- **Auth** : aucune.
- **Action** : [RelatedMediaHashtagsAction](../src/Http/Action/Api/Media/Hashtag/RelatedMediaHashtagsAction.php).
- **Query** :
  - `hashtag` (obligatoire) : slug d'ancrage. Passé par `HashtagNormalizer`.
  - `limit` (optionnel, 1..`MEDIA_HASHTAG_RELATED_MAX_LIMIT`, défaut 10).
- **Réponse `200 OK`** : collection JSON:API `media-hashtags`. `meta.anchor`
  contient le slug normalisé.

```json
{
  "data": [
    { "type": "media-hashtags", "id": "beach",  "attributes": { "slug": "beach",  "mediaCount": 4321 } },
    { "type": "media-hashtags", "id": "summer", "attributes": { "slug": "summer", "mediaCount": 1987 } }
  ],
  "links": { "self": "https://api.example/api/media/hashtags/related?hashtag=sunset&limit=10" },
  "meta":  { "total": 2, "anchor": "sunset", "limit": 10 }
}
```

- **Erreurs** :
  - `422 Missing anchor hashtag` : `hashtag` absent ou trim vide.
  - `422 Invalid anchor hashtag` : dégénère en slug vide après normalisation.
  - `503 Search backend unavailable` : Meilisearch KO.

---

### `GET /media/{hex}.{ext}`

Endpoint **public hors `/api`** de redimensionnement/transcodage à la
volée via [league/glide](https://glide.thephpleague.com/).

- **Auth** : optionnelle (soft) via [OptionalAuthenticationMiddleware](../src/Http/Middleware/OptionalAuthenticationMiddleware.php). Un Bearer token valide est honoré mais pas obligatoire — l'absence de token ne renvoie jamais `401`.
- **Action** : [ServeMediaAction](../src/Http/Action/Media/ServeMediaAction.php)
- **Route** : `/media/{hex:[0-9a-f]{32}}.{ext:webp|jpg|jpeg|png|gif|avif}`
  - `hex` : hex lowercase 32 chars de l'UUID du média
  - `ext` : format de SORTIE souhaité (le source est **toujours** le WebP
    canonique à `MEDIA_STORAGE_PATH/AA/BB/CC/<hex>.webp`)

- **Query Glide** standard : `w`, `h`, `fit`, `q`, `dpr`, `bri`, `con`,
  `blur`, `sharp`, `crop`, etc. — voir [la doc Glide](https://glide.thephpleague.com/2.0/api/quick-reference/).
- **Signature** : si `MEDIA_SIGN_KEY` est défini, le param `s=<md5>` est
  **obligatoire** (`SignatureFactory` Glide standard, signature calculée
  sur `"<hex>.<ext>" + params triés alphabétiquement`). En dev,
  `MEDIA_SIGN_KEY` vide → toute requête est acceptée.
- **Cache** : derivés écrits sous `MEDIA_CACHE_PATH` (ventilé sur le
  source, `cache_with_file_extensions=true`). Une requête identique
  ultérieure est servie directement depuis le cache (~0.5 ms vs ~60 ms à
  froid).
- **Pilote** : Imagick (cohérent avec le pipeline d'upload).
- **Garde-fou** : Glide refuse toute production excédant
  `MEDIA_GLIDE_MAX_PIXELS` (défaut 16 MP) → `400`.

- **Réponse `200 OK`** : binaire image, `Content-Type` cohérent avec
  `ext`, `Cache-Control: max-age=31536000, public` (1 an, immuable —
  toute modification produit une URL différente).
- **Contrôle d'accès** :
  - Média **publié** (`is_published = 1`) → accessible anonymement.
  - Média **non publié** (encore en attente du pipeline AI, ou retiré) →
    servi **uniquement à son propriétaire** (Bearer token requis ET
    `media.user_id` doit correspondre au porteur du token). Tout autre
    cas (token absent, autre utilisateur, média inexistant) → `404`
    indifférencié, pour ne jamais confirmer l'existence d'un upload
    privé.
- **Erreurs** :
  - `400` : `hex` ou `ext` invalide, ou paramètres Glide aberrants.
  - `403` : signature manquante / invalide (uniquement si
    `MEDIA_SIGN_KEY` est défini).
  - `404` : média inconnu, source filesystem manquante, ou média non
    publié réclamé par quelqu'un d'autre que son propriétaire (cas
    indistinguables côté client par design).

---

## Réactions (likes / dislikes)

Un utilisateur authentifié peut **liker** ou **disliker** un média (mais
**pas le sien** — `403`). Le couple `(media_id, user_id)` est unique :
poser une réaction écrase la précédente (flip like ↔ dislike), idempotent
si la même valeur est repostée. La suppression est elle aussi idempotente.

### Règles produit (validées)

- **Pas d'auto-réaction** : l'auteur d'un média ne peut ni le liker, ni le
  disliker → `403 Self reaction forbidden`.
- **Pas de XP / gamification** sur les réactions (contrairement aux
  uploads / follows).
- **Listings 100 % publics** (`/media/{id}/likes`, `/media/{id}/dislikes`,
  `/users/{userId}/likes`, `/users/{userId}/dislikes`) — site de tourisme,
  transparence sociale. Les dislikes sont également publics car la donnée
  est symétrique et ne crée pas de surface de harcèlement spécifique.
- **Notification owner = like uniquement, et seulement le premier**.
  - Un dislike ne notifie jamais.
  - Un flip (`dislike → like`, ou re-like après unreact dans la même
    session) ne re-notifie pas le propriétaire — dedup_key
    `media.reaction:<mediaHex>:<actorHex>`.

### Compteurs et `viewerReaction`

Toutes les ressources `medias` exposent désormais 5 compteurs
dénormalisés sur `media_stats` :

```json
"likesCount":       42,
"dislikesCount":    3,
"viewsCount":       1287,
"impressionsCount": 9412,
"commentsCount":    21
```

- `likesCount` / `dislikesCount` sont maintenus par triggers MySQL.
- `commentsCount` est maintenu applicativement par le service commentaires.
- `viewsCount` et `impressionsCount` sont maintenus **asynchronement**
  par le pipeline compteurs :
  - **view** = un appel à `GET /media/{hex}.{ext}` (servir le binaire).
    Déduplication par viewer sur une fenêtre glissante de
    `MEDIA_COUNTERS_VIEW_DEDUP_WINDOW_SECONDS` (1h par défaut).
  - **impression** = un média apparaît dans une collection
    (`/api/media/nearby`, `/api/media/in-bounds`, `/api/users/{id}/media`).
    Chaque média livré dans la réponse prend `+1`. Pas de dedup.
  - Les bots détectés via `jenssegers/agent` sont ignorés
    (`MEDIA_COUNTERS_IGNORE_BOTS=true`).
  - Les compteurs sont alimentés par un worker
    (`bin/media-counters-flush.php`, tick
    `MEDIA_COUNTERS_FLUSH_TICK_SECONDS`) : ils peuvent retarder l'activité
    réelle d'un tick avant d'apparaître sur la ressource.

Sur un appel **authentifié**, la ressource expose en plus l'état du
viewer vis-à-vis du média (clé omise si appelant anonyme — le client ne
peut donc rien inférer d'une absence) :

```json
"viewerReaction": "like"   // ou "dislike", ou null si pas de réaction
```

L'index Meilisearch `MEILISEARCH_MEDIA_INDEX` reçoit également
`likes_count` / `dislikes_count` / `views_count`. Pour pouvoir trier ou
filtrer dessus, ajouter ces clés à `filterableAttributes` /
`sortableAttributes` côté ops (one-shot).

### Orientation (`orientation`) sur la ressource `medias`

Chaque ressource `medias` expose un attribut **`orientation`** dérivé une
fois à l'upload depuis les dimensions publiées (post-resize). Quatre
valeurs possibles, choisies pour couvrir les vrais besoins UI :

| Valeur       | Règle                                        | Cas typique             |
|--------------|----------------------------------------------|-------------------------|
| `landscape`  | `width > height` et `width/height < 2.0`     | 4:3, 16:9, photos kit   |
| `portrait`   | `height > width`                              | photos mobiles, stories |
| `square`     | `width == height` (ou dimensions inconnues)   | crop 1:1, fallback      |
| `panorama`   | `width > height` et `width/height >= 2.0`     | panoramas stitchés, 18:9+|

Le seuil `panorama` est codé en dur dans
[MediaOrientationResolver::PANORAMA_RATIO_THRESHOLD](../src/Domain/Media/MediaOrientationResolver.php) ;
la migration SQL `add_media_orientation` applique exactement la même règle
pour le backfill. La valeur est :
- persistée dans la colonne `media.orientation` (`VARCHAR(16) NOT NULL`) ;
- indexée comme **facet Meili** filterable sur le document média ;
- exposée en clair dans `attributes.orientation` côté API.

Les deux endpoints geo (`/api/media/nearby`, `/api/media/in-bounds`) acceptent
un paramètre `?orientation=portrait` (whitelist stricte ; toute valeur hors
enum répond `422 Invalid orientation`). Pas de combinaison `OR` : un seul
bucket à la fois (le besoin produit ne s'est pas encore présenté pour `OR`).

**Ops après déploiement** (à exécuter dans cet ordre) :

1. Migration SQL :
   `database/migrations/2026_06_13_160000_add_media_orientation.sql` —
   ajoute la colonne et backfille les rows existants.
2. Re-push des settings Meili pour rendre `orientation` filterable :
   ```bash
   php bin/media-meili-apply-settings.php
   ```
3. Full-reindex de l'index `media_dev` pour propager le champ
   `orientation` sur les documents déjà présents (sinon les filtres ne
   matchent rien pour ces docs) — utiliser l'endpoint admin de reindex ou
   le bin script habituel.

### Description libre (`description`) sur la ressource `medias`

Chaque ressource `medias` expose un attribut **`description`** : texte
libre écrit par le propriétaire (max `MEDIA_DESCRIPTION_MAX_LENGTH`
caractères, défaut **1024**). Stocké dans la table 1-1
`hxa.media_description` (FK `ON DELETE CASCADE` vers `media`).

- Valeur `null` quand aucune description n'est enregistrée. Le champ est
  **toujours présent** dans l'attribut payload pour que le client puisse
  binder sans test d'existence.
- Édité indépendamment du média :
  - À l'upload : champ `description` du multipart `POST /api/users/me/media`
    (s'applique à tous les fichiers du POST).
  - Après coup : `PUT /api/users/me/media/{mediaId}/description`
    (idempotent ; chaîne vide ⇒ suppression).
  - Suppression explicite :
    `DELETE /api/users/me/media/{mediaId}/description` (idempotent).
- Indexation Meilisearch : la description est incluse dans le document
  via [MediaIndexService](../src/Domain/Media/MediaIndexService.php) — toute
  mutation déclenche un `reindex($id)` pour rester en phase avec
  l'index full-text.
- Batch-load : les listings de medias chargent les descriptions en **un
  seul round-trip** via `MediaDescriptionRepository::findManyFor()` (pas
  d'N+1).

Erreur de validation : `422 descriptionTooLong` (source-pointer
`/data/attributes/description`) si la longueur dépasse
`MEDIA_DESCRIPTION_MAX_LENGTH`.

### Auteur public (`author`) sur la ressource `medias`

Toutes les ressources `medias` retournées par les listings exposent un
sous-objet `author` contenant le sous-ensemble strictement PUBLIC de
l'utilisateur propriétaire. L'ancien attribut plat `userId` est retiré :
les clients lisent désormais `author.id` (même UUID).

```json
"author": {
  "id":           "0193c1…",
  "username":     "havoc",
  "nickname":     "Havoc",
  "displayName":  "Havoc",
  "bio":          "Photographe nomade",
  "avatarUrl":    "https://hexatrip-static.dev.com/users/01/93/01..c1/avatar.webp",
  "hasAvatar":    true,
  "qrcodeUrl":    "https://hexatrip.dev.com/qrcode/havoc.png?v=1812345678",
  "isVerified":   true,
  "level":        7,
  "levelProgress": 42.50,
  "displayTitle": "Touriste expert"
}
```

> `qrcodeUrl` : URL publique du QR code de profil (PNG) encodant `/@{username}`.
> Le cache-buster `?v=<timestamp>` (issu de `avatar_updated_at`) n'est présent
> que si l'utilisateur a un avatar — le QR l'embarque en son centre.

- Exposé sur les 8 endpoints qui renvoient des ressources `medias` :
  `/api/media/nearby`, `/api/media/in-bounds`, `/api/users/{userId}/media`,
  `/api/users/me/media`, `/api/users/{userId}/likes`,
  `/api/users/{userId}/dislikes`, `/api/users/me/likes`,
  `/api/users/me/dislikes`, `/api/users/me/bookmarks`, et la réponse de
  `POST /api/users/me/media` (upload).
- **N+1 évité** : chaque action batch-load les auteurs (uniques) du lot
  via `UserRepository::findManyByIds()` — une seule SELECT par page.
- Champs strictement publics : pas de `email`, `name`, `firstname`,
  `sex`, `birthdate`, `birthplaceCityId`, `status`, `confirmedAt`,
  `bannedUntil`, `joinedAt`, `experience`. Pour la fiche complète,
  utiliser les endpoints `/api/users/{userId}/*`.
- `displayTitle` est omis quand le catalogue de titres est vide (même
  contrat que `UserResourceSerializer`).
- **Breaking change** : l'attribut `userId` (plat) sur la ressource a
  été retiré. Les clients qui linkaient le profil via `media.userId`
  doivent désormais lire `media.author.id`.

Implémentation : [PublicUserSummarySerializer](../src/Http/Serializer/PublicUserSummarySerializer.php),
injecté dans [MediaResourceSerializer](../src/Http/Serializer/MediaResourceSerializer.php).

### Blocs géographiques (`city`, `subregion`, `region`, `country`)

Toutes les ressources `medias` retournées par les listings exposent jusqu'à
4 blocs hiérarchiques inline, résolus à partir des identifiants stockés
sur la `Media` (`cityId` UUID, `subregionId`/`regionId`/`countryId` codes
ISO 3166) contre les index Meilisearch correspondants. Chaque bloc est
**indépendamment nullable** : un media peut avoir un `country` mais pas
de `city` (EXIF GPS hors d'un polygone connu) et vice-versa.

```json
"city": {
  "id":          "67104949-52b7-11f1-96d5-00155dda08de",
  "name":        "Annecy",
  "slug":        "annecy",
  "latitude":    45.8992,
  "longitude":   6.1294,
  "countryId":   "fr",
  "regionId":    "fr-ara",
  "subregionId": "fr-74"
},
"subregion": { "id": "fr-74", "name": "…", "slug": "…", "regionId": "fr-ara", "countryId": "fr" },
"region":    { "id": "fr-ara", "name": "Auvergne-Rhône-Alpes", "slug": "…", "countryId": "fr" },
"country":   { "id": "fr", "name": "France", "slug": "france", "alpha3": "FRA", "numeric": "250" }
```

- Exposé sur les mêmes 8 endpoints que le bloc `author` :
  `/api/media/nearby`, `/api/media/in-bounds`, `/api/users/{userId}/media`,
  `/api/users/me/media`, `/api/users/{userId}/likes`/`dislikes`,
  `/api/users/me/likes`/`dislikes`, `/api/users/me/bookmarks`,
  `/api/social-feeds/{code}`, plus la réponse de `POST /api/users/me/media`.
- **N+1 évité** : un seul appel batch par index Meili et par page (4 calls
  total) via [MediaLocationBlocksLoader](../src/Domain/Media/MediaLocationBlocksLoader.php) ;
  resolvers individuels (`CitySummaryResolver`, `SubregionSummaryResolver`,
  `RegionSummaryResolver`, `CountrySummaryResolver`) avec cache request-scoped.
- L'identifiant clé est l'UUID dashé lowercase pour `city.id`, le code ISO
  3166 lowercase pour `subregion`/`region`/`country.id`.
- Index `subregions` partiellement couvert (~99 docs en prod) : la majorité
  des cities pointent sur un code subregion absent → `subregion: null`. Pas
  un bug, question de couverture data.
- Quand AUCUN bloc n'a été batch-loadé (chemin legacy / anonyme), les 4
  clés sont OMISES de la réponse — le wire reste rétrocompatible.

### Compteurs de profil (views + impressions)

Le même pipeline est appliqué côté utilisateurs. Toute ressource `users`
qui inclut son `stats` expose désormais :

```json
"viewsCount":       284,
"impressionsCount": 1576
```

- **view** = un appel à `GET /users/{userId}/{following,followers,media,likes,dislikes,badges}`.
  Le middleware `UserProfileViewMiddleware` (wiré sur ces 6 sous-routes,
  derrière `OptionalAuthenticationMiddleware`) lit `{userId}` sur la
  route matchée et compte un view. Déduplication par viewer sur une
  fenêtre glissante de `USER_COUNTERS_VIEW_DEDUP_WINDOW_SECONDS` (1h par
  défaut). **Self-view filtré** : un utilisateur qui consulte son propre
  profil ne s'incrémente pas.
- **impression** = un user apparaît dans la réponse de
  `GET /api/users/search`. Chaque entrée de la collection prend `+1`.
  Pas de dedup. **Self-impression filtré**.
- Les bots détectés via `jenssegers/agent` sont ignorés
  (`USER_COUNTERS_IGNORE_BOTS=true`).
- Les compteurs sont alimentés par un worker
  (`bin/user-counters-flush.php`, tick
  `USER_COUNTERS_FLUSH_TICK_SECONDS`) : ils peuvent retarder l'activité
  réelle d'un tick avant d'apparaître sur la ressource. Le worker draine
  `user_counter_event`, UPDATE `user_stats.num_views` /
  `user_stats.num_impressions` (additionnels, jamais set), puis UPSERT
  `user_view_daily` pour le jour courant.

`user_stats` reste **trigger-maintained** pour les autres compteurs
(`num_user_follower`, `num_user_followed`, `num_media`, `num_album`,
`num_comments`) — Hydrogen n'écrit que `num_views` / `num_impressions` et
exclusivement via `UserStatsCounterRepository::bumpMany()` (UPDATE
additionnel, pas d'UPSERT — la ligne `user_stats` est provisionnée à la
création du compte).

### Ressource `mediaReactions`

L'écriture (`PUT`) renvoie une ressource composite typée
`mediaReactions`, avec un id composite `mediaUuid:userUuid` :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "mediaReactions",
    "id":   "0193b2…:0193c1…",
    "attributes": {
      "mediaId":   "0193b2…",
      "userId":    "0193c1…",
      "value":     "like",
      "createdAt": "2026-06-08T14:21:03+00:00"
    }
  }
}
```

---

### `PUT /api/users/me/media/{mediaId}/reaction`

Pose **ou** modifie la réaction du viewer sur le média ciblé. Idempotent
si la même valeur est repostée. Un flip `like ↔ dislike` préserve le
`created_at` original (utile pour la stabilité des curseurs de listing).

- **Auth** : requise (Bearer)
- **Action** : [UpsertMediaReactionAction](../src/Http/Action/Api/Media/Reaction/UpsertMediaReactionAction.php)
- **Body** : accepté en JSON plat **ou** JSON:API.
  - Plat : `{"value": "like"}` (ou `"dislike"`)
  - JSON:API : `{"data": {"attributes": {"value": "like"}}}`
- **Réponses** :
  - `200 OK` + ressource `mediaReactions` (voir plus haut). Aucun
    distinguo entre création et flip — la ressource finale fait foi.
  - `400 Invalid body` : JSON malformé.
  - `403 Self reaction forbidden` : tentative d'auto-réaction.
  - `403 Account not confirmed` / `Account banned` : l'utilisateur n'est
    pas habilité à interagir.
  - `404 Media not found` : `mediaId` inconnu (UUID inexistant).
  - `422 Invalid value` : `value` absent ou pas dans `{"like","dislike"}`.
  - `422 Invalid media id` : `mediaId` n'est pas un UUID.

- **Effets de bord** :
  - `media_stats.likes_count` / `dislikes_count` mis à jour par trigger.
  - Document Meilisearch ré-indexé (best-effort).
  - **Notification owner** si et seulement si c'est la **première** like
    de ce viewer sur ce média (dedup serveur 5 min + dedup_key
    `media.reaction:<mediaHex>:<actorHex>` pour les ré-likes ultérieurs).

---

### `DELETE /api/users/me/media/{mediaId}/reaction`

Retire la réaction du viewer sur le média (peu importe sa valeur
courante). Idempotent : si aucune réaction n'existait, renvoie quand
même `204`.

- **Auth** : requise (Bearer)
- **Action** : [DeleteMediaReactionAction](../src/Http/Action/Api/Media/Reaction/DeleteMediaReactionAction.php)
- **Réponses** :
  - `204 No Content` — succès, même si aucune réaction préexistante.
  - `403 Account not confirmed` / `Account banned`.
  - `422 Invalid media id` : `mediaId` n'est pas un UUID.

Pas de `404` quand le média n'existe pas : on traite la requête comme un
unreact d'un état déjà vide (idempotent). Le re-index Meilisearch n'est
émis que si une ligne a effectivement été supprimée.

---

### `GET /api/media/{mediaId}/likes`

Liste **publique** des utilisateurs ayant liké le média ciblé, **réaction
la plus récente d'abord**. Pagination keyset sur
`(reaction.created_at DESC, user_id DESC)`.

- **Auth** : optionnelle (soft). Un Bearer token actif permet de remplir
  `isFollowedByMe` sur les ressources `users` retournées.
- **Action** : [ListMediaLikersAction](../src/Http/Action/Api/Media/Reaction/ListMediaLikersAction.php)
- **Query** : `cursor` (next), `before` (prev), `limit` (1..100, défaut 20).
- **Réponses** :
  - `200 OK` + collection JSON:API `users` (vide si personne n'a liké).
    `meta.total` = `COUNT(*)` exact (`media_reaction WHERE media_id = ? AND value = 'like'`).
  - `400 Invalid cursor`
  - `404 Media not found`
  - `422 Invalid media id`

---

### `GET /api/media/{mediaId}/dislikes`

Identique à `/likes` mais filtre sur `value = 'dislike'`.
[ListMediaDislikersAction](../src/Http/Action/Api/Media/Reaction/ListMediaDislikersAction.php).

---

### `GET /api/users/me/likes`

Liste les médias **publiés** que le viewer a likés, **réaction la plus
récente d'abord**. Les médias dépubliés a posteriori sont silencieusement
retirés de la collection (pas de 404, ils ressortiront si la
republication a lieu). Pagination keyset sur la `created_at` de la
réaction (pas du média).

- **Auth** : requise (Bearer)
- **Action** : [ListMyLikedMediaAction](../src/Http/Action/Api/Users/Reaction/ListMyLikedMediaAction.php)
- **Query** : `cursor`, `before`, `limit` (1..50, défaut 20).
- **Attribut additionnel** sur chaque ressource `medias` :
  - `reactedAt` : ISO-8601 de la date à laquelle le viewer a posé la
    réaction (utile pour un rendu "liké il y a 3 jours").
- **Réponses** :
  - `200 OK` + collection `medias` (peut être vide).
    `meta.total` = `COUNT(*)` exact des likes posés par le viewer.
  - `400 Invalid cursor`

---

### `GET /api/users/me/dislikes`

Idem `/me/likes` mais filtre sur les dislikes du viewer.
[ListMyDislikedMediaAction](../src/Http/Action/Api/Users/Reaction/ListMyDislikedMediaAction.php).

---

### `GET /api/users/{userId}/likes`

Variante **publique** : liste les médias publiés qu'un utilisateur tiers
a likés. Mêmes garanties que `/me/likes` (médias dépubliés masqués).

- **Auth** : optionnelle (soft) — permet de remplir `viewerReaction` sur
  les ressources `medias` retournées si le viewer est connecté.
- **Action** : [ListUserLikedMediaAction](../src/Http/Action/Api/Users/Reaction/ListUserLikedMediaAction.php)
- **Réponses** :
  - `200 OK` + collection `medias`.
  - `404 User not found` si `userId` n'existe pas.
  - `400 Invalid cursor`
  - `422 Invalid user id`

---

### `GET /api/users/{userId}/dislikes`

Idem `/users/{userId}/likes` mais sur les dislikes de l'utilisateur cible.
[ListUserDislikedMediaAction](../src/Http/Action/Api/Users/Reaction/ListUserDislikedMediaAction.php).

---

## Commentaires

Système de commentaires threadés sur les médias, avec réponses jusqu'à **`COMMENT_MAX_DEPTH` niveaux** (défaut : 5, soit `depth ∈ [0, 4]`).

### Modèle

| Champ        | Notes |
|--------------|-------|
| `id`         | UUID v4 du commentaire. |
| `mediaId`    | UUID du média porteur. |
| `userId`     | UUID de l'auteur. |
| `parentId`   | UUID du commentaire parent, ou `null` pour un top-level. |
| `rootId`     | UUID du top-level ancestral. Pour un top-level, `rootId = id` (dénormalisation : permet le fetch d'un thread complet avec un seul `WHERE root_id = ?`, sans CTE récursive). |
| `depth`      | 0 pour un top-level, +1 par niveau de réponse. Plafonné à `COMMENT_MAX_DEPTH - 1`. |
| `body`       | Texte du commentaire. `null` quand le commentaire est soft-supprimé. |
| `replyCount` | Nombre d'enfants directs **non supprimés**. Maintenu applicativement. |
| `createdAt`  | Date d'écriture. |
| `editedAt`   | Date de la dernière édition ; `null` si jamais édité. |
| `deletedAt`  | Date de soft-delete ; `null` si actif. |

Quand `deletedAt !== null` : la ligne est conservée pour ne pas orphelinser les enfants, mais `body` est renvoyé à `null` et `meta.deleted = true` est ajouté à la ressource.

### Compteurs

- `media_comment.reply_count` : enfants directs non supprimés (utilisé pour les listings de réponses).
- `media_stats.comments_count` : top-level non supprimés (exposé dans la ressource Media en tant que `commentsCount`, utilisé pour `meta.total` du listing `/api/media/{mediaId}/comments`).

Les deux sont maintenus **dans la même transaction** que l'INSERT / soft-delete par [MediaCommentService](../src/Domain/Media/Comment/MediaCommentService.php). Contrairement aux likes/dislikes (triggers), le choix applicatif évite des triggers complexes autour du soft-delete.

### Variables d'environnement

| Var                            | Défaut | Rôle |
|--------------------------------|--------|------|
| `COMMENT_MAX_LENGTH`           | 2000   | Longueur max du `body` (en caractères UTF-8, via `mb_strlen`). |
| `COMMENT_MAX_DEPTH`            | 5      | Niveaux de profondeur autorisés (depth ∈ [0, max-1]). |
| `COMMENT_EDIT_WINDOW_MINUTES`  | 15     | Fenêtre d'édition après création. `0` = édition désactivée, `-1` = illimitée, `N>0` = N minutes. |
| `COMMENT_DELETE_POLICY`        | both   | Qui peut soft-supprimer : `author`, `owner` (propriétaire du média), ou `both`. |

### Notifications

- Top-level → l'**owner** du média reçoit `media.comment.received` (déduplique sur `(media, actor)` dans la fenêtre).
- Réponse → l'auteur du **commentaire parent** reçoit `media.comment.reply.received` (déduplique sur `(parent, actor)`).
- Pas de notif sur soi-même (commenter / répondre à son propre contenu reste autorisé mais silencieux).
- Pas de notif lors de l'édition ni du soft-delete.

### Indexation Meili

Les commentaires ne sont **pas** indexés dans Meilisearch. Le volume peut être élevé et il n'y a pas de cas d'usage de recherche full-text aujourd'hui ; un index pourra être ajouté plus tard sans casser l'API (les ressources resteront identiques).

### `GET /api/media/{mediaId}/comments`

Liste les commentaires **top-level** d'un média, du plus récent au plus ancien. Paginé en keyset (cursor) — convention partagée avec les autres listings. Public (pas d'auth requise).

Query :
- `limit` (1..100, def 20)
- `cursor` / `before` (mutuellement exclusifs)

`meta.total` lit le compteur dénormalisé `media_stats.comments_count` (top-level non supprimés). Les commentaires soft-supprimés sont retournés avec `body: null` + `meta.deleted = true`.

[ListMediaCommentsAction](../src/Http/Action/Api/Media/Comment/ListMediaCommentsAction.php)

Erreurs :
- `404 Media not found`
- `400 Invalid cursor`
- `422 Invalid media id`

---

### `POST /api/media/{mediaId}/comments`

Crée un commentaire top-level OU une réponse. **Auth requise** (Bearer). L'utilisateur doit avoir confirmé son e-mail et ne pas être banni.

Body (flat ou JSON:API) :

```json
{ "body": "Joli cliché !", "parentId": null }
```

```json
{
  "data": {
    "type": "mediaComments",
    "attributes": {
      "body": "Merci pour la réponse 🙏",
      "parentId": "0192c4e3-…"
    }
  }
}
```

- `body` (string, requis, trimé non-vide, ≤ `COMMENT_MAX_LENGTH`).
- `parentId` (UUID, optionnel). Si renseigné, le parent doit appartenir au **même média**, ne pas être soft-supprimé, et `parent.depth + 1` doit rester < `COMMENT_MAX_DEPTH`.

Réponse `201` : ressource `mediaComments` (cf. modèle plus haut). [CreateMediaCommentAction](../src/Http/Action/Api/Media/Comment/CreateMediaCommentAction.php)

Erreurs (`meta.code`) :
- `422 comment.bodyMissing` — body absent / vide après trim.
- `422 comment.bodyTooLong` — > `COMMENT_MAX_LENGTH`.
- `404 comment.mediaNotFound` — média inconnu.
- `404 comment.parentNotFound` — parent inconnu ou rattaché à un autre média.
- `403 comment.parentDeleted` — répondre à un commentaire supprimé est refusé.
- `422 comment.depthExceeded` — profondeur max atteinte.
- `403 comment.actorNotConfirmed` / `comment.actorBanned`.

---

### `GET /api/media/comments/{commentId}/thread`

Renvoie le **thread complet** ancré au top-level dont dépend `commentId`. Si `commentId` pointe sur une réponse profonde, on remonte automatiquement à son `rootId` — la réponse est donc toujours la conversation entière (utile pour un deep-link arrivant depuis une notification).

Pas de pagination (le thread est borné par `COMMENT_MAX_DEPTH`). Les nœuds sont retournés à plat, triés par `(depth ASC, createdAt ASC, id ASC)` ; le client reconstruit l'arbre via `parentId`.

[GetMediaCommentThreadAction](../src/Http/Action/Api/Media/Comment/GetMediaCommentThreadAction.php)

Erreurs :
- `404 Comment not found`
- `422 Invalid comment id`

---

### `GET /api/media/comments/{commentId}/replies`

Liste les **enfants directs** d'un commentaire, du plus ancien au plus récent (ordre chronologique de réponse). Keyset-paginé.

Query : `limit` (1..100, def 20), `cursor` / `before`.

`meta.total` reprend le `replyCount` du parent (dénormalisé). [ListMediaCommentRepliesAction](../src/Http/Action/Api/Media/Comment/ListMediaCommentRepliesAction.php)

Erreurs :
- `404 Comment not found`
- `400 Invalid cursor`
- `422 Invalid comment id`

---

### `PATCH /api/media/comments/{commentId}`

Édite le `body` d'un commentaire. **Auth requise**. Seul l'auteur peut éditer, et uniquement dans la fenêtre `COMMENT_EDIT_WINDOW_MINUTES` (`0` = édition désactivée, `-1` = illimitée).

Body (flat ou JSON:API) — mêmes shapes que la création, mais sans `parentId` (le re-parentage n'est jamais autorisé) :

```json
{ "body": "..." }
```

Réponse `200` : ressource mise à jour avec `editedAt` rempli. [UpdateMediaCommentAction](../src/Http/Action/Api/Media/Comment/UpdateMediaCommentAction.php)

Erreurs (`meta.code`) :
- `422 comment.bodyMissing` / `comment.bodyTooLong`.
- `404 comment.notFound`.
- `403 comment.forbidden` — pas l'auteur.
- `410 comment.alreadyDeleted` — éditer un commentaire supprimé est refusé.
- `403 comment.editWindowClosed` — fenêtre expirée (ou `COMMENT_EDIT_WINDOW_MINUTES=0`).
- `403 comment.actorNotConfirmed` / `comment.actorBanned`.

---

### `DELETE /api/media/comments/{commentId}`

Soft-supprime le commentaire selon `COMMENT_DELETE_POLICY` :

- `author` — seul l'auteur peut supprimer.
- `owner` — seul le propriétaire du média peut supprimer.
- `both` (défaut) — l'un ou l'autre.

La ligne est conservée pour ne pas orphelinser ses enfants. Le `body` cesse d'être renvoyé (`null`) et `meta.deleted = true` est ajouté à la ressource. Les compteurs `reply_count` du parent (si réponse) ou `media_stats.comments_count` (si top-level) sont décrémentés dans la même transaction.

Réponse `200` : ressource redactée (pas de `204` pour permettre au client de mettre à jour sa vue en place). [DeleteMediaCommentAction](../src/Http/Action/Api/Media/Comment/DeleteMediaCommentAction.php)

Erreurs (`meta.code`) :
- `404 comment.notFound`.
- `410 comment.alreadyDeleted` — opération idempotente côté UX, mais l'API signale l'état.
- `403 comment.forbidden` — la politique en vigueur n'autorise pas le caller.
- `403 comment.actorNotConfirmed` / `comment.actorBanned`.

---

## Favoris

Système de **favoris privés** style Pinterest. Chaque utilisateur peut :

- bookmarker un média d'un autre utilisateur (auto-bookmark interdit) ;
- ranger ses favoris dans des **collections nommées** (1 média → 1 collection au plus) ;
- laisser des favoris **"non-classés"** (`collection_id IS NULL`).

L'ensemble est **strictement privé** : aucun compteur public n'est exposé sur la ressource `media`, aucune notification au propriétaire du média n'est émise, aucune gamification (pas d'XP).

### Modèle

| Table | Colonnes notables |
|---|---|
| `bookmark_collection` | `id`, `user_id`, `name` (unique par user, case-insensitive), `position`, `media_count` (cache applicatif), `cover_media_id` (FK `media`, `ON DELETE SET NULL`), `created_at`, `updated_at` |
| `user_bookmark` | `(user_id, media_id)` PK, `collection_id` (FK, **`ON DELETE SET NULL`** → garde le favori en "non-classé" si la collection est supprimée), `created_at` |

Index : `idx_ub_user_created` couvre les listings tous-favoris ; `idx_ub_user_collection_created` couvre les listings filtrés par collection et le bucket non-classé. La couverture cible toujours le tri `(created_at DESC, media_id DESC)`.

### Compteurs

`bookmark_collection.media_count` est **maintenu applicativement** dans la même transaction que le `user_bookmark` (insert / move / delete). Le bucket non-classé n'a pas de compteur dédié — il s'obtient via `COUNT(*) … WHERE collection_id IS NULL` (indexé).

### Variables d'environnement

- `BOOKMARK_COLLECTION_NAME_MAX_LENGTH` (def. `80`) — longueur max du nom (en glyphes).
- `BOOKMARK_MAX_COLLECTIONS_PER_USER` (def. `50`) — plafond du nombre de collections par user.
- `BOOKMARK_MAX_PER_COLLECTION` (def. `1000`) — plafond du nombre de favoris dans **une** collection (le bucket non-classé n'est **PAS** plafonné).

### Règles métier

- **Auto-bookmark interdit** — `403 bookmark.selfBookmark`.
- Caller doit être confirmé + non banni — `403 bookmark.actorNotConfirmed` / `bookmark.actorBanned`.
- Le média cible doit exister — `404 bookmark.mediaNotFound`.
- La collection cible (si fournie) doit appartenir au caller — `404 bookmark.collectionNotFound` (un id non-possédé renvoie **404** par sécurité, pour ne pas révéler l'existence des collections d'autrui).
- Nom de collection : trimmed-non-empty, ≤ `BOOKMARK_COLLECTION_NAME_MAX_LENGTH`, unique par user (collation `utf8mb4_unicode_ci`).
- Cover pinned : le média doit être **membre** de la collection (sinon `422 bookmark.coverNotInCollection`). À chaque retrait du média (move ou unbookmark), la couverture pinned est automatiquement effacée et le serializer retombe sur le dernier média ajouté.

### Idempotence

- `PUT /api/users/me/media/{mediaId}/bookmark` est idempotent : 2 appels avec le même `collectionId` → 200 no-op. Un appel avec un `collectionId` différent de l'existant **déplace** le favori (compteurs swappés dans la même tx).
- `DELETE` renvoie `404 bookmark.notFound` si rien n'était bookmarké (vs `204` silencieux) — la cliente peut ainsi rafraîchir son état UI.

### `GET /api/users/me/bookmarks/collections`

Liste les collections du caller + une entrée virtuelle `id = "unclassified"` exposant le `mediaCount` du bucket non-classé. Pas de pagination (cap env). Auth requise. [ListMyBookmarkCollectionsAction](../src/Http/Action/Api/Users/Bookmark/ListMyBookmarkCollectionsAction.php)

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    {
      "type": "bookmarkCollections",
      "id": "0193…",
      "attributes": {
        "userId": "0193…",
        "name": "Restos à tester",
        "position": 0,
        "mediaCount": 12,
        "coverMediaId": "0193…",
        "pinnedCover": false,
        "createdAt": "2026-06-10T10:00:00+00:00",
        "updatedAt": null
      }
    },
    {
      "type": "bookmarkCollections",
      "id": "unclassified",
      "attributes": { "userId": "0193…", "name": null, "position": -1, "mediaCount": 4, "coverMediaId": null, "pinnedCover": false, "createdAt": null, "updatedAt": null },
      "meta": { "virtual": true }
    }
  ],
  "meta": { "total": 2 }
}
```

### `POST /api/users/me/bookmarks/collections`

Crée une collection vide. Body flat `{ "name": "…" }` ou JSON:API. Retourne `201`. [CreateBookmarkCollectionAction](../src/Http/Action/Api/Users/Bookmark/CreateBookmarkCollectionAction.php)

Erreurs (`meta.code`) : `422 bookmark.collectionNameMissing` / `bookmark.collectionNameTooLong` / `bookmark.collectionLimitReached`, `409 bookmark.collectionNameTaken`.

### `PATCH /api/users/me/bookmarks/collections/{collectionId}`

Patch partiel : tout sous-ensemble de `{ name, position, coverMediaId }` (envoyer `coverMediaId = null` efface explicitement la couverture pinned). Retourne `200` avec la ressource. [UpdateBookmarkCollectionAction](../src/Http/Action/Api/Users/Bookmark/UpdateBookmarkCollectionAction.php)

Erreurs : voir tableau global (`404 bookmark.collectionNotFound`, `409 bookmark.collectionNameTaken`, `422 bookmark.collectionNameTooLong` / `bookmark.coverNotInCollection`).

### `DELETE /api/users/me/bookmarks/collections/{collectionId}`

Hard-delete de la collection. Ses favoris sont **conservés** et passés dans le bucket "non-classé" via `ON DELETE SET NULL`. Retourne `204`. [DeleteBookmarkCollectionAction](../src/Http/Action/Api/Users/Bookmark/DeleteBookmarkCollectionAction.php)

### `GET /api/users/me/bookmarks`

Liste les favoris du caller, paginés en keyset sur `(createdAt DESC, mediaId DESC)`. Auth requise. [ListMyBookmarksAction](../src/Http/Action/Api/Users/Bookmark/ListMyBookmarksAction.php)

Filtres mutuellement exclusifs :
- `?collectionId=<uuid>` — uniquement cette collection (404 si non-possédée).
- `?unclassified=true` — uniquement le bucket non-classé.
- (rien) — tous les favoris du caller.

Paramètres : `?limit=…` (def 20, max 100), `?cursor=…` / `?before=…`.

Chaque ressource est une `media` (mêmes attributs que les autres listings de médias, avec `viewerReaction` et stats) enrichie d'un `meta.bookmark = { collectionId, createdAt }`. `meta.total` est exact (COUNT indexé).

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    {
      "type": "media",
      "id": "0193…",
      "attributes": { "blurHash": "…", "url": "…", "likesCount": 7, "viewerReaction": "like", … },
      "meta": { "bookmark": { "collectionId": "0193…", "createdAt": "2026-06-10T11:32:01+00:00" } }
    }
  ],
  "links": { "self": "/api/users/me/bookmarks", "next": "…", "prev": null },
  "meta": { "limit": 20, "total": 142 }
}
```

### `PUT /api/users/me/media/{mediaId}/bookmark`

Bookmark **idempotent**. Body optionnel :
- vide → bucket non-classé.
- `{ "collectionId": "<uuid>" }` → range dans cette collection.
- `{ "collectionId": null }` → bucket non-classé.

Si un favori existait déjà, l'opération **déplace** le favori vers la nouvelle cible (compteurs swappés). Retourne `200` avec la ressource `bookmarks`. [UpsertBookmarkAction](../src/Http/Action/Api/Users/Bookmark/UpsertBookmarkAction.php)

Erreurs (`meta.code`) : `403 bookmark.selfBookmark` / `bookmark.actorNotConfirmed` / `bookmark.actorBanned`, `404 bookmark.mediaNotFound` / `bookmark.collectionNotFound`, `422 bookmark.collectionFull`.

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "bookmarks",
    "id": "0193…userHex:0193…mediaHex",
    "attributes": {
      "userId": "0193…",
      "mediaId": "0193…",
      "collectionId": "0193…",
      "createdAt": "2026-06-10T11:32:01+00:00"
    }
  }
}
```

### `DELETE /api/users/me/media/{mediaId}/bookmark`

Retire le favori. `204` si retiré, `404 bookmark.notFound` si rien n'était bookmarké. La couverture pinned de la collection est automatiquement effacée si elle pointait sur ce média. [DeleteBookmarkAction](../src/Http/Action/Api/Users/Bookmark/DeleteBookmarkAction.php)

---

## Social feeds

Un **social feed** regroupe entre `SOCIAL_FEED_MEDIA_MIN` et `SOCIAL_FEED_MEDIA_MAX` (par défaut **1 à 15**) des **médias publiés de l'utilisateur connecté** sous un **code court** (Crockford Base32, 4..10 caractères) qu'on peut partager hors-plateforme (réseaux sociaux, e-mail, SMS…). Le destinataire ouvre l'URL `/api/social-feeds/{code}` et reçoit la liste complète du carrousel en un seul appel.

**Codes** : alphabet `0123456789ABCDEFGHJKMNPQRSTVWXYZ` (Crockford, sans I/L/O/U → pas d'ambiguïté oral/saisie). Longueur initiale **4** (env `SOCIAL_FEED_CODE_LENGTH`, ≈ 1.05M codes uniques). Quand les collisions saturent la file de tentatives (`SOCIAL_FEED_CODE_MAX_ATTEMPTS`, défaut 5), le générateur renvoie `503 socialFeed.codeExhausted` — signal d'ops pour passer la longueur à 5, 6, etc., jusqu'à la borne de la colonne (`VARCHAR(10)`). Les codes courts déjà émis restent valides.

**Règles produit** :
- Pas de XP, pas de notification, pas de compteur public.
- Les médias d'un feed doivent **exister, être publiés ET appartenir à l'auteur**. Un média dépublié *après* la création du feed est silencieusement filtré de la lecture publique (la ligne reste en DB).
- L'ordre des `mediaIds` au POST est l'ordre du carrousel (`position` 1..N en base, contrainte UNIQUE).
- Un feed est **permanent** ; seul son auteur peut le supprimer (`DELETE /api/users/me/social-feeds/{code}`).
- Lecture publique anonyme OK (la share URL ne nécessite pas d'auth) ; un viewer authentifié récupère en plus son `viewerReaction` sur chaque média du carrousel.

### `POST /api/users/me/social-feeds`

Crée un feed. Auth requise. Le body accepte la forme JSON:API (`data.attributes.mediaIds`) ou plate (`{ "mediaIds": [...] }`).

```json
{
  "data": {
    "attributes": {
      "mediaIds": [
        "11111111-1111-1111-1111-111111111111",
        "22222222-2222-2222-2222-222222222222",
        "33333333-3333-3333-3333-333333333333"
      ]
    }
  }
}
```

Réponse `201` :

```json
{
  "data": {
    "type": "socialFeeds",
    "id": "7JBQ",
    "attributes": {
      "userId":     "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
      "mediaCount": 3,
      "mediaIds":   [
        "11111111-1111-1111-1111-111111111111",
        "22222222-2222-2222-2222-222222222222",
        "33333333-3333-3333-3333-333333333333"
      ],
      "createdAt":  { "iso": "...", "formatted": { "...": "..." }, "relative": "..." }
    }
  }
}
```

Erreurs (`422` sauf indication) :

| Code                            | Raison |
|---------------------------------|--------|
| `socialFeed.actorNotConfirmed`  | (403) compte non confirmé |
| `socialFeed.actorBanned`        | (403) compte banni |
| `socialFeed.tooFewMedias`       | moins de `SOCIAL_FEED_MEDIA_MIN` médias |
| `socialFeed.tooManyMedias`      | plus de `SOCIAL_FEED_MEDIA_MAX` médias |
| `socialFeed.duplicateMedia`     | un même `mediaId` apparaît plusieurs fois |
| `socialFeed.mediaNotFound`      | un `mediaId` n'existe pas ou n'est pas publié |
| `socialFeed.mediaNotOwned`      | un `mediaId` n'appartient pas à l'utilisateur |
| `socialFeed.codeExhausted`      | (503) bumpez `SOCIAL_FEED_CODE_LENGTH` |

[CreateSocialFeedAction](../src/Http/Action/Api/SocialFeed/CreateSocialFeedAction.php)

### `GET /api/users/me/social-feeds`

Liste les feeds de l'utilisateur authentifié, du plus récent au plus ancien. Paramètre `?limit=` (1..100, défaut 50). Pas de pagination keyset — la volumétrie attendue par utilisateur est faible. `meta.total` est exact.

Chaque ressource expose `mediaIds` (UUIDs ordonnés) — pas les médias complets ; le client utilise les UUIDs pour résoudre les thumbnails via les URLs media existantes. [ListMySocialFeedsAction](../src/Http/Action/Api/SocialFeed/ListMySocialFeedsAction.php)

### `DELETE /api/users/me/social-feeds/{code}`

Supprime un feed appartenant à l'utilisateur connecté. `204` en cas de succès. `404 socialFeed.notFound` lorsque le code n'existe pas **OU** appartient à un autre utilisateur (confusion délibérée pour éviter l'énumération des codes par sonde DELETE). [DeleteSocialFeedAction](../src/Http/Action/Api/SocialFeed/DeleteSocialFeedAction.php)

### `GET /api/social-feeds/{code}`

**Lecture publique** : pas d'auth requise (anonymous OK via `OptionalAuthenticationMiddleware`). Retourne la ressource feed avec :

- `mediaIds` — UUIDs ordonnés du carrousel ;
- `medias` — la liste **complète** des médias hydratés (mêmes attributs que `GET /api/media/{id}` : `url`, `blurhashUrl`, `latitude/longitude`, `author` public, `likesCount`, etc.), dans l'ordre du carrousel. Les médias dépubliés depuis la création sont filtrés ; `medias.length` peut donc être strictement inférieur à `mediaCount`.

Un viewer authentifié reçoit en plus `viewerReaction` sur chaque média.

`404 socialFeed.notFound` si le code n'a pas la bonne forme (longueur/alphabet) ou n'existe pas — même réponse pour les deux cas. [GetSocialFeedAction](../src/Http/Action/Api/SocialFeed/GetSocialFeedAction.php)

---

## Parrainage

Chaque utilisateur reçoit, **au moment de son inscription**, un code de parrainage unique qu'il pourra communiquer à de futurs inscrits. Lorsqu'un nouvel inscrit fournit ce code dans son `POST /api/auth/register` (champ optionnel `sponsorshipCode`), une relation parrain → filleul est enregistrée. Deux compteurs sont maintenus côté parrain :

- `numUserTotal` — nombre total de filleuls associés. Incrémenté **à l'inscription du filleul** (au moment où le code est validé et l'edge `sponsorship_user` insérée).
- `numUserValid` — nombre de filleuls ayant **confirmé leur adresse e-mail**. Incrémenté au moment du clic sur le lien de confirmation.

L'invariant côté base est `numUserValid <= numUserTotal`. L'écart entre les deux représente les filleuls inscrits mais qui n'ont pas (encore) activé leur compte.

### Notification au parrain

À chaque association d'un filleul, un e-mail est envoyé au parrain pour l'informer de l'inscription (template [`templates/mails/sponsorship_referred_signup.twig`](../templates/mails/sponsorship_referred_signup.twig), traductions dans `resources/lang/<locale>/mails.php` sous la clé `sponsorshipReferredSignup`). Le mail est rendu dans la locale du **parrain** (et non celle de la requête HTTP du filleul). L'échec d'envoi est silencieusement absorbé côté `RegistrationService` : la notif est informationnelle, elle ne doit jamais faire échouer l'inscription du filleul.

### Format du code

- **Longueur actuelle** : 6 caractères.
- **Alphabet** : Crockford Base32 — `0-9` + `A-Z` sans les lettres ambiguës `I`, `L`, `O`, `U`. Cela rend le code facile à dicter oralement et illisible-de-travers (pas de confusion `0`/`O` ou `1`/`I`/`L`).
- **Casse** : le serveur normalise en majuscules ; côté client la saisie est case-insensitive.
- **Codespace** : 32⁶ ≈ 1,07 × 10⁹ combinaisons — largement suffisant pour la base actuelle, avec possibilité d'allonger la longueur plus tard sans casser les codes existants (la longueur n'est imposée qu'à la **génération**, la validation accepte 6 caractères pour le moment et sera assouplie si besoin).
- **Génération** : tirages CSPRNG via `random_int` caractère par caractère ; en cas de collision sur l'index unique (très improbable mais possible), la transaction d'inscription retente jusqu'à 5 fois avant d'échouer.

Voir [SponsorshipCode](../src/Domain/Sponsorship/SponsorshipCode.php) pour les détails d'implémentation.

### Validation à l'inscription

Lorsque `sponsorshipCode` est fourni dans `POST /api/auth/register` :

- Si le format ne correspond pas à l'alphabet/longueur attendus → erreur `422` avec le code `sponsorship.codeInvalid` (pointeur `/data/attributes/sponsorshipCode`).
- Si le format est valide mais qu'aucun utilisateur ne porte ce code → erreur `422` avec le code `sponsorship.codeNotFound`.
- Dans les deux cas, **aucun utilisateur n'est créé** : la résolution du code se fait avant l'insertion pour éviter les comptes fantômes.
- Si le code résout vers l'utilisateur en train de s'inscrire (cas théorique impossible puisque le compte n'existe pas encore, mais protégé en interne), l'association est silencieusement ignorée — on ne s'auto-parraine pas.

Quand la résolution réussit, l'inscription provisionne dans une seule transaction logique :
1. La ligne `user`.
2. La ligne `sponsorship` (code unique du nouvel inscrit).
3. La ligne `sponsorship_stats` initialisée à zéro.
4. La ligne `sponsorship_user` reliant le filleul au parrain.

### Incrémentation du compteur `numUserValid`

Le compteur est incrémenté **atomiquement** au moment de la confirmation d'e-mail du filleul ([EmailConfirmationService](../src/Domain/Auth/EmailConfirmationService.php)). L'idempotence repose sur le fait que les tokens de confirmation sont à usage unique : un même filleul ne peut donc déclencher l'incrément qu'une fois. Si le filleul n'a pas de parrain associé, l'opération est un no-op.

---

### `GET /api/users/me/sponsorship`

Retourne le code de parrainage du viewer authentifié et ses statistiques courantes.

- **Auth** : requise (Bearer token).
- **Réponse** :
  - `200 OK` — l'utilisateur dispose bien d'une ligne `sponsorship` (cas standard).
  - `404` avec le code interne `Sponsorship not provisioned` — l'utilisateur a été créé **avant** l'introduction du parrainage et n'a jamais été provisionné. Le client doit afficher un message "fonctionnalité indisponible pour ce compte" plutôt que tenter de générer un code à la volée (un `GET` ne doit pas écrire en base).

#### Exemple de réponse

```json
{
  "data": {
    "type": "sponsorships",
    "id": "5f6e2d4a-1b8c-4d2e-9a3b-7c1d8e9f0a2b",
    "attributes": {
      "code": "A7K3M9",
      "numUserTotal": 7,
      "numUserValid": 4,
      "createdAt": "2026-06-10T14:23:11+00:00"
    }
  }
}
```

[GetMySponsorshipAction](../src/Http/Action/Api/Users/GetMySponsorshipAction.php).

---

## Coupons

Système de codes promotionnels saisis manuellement par l'administration dans la table `hxa.coupon`. Aucune API d'administration ne les expose — Hydrogen se contente de **les consommer** quand un utilisateur en fournit un.

### Schéma des tables

- `hxa.coupon` (existante, **non gérée par Hydrogen**) :
  - `id VARCHAR(32)` — code lisible saisi par l'admin (PK).
  - `user_limit INT UNSIGNED` — plafond d'utilisations. **`0` = illimité**.
  - `start DATETIME NULL`, `end DATETIME NULL` — fenêtre de validité optionnelle.
  - `user_count INT UNSIGNED` — compteur courant, bumpé atomiquement par Hydrogen à chaque rédemption.
- `hxa.reward` (existante) : catalogue des types de récompense. À ce jour : `experience`, `point`. **Seul `experience` est actuellement appliqué** ; `point` est silencieusement ignoré jusqu'à ce que le domaine "points" existe.
- `hxa.coupon_reward` (existante) : paramétrage `(coupon_id, reward_id, value, badge_id?)`. Plusieurs rewards par coupon possibles. `badge_id` est lu mais ignoré côté Hydrogen.
- `hxa.coupon_user` (**créée par Hydrogen** — migration `2026_06_10_160000_create_coupon_user_table.sql`) : registre append-only des rédemptions, PK composite `(coupon_id, user_id)` qui garantit "1 fois par user, à jamais".

### Règles de rédemption

Dans une **transaction unique** ([CouponRedemptionService](../src/Domain/Coupon/CouponRedemptionService.php)) :

1. Lookup du coupon par `id`. Inexistant → `coupon.notFound`.
2. Vérification de `start` (si renseigné) et `end` (si renseigné) contre `now`. Hors fenêtre → `coupon.notStarted` ou `coupon.expired`.
3. INSERT dans `coupon_user`. Collision PK → `coupon.alreadyRedeemed` (cet utilisateur a déjà consommé ce coupon).
4. `UPDATE coupon SET user_count = user_count + 1 WHERE id = ? AND (user_limit = 0 OR user_count < user_limit)`. Si `rowCount = 0` → `coupon.userLimitReached`. Le check est atomique et race-safe.
5. Lecture de `coupon_reward` (joint avec `reward`) et application :
   - `reward.type = 'experience'` avec `value > 0` → [ExperienceService::award](../src/Domain/User/ExperienceService.php) (rejoint la transaction en cours, pas de double-XP possible).
   - `reward.type = 'point'` → no-op silencieux.
   - Type inconnu → no-op silencieux (forward-compat : un admin peut ajouter un type avant le déploiement du code correspondant).

Toute exception en cours de route déclenche un `rollBack` complet : pas de demi-rédemption.

### Codes de résultat

| Code                        | Sens                                                              |
| --------------------------- | ----------------------------------------------------------------- |
| `coupon.notFound`           | Le `couponId` ne correspond à aucune ligne de `coupon`.           |
| `coupon.notStarted`         | `start` est dans le futur.                                        |
| `coupon.expired`            | `end` est dans le passé.                                          |
| `coupon.userLimitReached`   | `user_count` a atteint `user_limit` (cap strict).                 |
| `coupon.alreadyRedeemed`    | Cet utilisateur a déjà utilisé ce coupon.                         |
| `coupon.applyFailed`        | Erreur technique inattendue (rollback) — à journaliser côté ops.  |

### Utilisation à l'inscription

L'unique point d'entrée actuel est le champ optionnel `couponId` de [POST /api/auth/register](#post-apiauthregister). Toute défaillance est **non bloquante** : la réponse `201` contient `meta.coupon = { applied, couponId, reason?, message?, rewards? }`. Le client est libre d'afficher un message d'info ou de proposer une nouvelle saisie post-inscription (endpoint dédié non implémenté à ce jour).

---

## Établissements

Triplet `/api/establishments` (list paginé) / `/api/establishments/search` (full-text + géo) / `/api/establishments/{id}` (détail) adossé à l'index Meilisearch `MEILISEARCH_ESTABLISHMENTS_INDEX` (défaut `establishments_dev` ; prod : `establishments`). Catalogue **~350 000 documents** ⇒ pagination obligatoire sur les listings.

Contrairement aux médias et aux utilisateurs, Hydrogen **n'a pas de domaine `Establishment` côté MySQL** : l'index Meilisearch est la **source de vérité**, alimenté par un processus externe. Pas de re-hydratation SQL, pas de cache applicatif — chaque hit Meili devient ressource JSON:API directement.

**Identité côté API** : le `id` JSON:API d'une ressource `establishments` est le **hash hex 32 caractères lowercase** stocké dans le champ `id` du document Meili (ex: `00003c7104994cc688663a51b89c9d40`). Les documents source sont déjà en minuscules, la regex de path `^[a-f0-9]{32}$` est **stricte** sur la casse pour garder un espace d'URL canonique (pas de variante upper).

**Shape attributes commune (formatter partagé)** : les 3 endpoints utilisent `EstablishmentHitFormatter`, qui :
1. recopie tous les champs du document dans `attributes`, sauf les clés Meili-internes (`_geo`, `_geoDistance`, `_formatted`, `_matchesPosition`, `_rankingScore`, `_rankingScoreDetails`) et le `id` brut (passe en JSON:API id) ;
2. aplatit `_geo: { lat, lng }` en `attributes.latitude` / `attributes.longitude` (les coords source sont stockées comme strings — `"42.644108000000"` — le `(float)` cast les normalise) ;
3. expose `_geoDistance` (mètres) en `attributes.distanceMeters` quand le tri géo est actif (search en mode géo uniquement).

Champs métier exposés tels quels : `name`, `open_location_code`, `class[]`, `contact{email[], website[], phone[]}`, `city{id[], name[]}`, `region{id[], name[]}`, `country{id[], name[]}`, `continent{id[], name[]}`. Les sous-objets géographiques (`city`/`region`/`country`/`continent`) sont **passés tels quels depuis l'index** : leur format (tableau d'ids/noms) est défini par le pipeline d'alimentation, pas par Hydrogen.

> `created_at` et `updated_at` sont stockés dans l'index (le formatter les utilise pour le tri whitelist `sort=created_at` / `sort=updated_at`) mais **sont privés** : ils ne sortent pas dans `attributes` — bookkeeping ETL, hors contrat public.

> **Pas de description Markdown ici** — contrairement aux pays/régions/sous-régions, les établissements n'ont pas de blurb éditorial. Si on en ajoute un jour, ce sera via un domaine BO dédié (pas via `resources/lang/`).

### `GET /api/establishments`

Listing paginé du catalogue, sans filtre full-text ni géo (pour ça, voir `/api/establishments/search`).

- **Auth** : aucune.
- **Action** : [ListEstablishmentsAction](../src/Http/Action/Api/Establishment/ListEstablishmentsAction.php).
- **Query** :
  - `limit` (int, défaut `20`, plafond `100`).
  - `offset` (int ≥ 0, défaut `0`).
  - `sort` (string, optionnel) — whitelist : `created_at` / `-created_at` (= défaut, desc), `created_at_asc`, `updated_at` / `-updated_at`, `updated_at_asc`, `name` (asc), `-name` (desc). Valeur hors whitelist → `422 Invalid sort` (évite d'exposer un attribut absent de `sortableAttributes` ce qui 503erait Meili).
- **Tri par défaut** : `created_at:desc` — les nouveautés en haut.

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "establishments",
      "id":   "00003c7104994cc688663a51b89c9d40",
      "attributes": {
        "name":               "Appartement de vacances à Grad Dubrovnik",
        "open_location_code": "8FJWJ3VR+J4",
        "class":              [],
        "contact":            { "email": [], "website": [], "phone": [] },
        "city":               { "id": [], "name": [] },
        "region":             { "id": [], "name": [] },
        "country":            { "id": [], "name": [] },
        "continent":          { "id": [], "name": [] },
        "latitude":42.644108,
        "longitude":          18.090339
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/establishments?limit=20",
    "first": "https://api.example/api/establishments?limit=20",
    "prev":  null,
    "next":  "https://api.example/api/establishments?limit=20&offset=20",
    "last":  "https://api.example/api/establishments?limit=20&offset=354500"
  },
  "meta": {
    "totalHits": 354505,
    "limit":     20,
    "offset":    0,
    "count":     20,
    "sort":      "created_at:desc"
  }
}
```

- **Réponses d'erreur** :
  - `422 Invalid sort` — valeur de `sort` hors whitelist.
  - `503 Search backend unavailable`.

### `GET /api/establishments/search`

Recherche d'établissements par **nom** (`?q=…`), par **proximité GPS** (`?lat=…&lng=…&distance=…`), ou les deux combinés.

- **Auth** : aucune (endpoint public).
- **Action** : [SearchEstablishmentsAction](../src/Http/Action/Api/Establishment/SearchEstablishmentsAction.php).
- **Query** :
  - `q` (optionnel) — recherche full-text. Sans `q`, Meilisearch retourne tous les documents (filtrés par géo si présent), ordre par défaut.
  - `lat`, `lng`, `distance` — **tous les trois ou aucun**. Fournir l'un sans les autres ⇒ `422 Incomplete geo parameters`. Avec eux, le filtre `_geoRadius(lat, lng, distance)` s'applique ET le tri bascule sur `_geoPoint(lat, lng):asc` (du plus proche au plus éloigné).
    - `lat` : float dans `[-90, 90]`.
    - `lng` : float dans `[-180, 180]`.
    - `distance` : entier positif (mètres), borné par `ESTABLISHMENT_NEARBY_MAX_DISTANCE_METERS` (défaut **50 km**). Au-delà → `422 Distance too large`.
  - `limit` (1..50, défaut 20).
  - `offset` (≥0, défaut 0).

- **Pipeline** : pas de re-hydratation MySQL — mêmes étapes de mise en forme que le listing (formatter partagé), avec en plus `attributes.distanceMeters` rempli quand le tri géo est actif.

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "establishments",
      "id":   "00003c7104994cc688663a51b89c9d40",
      "attributes": {
        "name":               "Café des Arts",
        "open_location_code": "8FW4V75V+8Q",
        "class":              ["cafe"],
        "contact":            { "email": [], "website": [], "phone": [] },
        "city":               { "id": [], "name": [] },
        "region":             { "id": [], "name": [] },
        "country":            { "id": [], "name": [] },
        "continent":          { "id": [], "name": [] },
        "latitude":48.8566,
        "longitude":          2.3522,
        "distanceMeters":     412.7
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/establishments/search?q=café&lat=48.8566&lng=2.3522&distance=2000&limit=20",
    "first": "https://api.example/api/establishments/search?q=café&lat=48.8566&lng=2.3522&distance=2000&limit=20",
    "prev":  null,
    "next":  "https://api.example/api/establishments/search?q=café&lat=48.8566&lng=2.3522&distance=2000&limit=20&offset=20",
    "last":  "https://api.example/api/establishments/search?q=café&lat=48.8566&lng=2.3522&distance=2000&limit=20&offset=80"
  },
  "meta": {
    "totalHits": 87,
    "limit": 20,
    "offset": 0,
    "query": "café",
    "center": { "lat": 48.8566, "lng": 2.3522 },
    "distance": 2000
  }
}
```

  - `meta.totalHits` : **estimé** par Meilisearch (sémantique offset, cf. [conventions générales](#conventions-générales)).
  - `meta.center` et `meta.distance` ne sont présents que si le mode géo est actif.
  - `attributes.distanceMeters` n'est présent que sur les hits issus d'un tri `_geoPoint:asc` (mode géo).

- **Pagination** : offset-based, navigation via `links.{self,first,prev,next,last}` (`links.last` calculé grâce à `totalHits`).

- **Réponses d'erreur** :
  - `422 Incomplete geo parameters` — un seul de `lat`/`lng`/`distance` est fourni (ou deux sur trois).
  - `422 Invalid latitude` / `Invalid longitude` / `Invalid distance` — valeurs hors plage ou non numériques.
  - `422 Distance too large` — `distance` > `ESTABLISHMENT_NEARBY_MAX_DISTANCE_METERS`.
  - `503 Search backend unavailable` — Meilisearch injoignable, index manquant, ou pré-requis index non satisfaits (`_geo` pas dans `filterableAttributes`/`sortableAttributes`). Le détail Meili est propagé dans `errors[0].detail`.

> **Pré-requis index Meilisearch** (ops one-shot, sans ça le mode géo 503e) :
> - `_geo` doit être dans `filterableAttributes` ET `sortableAttributes`
> - Les champs textuels à exposer dans la recherche (`name`, `address`, etc.) doivent être listés dans `searchableAttributes`
>
> Sans ça, l'endpoint répond `503` côté géo (la recherche purement full-text reste fonctionnelle tant qu'un `searchableAttributes` est configuré).

### `GET /api/establishments/{id}`

Détail d'un établissement par son id hex.

- **Auth** : aucune.
- **Action** : [GetEstablishmentAction](../src/Http/Action/Api/Establishment/GetEstablishmentAction.php).
- **Path** :
  - `{id}` — regex **stricte** `[a-f0-9]{32}`. Pas de normalisation de casse : `00003C71…` (upper) ⇒ `404`. Le pattern de routage Slim porte déjà la regex, donc tout id mal formé n'atteint même pas l'action.
- **Résolution** : filtre Meili exact `id = "<hex>"` sur l'index (l'attribut `id` est filterable) — pas de recherche full-text, le scorer pourrait surfacer un mauvais doc sur des préfixes d'id.

#### Enrichissement statique (détail uniquement)

En plus des champs venant de l'index, le détail attache **deux attributs** lus depuis le mount partagé `hexatrip-static` (cf. envs `ESTABLISHMENT_STATIC_PATH` / `ESTABLISHMENT_STATIC_PUBLIC_URL`, défaut = mêmes valeurs qu'`AVATAR_*`) :

- **`images`** — liste **ordonnée alphabétiquement** des URL absolues du dossier `images/` du bucket de l'établissement (carousel client). Extensions retenues : `webp`, `jpg`, `jpeg`, `png`, `gif`, `avif`. Préfixer les fichiers par `01_`, `02_`, … donne un contrôle d'ordre sans état BD.
  - **Fallback** : bucket absent / dossier `images/` absent / contenu vide après filtre ⇒ `images` contient **une seule entrée**, l'URL absolue du fichier partagé `<ESTABLISHMENT_STATIC_PUBLIC_URL>/establishment/default-establishment.webp` (fichier ops à la racine de `establishment/`, calqué sur le pattern `user/default-avatar.webp`). Le client peut donc toujours afficher au moins une slide sans cas particulier "pas d'image".
- **`description`** — corps Markdown **brut** (non rendu serveur-side) lu depuis `<locale>_description.md` dans le bucket. Résolution :
  1. `<requestLocale>_description.md` (si la locale est supportée — cf. `SupportedLocales`),
  2. sinon `<FALLBACK>_description.md` (`fr-FR` par défaut),
  3. sinon `null`.

Bucket on-disk, **ventilé sur 3 niveaux** comme les avatars (3 paires de 2 chars hex du début de l'id) :

```
<ESTABLISHMENT_STATIC_PATH>/establishment/
  default-establishment.webp        ← fallback partagé (carousel sans image)
  AA/BB/CC/<hex32>/
    images/
      01_facade.webp
      02_lobby.webp
      …
    fr-FR_description.md
    en-US_description.md
```

Exemple pour `id = 00003c7104994cc688663a51b89c9d40` ⇒ `establishment/00/00/3c/00003c7104994cc688663a51b89c9d40/`.

Le loader [EstablishmentStaticAssetsLoader](../src/Domain/Establishment/EstablishmentStaticAssetsLoader.php) re-valide l'id contre `^[a-f0-9]{32}$` et la locale via `SupportedLocales::supports()` avant tout `stat()` (path-traversal guard). Un bucket absent n'est jamais une erreur : la ressource sort avec `images: []` + `description: null`.

> ⚠️ Cet enrichissement n'est **pas** appliqué sur `/api/establishments` ni `/api/establishments/search` — sinon chaque page paginée déclencherait jusqu'à 100 `scandir()` consécutifs. Si un listing a besoin d'une miniature, ce sera un champ dédié dans l'index Meili (pas une lecture disque par hit).

- **Réponse `200 OK`** :

```json
{
  "data": {
    "type": "establishments",
    "id":   "00003c7104994cc688663a51b89c9d40",
    "attributes": {
      "name":               "Appartement de vacances à Grad Dubrovnik",
      "open_location_code": "8FJWJ3VR+J4",
      "class":              [],
      "contact":            { "email": [], "website": [], "phone": [] },
      "city":               { "id": [], "name": [] },
      "region":             { "id": [], "name": [] },
      "country":            { "id": [], "name": [] },
      "continent":          { "id": [], "name": [] },
      "latitude":           42.644108,
      "longitude":          18.090339,
      "images": [
        "http://hexatrip-static.dev.com/establishment/00/00/3c/00003c7104994cc688663a51b89c9d40/images/01_facade.webp",
        "http://hexatrip-static.dev.com/establishment/00/00/3c/00003c7104994cc688663a51b89c9d40/images/02_lobby.webp"
      ],
      "description": "## À propos\n\nUn appartement de charme au cœur de la vieille ville…"
    }
  }
}
```

- **Réponses d'erreur** :
  - `404 Establishment not found` — id mal formé (n'atteint pas l'action, le router 404e via la regex) OU id valide mais absent de l'index.
  - `503 Search backend unavailable`.

---

## Offres

Triplet `/api/offers` (list paginé) / `/api/offers/search` (full-text + géo) / `/api/offers/{id}` (détail) adossé à l'index Meilisearch `MEILISEARCH_OFFERS_INDEX` (**`offers_v2`** en prod comme en dev — le suffixe de version reste dans l'env pour permettre des rolls d'index blue/green sans toucher au code, qui parle toujours d'« offers » au sens logique). Catalogue **~377 000 documents** ⇒ pagination obligatoire sur les listings.

Comme pour les établissements, Hydrogen **n'a pas de domaine `Offer` côté MySQL** : l'index Meilisearch est la **source de vérité**, alimenté par un processus externe. Pas de re-hydratation SQL, pas de cache applicatif — chaque hit Meili devient ressource JSON:API directement.

**Identité côté API** : le `id` JSON:API d'une ressource `offers` est le **hash hex 32 caractères lowercase** stocké dans le champ `id` du document Meili. Même contrainte de canonicité que `/api/establishments/{id}` : la regex de path `^[a-f0-9]{32}$` est **stricte** sur la casse (pas de normalisation silencieuse).

**Shape attributes commune (formatter partagé)** : les 3 endpoints utilisent `OfferHitFormatter`, qui :
1. recopie tous les champs du document dans `attributes`, sauf les clés Meili-internes (`_geo`, `_geoDistance`, `_formatted`, `_matchesPosition`, `_rankingScore`, `_rankingScoreDetails`) et le `id` brut (passe en JSON:API id) ;
2. aplatit `_geo: { lat, lng }` en `attributes.latitude` / `attributes.longitude` ;
3. expose `_geoDistance` (mètres) en `attributes.distanceMeters` quand le tri géo est actif (search en mode géo uniquement).

Champs métier exposés tels quels : `name`, `description`, `url`, `call_price{price, currency}`, `position`, `importance`, `open_location_code`, `brand{brand_id, brand_name, brand_code}`, `establishment{id, name, slug}`.

> `created_at` et **`udated_at`** (sic — la faute de frappe vit dans le pipeline source) sont stockés dans l'index (gardés pour le tri whitelist `sort=created_at` / `sort=udated_at`) mais **sont privés** : ils ne sortent pas dans `attributes` — bookkeeping ETL, hors contrat public.

> **Pas de description Markdown ici** — contrairement aux pays/régions/sous-régions, les offres n'ont pas de blurb éditorial localisé. Le champ `description` provient directement du document Meili tel quel.

### `GET /api/offers`

Listing paginé du catalogue, sans full-text ni géo (pour ça, voir `/api/offers/search`). Pratique pour le tooling BO, les balayages de fraîcheur, ou pour lister toutes les offres d'un établissement donné via `?establishment=`.

- **Auth** : aucune.
- **Action** : [ListOffersAction](../src/Http/Action/Api/Offer/ListOffersAction.php).
- **Query** :
  - `limit` (int, défaut `20`, plafond `100`).
  - `offset` (int ≥ 0, défaut `0`).
  - `sort` (string, optionnel) — whitelist : `created_at` / `-created_at` (= défaut, desc), `created_at_asc`, `importance` / `-importance`, `importance_asc`, `position` / `-position`, `price` / `price_asc` / `-price` / `price_desc` (alias sur `call_price.price`), `udated_at` / `-udated_at`, `udated_at_asc`. Valeur hors whitelist → `422 Invalid sort` (évite d'exposer un attribut absent de `sortableAttributes` ce qui 503erait Meili).
  - `establishment` (optionnel) — `[a-f0-9]{32}` strict ; filtre `establishment.id = "<hex>"` côté index. Casse non lowercase ⇒ `422 Invalid establishment id`.
- **Tri par défaut** : `created_at:desc`.

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "offers",
      "id":   "5c2f1c5b9a4d4f3e8b7a6c1d2e3f4a5b",
      "attributes": {
        "name":               "Menu découverte",
        "description":        "Entrée + plat + dessert + café",
        "url":                "https://exemple.tld/menu",
        "call_price":         { "price": 24.50, "currency": "EUR" },
        "position":           1,
        "importance":         7,
        "open_location_code": "8FW4V75V+8Q",
        "brand":              { "brand_id": "42", "brand_name": "Acme", "brand_code": "ACM" },
        "establishment":      { "id": "00003c7104994cc688663a51b89c9d40", "name": "Café des Arts", "slug": "cafe-des-arts" },
        "latitude":           48.8566,
        "longitude":          2.3522
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/offers?limit=20",
    "first": "https://api.example/api/offers?limit=20",
    "prev":  null,
    "next":  "https://api.example/api/offers?limit=20&offset=20",
    "last":  "https://api.example/api/offers?limit=20&offset=377480"
  },
  "meta": {
    "totalHits": 377485,
    "limit":     20,
    "offset":    0,
    "count":     20,
    "sort":      "created_at:desc"
  }
}
```

- **Réponses d'erreur** :
  - `422 Invalid sort` — valeur de `sort` hors whitelist.
  - `422 Invalid establishment id` — `?establishment` mal formé (pas `[a-f0-9]{32}`).
  - `503 Search backend unavailable`.

### `GET /api/offers/search`

Recherche d'offres par **texte** (`?q=…`), par **proximité GPS** (`?lat=…&lng=…&distance=…`), ou les deux combinés. Sémantique et erreurs strictement identiques à [`GET /api/establishments/search`](#get-apiestablishmentssearch) — seuls le `type` JSON:API et l'index Meili changent.

- **Auth** : aucune (endpoint public).
- **Action** : [SearchOffersAction](../src/Http/Action/Api/Offer/SearchOffersAction.php).
- **Query** : (`q`, `lat`, `lng`, `distance`, `limit`, `offset`).
  - `limit` plafonné à `50` (vs 100 sur `/api/offers`).
  - `distance` borné par `OFFER_NEARBY_MAX_DISTANCE_METERS` (défaut **50 km**).
  - `lat`/`lng`/`distance` mutuellement requis ⇒ partial input = `422`.
- **Pipeline** : pass-through des hits Meili (formatter partagé), avec `attributes.distanceMeters` rempli quand le tri géo est actif.
- **Réponse `200 OK`** : ressources `offers` avec navigation `links.{self,first,prev,next,last}` et `meta.{totalHits,limit,offset,query[,center,distance]}`. Même shape `attributes` que `/api/offers` (formatter partagé).
- **Réponses d'erreur** : identiques à `/api/establishments/search` (`422` pour params invalides ou incomplets, `503` si Meilisearch est indisponible).

> **Pré-requis index Meilisearch** (one-shot, sans ça le mode géo 503e) :
> - `_geo` dans `filterableAttributes` ET `sortableAttributes`
> - les champs texte recherchables dans `searchableAttributes`

> **Index versionné** : `MEILISEARCH_OFFERS_INDEX=offers_v2` en prod comme en dev (contrairement à `media_dev`/`users_dev`). C'est volontaire : les rolls de schéma d'offres se font en créant un `offers_v3` à côté, en le remplissant via le process externe, puis en flippant l'env — aucun déploiement de code requis pour bumper la version.

### `GET /api/offers/{id}`

Détail d'une offre par son id hex.

- **Auth** : aucune.
- **Action** : [GetOfferAction](../src/Http/Action/Api/Offer/GetOfferAction.php).
- **Path** :
  - `{id}` — regex **stricte** `[a-f0-9]{32}`. Pas de normalisation de casse : upper ⇒ `404`. Le pattern de routage Slim porte déjà la regex, donc tout id mal formé n'atteint même pas l'action.
- **Résolution** : filtre Meili exact `id = "<hex>"` sur l'index (l'attribut `id` est filterable) — pas de recherche full-text, le scorer pourrait surfacer un mauvais doc sur des préfixes d'id.

- **Réponse `200 OK`** : ressource unique `offers` avec la même shape `attributes` que les listings (formatter partagé).

- **Réponses d'erreur** :
  - `404 Offer not found` — id mal formé (n'atteint pas l'action, le router 404e via la regex) OU id valide mais absent de l'index.
  - `503 Search backend unavailable`.

---

### `POST /api/offers/{id}/clicks`

Génère un **subid par clic** (`clickRef`) **sans rediriger**. À utiliser quand tu rends **toi-même** le lien que l'affilié t'a fourni (pointant directement vers le réseau) au lieu de passer par notre redirect `/go/...` : tu récupères le `clickRef` ici puis tu le colles dans le lien affilié comme subid.

- **Auth** : aucune (optionnelle : un `Bearer` rattache l'utilisateur au clic ; anonyme OK).
- **Action** : [CreateOfferClickAction](../src/Http/Action/Api/Offer/CreateOfferClickAction.php).
- **Path** :
  - `{id}` — offre, regex stricte `[a-f0-9]{32}`.
- **Body (JSON)** : `{ "mediaId": "<32-hex>" }` — `mediaId` **obligatoire** (le contexte média est toujours requis).
- **Effet** : mint un `clickRef` opaque (UUIDv7 hex), persiste une ligne `tracking_click` (média obligatoire + utilisateur nullable + visiteur), et émet le cookie visiteur si activé. **Seul** le `clickRef` opaque est renvoyé — jamais `userId`/PII.

- **Réponse `201 Created`** — ressource JSON:API `clicks` ; **`data.id` EST le `clickRef`** :

```json
{
  "data": {
    "type": "clicks",
    "id":   "0193a1b2c3d4e5f60718293a4b5c6d7e",
    "attributes": {
      "clickRef":   "0193a1b2c3d4e5f60718293a4b5c6d7e",
      "subidParam": "subid",
      "url":        "https://merchant.example.com/deal/123?subid=0193a1b2c3d4e5f60718293a4b5c6d7e"
    }
  }
}
```

| Attribut | Sens |
|---|---|
| `clickRef` | le subid opaque à insérer dans le lien affilié (= `data.id`) |
| `subidParam` | nom de paramètre subid à utiliser (config `TRACKING_SUBID_PARAM`) ; utile si tu construis le lien côté client |
| `url` | URL marchande de l'offre **avec le subid déjà injecté** (placeholder `{subid}` substitué, sinon `?subid=` ajouté), ou `null` si l'offre n'a pas d'URL exploitable |

- **Réponses d'erreur** :
  - `404 Offer not found` — id mal formé OU offre absente de l'index.
  - `422 Invalid media id` / `Invalid body` — `mediaId` manquant/mal formé ou body non-JSON.
  - `503 Search backend unavailable`.

**Exemple**

```bash
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"mediaId":"a1b2c3d4e5f600112233445566778899"}' \
  "http://hydrogen.dev.com/api/offers/0f1e2d3c4b5a69788796a5b4c3d2e1f0/clicks"
```

> Le `clickRef` remonte ensuite dans le postback de conversion (`POST /admin/tracking/conversions`, champ `clickRef`) et est résoluble via `GET /admin/tracking/clicks/{ref}` (voir `docs/admin.md`).

---

### `GET /go/offer/{id}`

Lien de **suivi de clic + redirection** vers le marchand. Brique du modèle d'affiliation/commission : au lieu de pointer l'offre directement vers l'URL du marchand, le front-end pointe vers cet endpoint. On compte un clic facturable (bufferisé, anti-bot) puis on renvoie un `302` vers l'URL de destination de l'offre.

- **Auth** : aucune (auth optionnelle : si un `Bearer` est présent, le viewer est attaché à la requête — l'attribution reste au niveau de la cible en v1).
- **Action** : [RedirectOfferAction](../src/Http/Action/Go/RedirectOfferAction.php).
- **Hors groupe `/api`** : pas de middleware JSON ; succès = `302` brut, erreurs = JSON:API.
- **Path** :
  - `{id}` — regex **stricte** `[a-f0-9]{32}` (même shape canonique que `GET /api/offers/{id}`).
- **Résolution** : filtre Meili exact `id = "<hex>"`, on ne récupère que `id` + `url`.
- **Comptage** : le clic part dans un tampon (`tracking_event`) vidé en fin de requête (`register_shutdown_function`), drainé par le worker `bin/tracking-flush.php` vers `tracking_stats`/`tracking_daily`. Les bots (crawlers, dépliage de liens, User-Agent vide) sont ignorés quand `TRACKING_IGNORE_BOTS=true`. Aucun dédoublonnage : chaque clic réel compte (le réseau d'affiliation fait sa propre attribution).
- **Conversions** : remontent côté Admin via `POST /admin/tracking/conversions` (voir `docs/admin.md`).

- **Réponse `302 Found`** : header `Location` = URL marchand de l'offre. Aucun corps.

- **Réponses d'erreur** :
  - `404 Offer not found` — id mal formé, offre absente, ou offre sans champ `url` exploitable.
  - `503 Search backend unavailable`.

**Exemple**

```bash
curl -i "http://hydrogen.dev.com/go/offer/0f1e2d3c4b5a69788796a5b4c3d2e1f0"
# HTTP/1.1 302 Found
# Location: https://merchant.example.com/deal/123?aff=hexatrip
```

---

### `GET /go/offer/{id}/media/{mediaId}`

Variante du lien de suivi ci-dessus avec **attribution par clic** (le *subid* d'affiliation). À utiliser quand le lien partenaire est rendu **au contexte d'un média** : on veut savoir non seulement *que* l'offre a été cliquée, mais *qui* a cliqué et *depuis quel média*.

- **Auth** : aucune (optionnelle ; un `Bearer` présent rattache l'utilisateur au clic). Les clics anonymes sont gérés nativement (`userId` nul).
- **Action** : [RedirectOfferMediaAction](../src/Http/Action/Go/RedirectOfferMediaAction.php).
- **Hors groupe `/api`** : succès = `302` brut, erreurs = JSON:API.
- **Path** :
  - `{id}` — offre, regex stricte `[a-f0-9]{32}`.
  - `{mediaId}` — média, regex stricte `[a-f0-9]{32}`. **Toujours obligatoire** (segment de chemin).
- **Subid** : on mint un `clickRef` opaque (UUIDv7 en 32 hex), on persiste **une** ligne `tracking_click` reliant `clickRef → média (obligatoire) + utilisateur (nullable) + visiteur`, puis on **ajoute le `clickRef` à l'URL marchande** comme paramètre `subid` (nom configurable via `TRACKING_SUBID_PARAM`). Le réseau renvoie ce subid dans son postback de conversion (`POST /admin/tracking/conversions`, champ `clickRef`) pour une attribution précise clic → média → utilisateur.
- **Confidentialité** : **seul** le `clickRef` opaque sort du système — jamais `userId` ni PII. L'identité reste résoluble côté Admin via `GET /admin/tracking/clicks/{ref}`.
- **Visiteur anonyme** : si `TRACKING_VISITOR_COOKIE=true`, un cookie longue durée (`hyv`, UUIDv7) corrèle les clics d'un même invité non connecté. **Désactivé par défaut** (consentement RGPD requis). `HttpOnly`, `SameSite=Lax` (survit au 302).
- **Comptage cible** : le clic facturable par cible est compté en plus, exactement comme `/go/offer/{id}` (tampon bufferisé, anti-bot).

- **Réponse `302 Found`** : header `Location` = URL marchand **+ subid**. Émet aussi un `Set-Cookie` visiteur si un nouvel id est minté.

- **Réponses d'erreur** :
  - `404 Offer not found` / `404 Media not found` — id mal formé, offre absente/sans `url`.
  - `503 Search backend unavailable`.
- **Robustesse** : un échec de persistance du clic ne casse pas la redirection (fallback sur l'URL sans subid).

**Exemple**

```bash
curl -i "http://hydrogen.dev.com/go/offer/0f1e2d3c4b5a69788796a5b4c3d2e1f0/media/a1b2c3d4e5f600112233445566778899"
# HTTP/1.1 302 Found
# Location: https://merchant.example.com/deal/123?subid=0193a1b2c3d4e5f60718293a4b5c6d7e
```

---

## Villes

Triplet `/api/cities` (list paginé) / `/api/cities/search` (full-text + géo) / `/api/cities/{id}` (détail) adossé à l'index Meilisearch `MEILISEARCH_CITIES_INDEX` (défaut **`cities`**). Catalogue ~10 000 documents (montée en charge progressive via le pipeline d'alimentation externe).

Comme pour les établissements et les offres, Hydrogen **n'a pas de domaine `City` côté MySQL** : l'index Meilisearch est la **source de vérité**, alimenté par un processus externe. Pas de re-hydratation SQL.

**Identité côté API** : contrairement à `/api/establishments/{id}` (32-hex) et `/api/offers/{id}` (32-hex), le `id` JSON:API d'une ressource `cities` est un **UUID dashé lowercase** (ex: `67104949-52b7-11f1-96d5-00155dda08de`). La regex de path `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` est **stricte** sur la casse — uppercase ⇒ `404` (URL space canonique).

**Shape attributes commune (formatter partagé)** : les 3 endpoints utilisent `CityHitFormatter`, qui :
1. recopie tous les champs du document dans `attributes`, sauf les clés Meili-internes (`_geo`, `_geoDistance`, `_formatted`, `_matchesPosition`, `_rankingScore`, `_rankingScoreDetails`) et le `id` brut ;
2. aplatit `_geo: { lat, lng }` en `attributes.latitude` / `attributes.longitude` ;
3. expose `_geoDistance` (mètres) en `attributes.distanceMeters` quand le tri géo est actif (search en mode géo uniquement) ;
4. normalise `country_id` / `region_id` / `subregion_id` en **lowercase** (cohérence avec `/api/countries/{code}`, `/api/regions/{code}`, `/api/subregions/{code}`).

Champs métier exposés tels quels : `slug`, `codes{osm, wikidata, wikipedia}`, `names`, `official_names`, `alt_names`, `stats{stats, surface}`. Le pipeline source stocke certains de ces champs à `null` selon la couverture data (`names`, `alt_names`, `official_names` notamment) — Hydrogen les passe à l'identique.

**Embed hiérarchique** : chaque ressource `cities` est enrichie de 3 blocs inline :
- `country` — résolu via `CountrySummaryResolver` contre l'index `countries`.
- `region` — résolu via `RegionSummaryResolver` contre l'index `regions`.
- `subregion` — résolu via `SubregionSummaryResolver` contre l'index `subregions`.

Les 3 résolveurs font du **batch loading** (1 round-trip Meili par niveau quel que soit le nombre de villes sur la page — pas de N+1). Si un code ISO ne résout pas dans son index parent, le bloc vaut `null` (l'index `subregions` ne couvre actuellement que ~99 codes ⇒ la majorité des villes auront `subregion: null` ; c'est une question de couverture data, pas un bug).

### `GET /api/cities`

Listing paginé du catalogue, avec filtres hiérarchiques cumulables (AND). Pas de full-text ni de géo ici — voir `/api/cities/search`.

- **Auth** : aucune.
- **Action** : [ListCitiesAction](../src/Http/Action/Api/City/ListCitiesAction.php).
- **Query** :
  - `limit` (int, défaut `20`, plafond `100`).
  - `offset` (int ≥ 0, défaut `0`).
  - `country` (ISO 3166-1 alpha-2, casse insensible, normalisée upper côté serveur) — filtre `country_id`.
  - `region` (ISO 3166-2, 2..7 chars alphanum + tiret, casse insensible) — filtre `region_id`.
  - `subregion` (ISO 3166-2, même contrainte) — filtre `subregion_id`.
- **Tri** : aucun `?sort=` n'est exposé. L'index `cities` n'a que `_geo` en `sortableAttributes` (réservé au search). Ordre = ordre naturel Meili (= ordre d'insertion).

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "cities",
      "id":   "67104949-52b7-11f1-96d5-00155dda08de",
      "attributes": {
        "slug":         "paris",
        "codes":        { "osm": null, "wikidata": "Q90", "wikipedia": "fr:Paris" },
        "names":        null,
        "official_names": null,
        "alt_names":    null,
        "stats":        { "stats": "2133111", "surface": "105.3" },
        "country_id":   "fr",
        "region_id":    "fr-cvl",
        "subregion_id": "fr-75c",
        "latitude":     48.8588897,
        "longitude":    2.32004102172,
        "country":      { "id": "fr",     "name": "France",        "slug": "france",       "continent": "Europe", "continentId": "eu" },
        "region":       { "id": "fr-cvl", "name": "Centre-Val de Loire", "slug": "centre-val-de-loire", "countryId": "fr" },
        "subregion":    { "id": "fr-75c", "name": "Paris", "slug": "paris", "regionId": "fr-idf", "countryId": "fr" }
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/cities?country=FR&limit=20",
    "first": "https://api.example/api/cities?country=FR&limit=20",
    "prev":  null,
    "next":  "https://api.example/api/cities?country=FR&limit=20&offset=20",
    "last":  "https://api.example/api/cities?country=FR&limit=20&offset=440"
  },
  "meta": {
    "totalHits": 458,
    "limit":     20,
    "offset":    0,
    "count":     20,
    "country":   "FR"
  }
}
```

- **Réponses d'erreur** :
  - `422 Invalid country code` / `Invalid region code` / `Invalid subregion code`.
  - `503 Search backend unavailable`.

### `GET /api/cities/search`

Recherche de villes par **nom/texte** (`?q=`), par **proximité GPS** (`?lat=&lng=&distance=`) ou les deux. Sémantique d'erreur identique à `/api/establishments/search` et `/api/offers/search` (geo params mutuellement requis, etc.). Les 3 filtres hiérarchiques (`country`, `region`, `subregion`) restent disponibles ici, **combinables** avec full-text et géo.

- **Auth** : aucune.
- **Action** : [SearchCitiesAction](../src/Http/Action/Api/City/SearchCitiesAction.php).
- **Query** :
  - `q` (optionnel) — recherche full-text. `searchableAttributes` est sur `*` ⇒ matche notamment `slug`, `names`, `codes.wikipedia`, etc.
  - `lat`, `lng`, `distance` — **tous les trois ou aucun**. Avec eux : filtre `_geoRadius(lat, lng, distance)` + tri `_geoPoint(lat, lng):asc`. `distance` borné par `CITY_NEARBY_MAX_DISTANCE_METERS` (défaut **50 km**).
  - `country`, `region`, `subregion` — mêmes contraintes qu'au listing, **cumulables** avec full-text et géo (AND).
  - `limit` (1..50, défaut 20).
  - `offset` (≥ 0, défaut 0).

- **Pipeline** : même formatter partagé + mêmes 3 resolvers d'embed. `attributes.distanceMeters` rempli quand le tri géo est actif.

- **Réponse `200 OK`** : structure identique au listing, plus `meta.center` / `meta.distance` quand géo, et `attributes.distanceMeters` par hit.

- **Réponses d'erreur** :
  - `422 Incomplete geo parameters` — sous-ensemble de `lat`/`lng`/`distance`.
  - `422 Invalid latitude` / `Invalid longitude` / `Invalid distance` / `Distance too large`.
  - `422 Invalid country code` / `Invalid region code` / `Invalid subregion code`.
  - `503 Search backend unavailable`.

> **Pré-requis index Meilisearch** :
> - `_geo` dans `filterableAttributes` ET `sortableAttributes`
> - `country_id`, `region_id`, `subregion_id` dans `filterableAttributes`
> - champs texte recherchables dans `searchableAttributes` (l'index actuel a `*` ⇒ ok)

### `GET /api/cities/{id}`

Détail d'une ville par son UUID dashé.

- **Auth** : aucune.
- **Action** : [GetCityAction](../src/Http/Action/Api/City/GetCityAction.php).
- **Path** : `{id}` — regex **stricte** `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`. Le pattern de route Slim porte déjà la regex : tout id mal formé n'atteint pas l'action et renvoie un 404 router.
- **Résolution** : filtre Meili exact `id = "<uuid>"` (l'attribut `id` est filterable) — pas de full-text, le scorer pourrait surfacer un mauvais doc sur des préfixes d'UUID.

- **Réponse `200 OK`** : ressource unique `cities` avec la même shape `attributes` que les listings (formatter + 3 blocs `country`/`region`/`subregion` partagés).

- **Réponses d'erreur** :
  - `404 City not found` — id mal formé OU absent de l'index.
  - `503 Search backend unavailable`.

---

## Marques

Catalogue **borné** (< 1000 documents) hébergé dans l'index Meilisearch `MEILISEARCH_BRANDS_INDEX` (défaut `brands`). Pas de domaine `Brand` côté MySQL — lecture seule depuis Meili, alimenté par un processus externe.

**Forme attendue du document** : un brand peut appartenir à plusieurs types, chacun avec sa propre position. Le champ `code` (slug minuscule, ex : `fram`, `cdiscount`) sert de clé pour résoudre les images du brand sur disque.

```jsonc
{
  "id":   "42",
  "name": "Acme",
  "code": "acme",
  "logoUrl": "…",
  "types": [
    { "type": "carrier", "position": 1 },
    { "type": "rental",  "position": 5 }
  ]
}
```

**Enrichissement images** : chaque ressource `brands` reçoit un attribut `images` (objet `{ icon, logo }`) résolu côté serveur à partir du champ `code` du document. Les fichiers sont servis comme **assets first-party de l'app** depuis `public/assets/images/brands/<code>/` :

- `icon.webp` → `images.icon`
- `logo.webp` → `images.logo`

Chaque URL est construite sur `APP_URL` et n'est exposée **que si le fichier existe réellement sur disque** ; un fichier absent donne `null` (le client retombe sur son propre placeholder). La résolution est faite **une seule fois par brand**, y compris en mode `?groupBy=type` (toutes les ressources explosées d'un même brand partagent le même bloc `images`, pour éviter N accès disque par brand). Un brand sans `code` exploitable donne `{ "icon": null, "logo": null }`.

> **Garde anti-path-traversal** : le `code` est re-validé contre `^[a-z0-9_-]+$` (après `strtolower`) avant tout accès disque — un code malformé (`../../etc`, chemin absolu, dotfile) ne peut jamais sortir du dossier `brands/` et donne `null`/`null`.

### `GET /api/brands`

Retourne **tous** les brands en un seul appel — pas de pagination, pas de `limit`/`offset` exposés au client. Deux modes :

- **Sans `?groupBy=type`** (défaut) : une ressource **par brand**, `types[]` intégral dans les attributs. Ordre : celui de Meili (relevance si `q` fourni, sinon ordre naturel de l'index).
- **Avec `?groupBy=type`** : chaque brand est **explosé** en N ressources, une par entrée de son `types[]`. Tri global `(type ASC, position ASC)` — appliqué côté serveur, ce qui garantit que les éléments d'un même type sont **contigus** dans `data[]`. `meta.groups[]` décrit la longueur de chaque segment.

> **Id composite en mode groupé** : un brand qui apparaît dans plusieurs types est dupliqué dans `data[]`. Pour respecter la contrainte JSON:API "`(type, id)` unique par document", l'id de chaque ressource explosée devient `<brandId>:<typeName>` (ex : `42:carrier`). L'id canonique reste accessible via `attributes.brandId`.

- **Auth** : aucune.
- **Action** : [ListBrandsAction](../src/Http/Action/Api/Brand/ListBrandsAction.php).
- **Query (tous optionnels)** :
  - `q` : recherche full-text (matche les `searchableAttributes` de l'index, typiquement `name`).
  - `groupBy` : seule valeur supportée = `type`. Active l'explosion + le tri `(type, position)` + `meta.groups[]`. Toute autre valeur ⇒ `422`.
- **Pipeline** : un seul appel Meili avec `limit = BRANDS_MAX_RESULTS` (défaut **1000**), **sans `sort`** (le tri par type/position se fait PHP-side après explosion ; `types.position` côté Meili ne suffirait pas pour un ordre global sur un tableau imbriqué).

- **Réponse `200 OK`** (mode `?groupBy=type`) :

```json
{
  "data": [
    { "type": "brands", "id": "42:carrier", "attributes": { "name": "Acme",     "type": "carrier", "position": 1, "brandId": "42", "logoUrl": "…", "images": { "icon": "https://app.example/assets/images/brands/acme/icon.webp", "logo": "https://app.example/assets/images/brands/acme/logo.webp" } } },
    { "type": "brands", "id": "17:carrier", "attributes": { "name": "Globex",   "type": "carrier", "position": 2, "brandId": "17", "logoUrl": "…", "images": { "icon": null, "logo": null } } },
    { "type": "brands", "id": "42:rental",  "attributes": { "name": "Acme",     "type": "rental",  "position": 5, "brandId": "42", "logoUrl": "…", "images": { "icon": "https://app.example/assets/images/brands/acme/icon.webp", "logo": "https://app.example/assets/images/brands/acme/logo.webp" } } },
    { "type": "brands", "id": "08:rental",  "attributes": { "name": "Initech",  "type": "rental",  "position": 1, "brandId": "08", "logoUrl": "…", "images": { "icon": null, "logo": null } } }
  ],
  "links": {
    "self": "https://api.example/api/brands?groupBy=type"
  },
  "meta": {
    "totalHits": 3,
    "query": "",
    "groups": [
      { "type": "carrier", "count": 2 },
      { "type": "rental",  "count": 2 }
    ]
  }
}
```

  > Le brand `Acme` (id `42`) apparaît **deux fois** : une fois dans le groupe `carrier` (position 1) et une fois dans `rental` (position 5). C'est attendu. `meta.totalHits` reste le nombre de brands distincts côté Meili (ici 3), pas la cardinalité de `data[]`.

- **Réponse `200 OK`** (sans `groupBy`) :

```json
{
  "data": [
    {
      "type": "brands",
      "id":   "42",
      "attributes": {
        "name": "Acme",
        "code": "acme",
        "logoUrl": "…",
        "types": [
          { "type": "carrier", "position": 1 },
          { "type": "rental",  "position": 5 }
        ],
        "images": {
          "icon": "https://app.example/assets/images/brands/acme/icon.webp",
          "logo": "https://app.example/assets/images/brands/acme/logo.webp"
        }
      }
    }
  ],
  "links": { "self": "https://api.example/api/brands" },
  "meta":  { "totalHits": 1, "query": "" }
}
```

- **Garde-fou catalogue** : si `meta.totalHits > BRANDS_MAX_RESULTS`, la réponse ajoute `meta.truncated = true` + `meta.maxResults = <cap>` — signal explicite que le cap serveur a été atteint et qu'il faut bumper `BRANDS_MAX_RESULTS` côté ops.

- **Pagination** : aucune (`links` ne contient que `self`). C'est volontaire — le catalogue est petit, le client peut tout charger en mémoire et l'utiliser comme référentiel.

- **Brands sans `types[]` en mode `groupBy`** : ignorés (ils ne peuvent pas être assignés à un groupe). Pour les voir, appeler `/api/brands` sans `groupBy`.

- **Réponses d'erreur** :
  - `422 Invalid groupBy` — `groupBy` fourni avec une valeur autre que `type`.
  - `503 Search backend unavailable` — Meilisearch injoignable. Le détail Meili est propagé dans `errors[0].detail`.

> **Pré-requis index Meilisearch** (one-shot ops) :
> - les champs texte recherchables (`name`, etc.) dans `searchableAttributes`
> - **aucun pré-requis sur `sortableAttributes`** — tout le tri est PHP-side après explosion par type

---

## Pays

Catalogue ISO 3166 **borné** (~250 documents) hébergé dans l'index Meilisearch `MEILISEARCH_COUNTRIES_INDEX` (défaut `countries`). Lecture seule depuis Meili — aucun domaine `Country` côté MySQL, l'index est alimenté par un processus externe.

**Identité côté API** : le `id` JSON:API d'une ressource `countries` est le **code ISO 3166-2 en minuscules** (`fr`, `us`, `ad`, …). Les documents Meili stockent le code en majuscules dans le champ `id` (`FR`, `US`) — Hydrogen normalise à la sérialisation. Les URLs sont **case-insensitive** end-to-end (`/api/countries/fr` ≡ `/api/countries/FR`).

**Descriptions Markdown** : la ressource détail (`GET /api/countries/{code}`) embarque un attribut `description` dont la valeur est le **contenu brut Markdown** du fichier `resources/lang/<locale>/countries/<code>/description.md`. Le layout est **un dossier par pays** (`<code>/`) contenant `description.md` — cela laisse la place pour ajouter d'autres ressources éditoriales par pays plus tard (`faq.md`, `highlights.md`, dossier `images/`, …) sans casser les URLs de description. La locale est résolue par `LocaleResolverMiddleware` (header `Accept-Language` ou param). Si le fichier n'existe pas pour la locale courante, fallback sur la locale `SupportedLocales::DEFAULT` ; si aucune des deux n'existe, `description = null` (le pays sert quand même, juste sans blurb). Le Markdown n'est **pas rendu côté serveur** — chaque surface (web, mobile, BO preview) applique son propre sanitizer.

> **Garde anti-path-traversal** : le code passe par une regex `^[a-z]{2,3}$` avant toute lecture disque. Toute tentative d'injection (`../`, `\0`, etc.) court-circuite la résolution → `description = null`.

### `GET /api/countries`

Retourne **tout le catalogue** en un seul appel — pas de pagination, pas de `limit`/`offset` exposés au client.

- **Auth** : aucune.
- **Action** : [ListCountriesAction](../src/Http/Action/Api/Country/ListCountriesAction.php).
- **Pipeline** : un seul appel Meili avec `limit = COUNTRIES_MAX_RESULTS` (défaut **1000**), `q = ""` (ordre naturel de l'index).
- **Pas de `description` dans le listing** : charger 250 fichiers Markdown sur un endpoint de catalogue est gaspilleur. Le blurb n'est disponible que sur le détail.

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "countries",
      "id":   "fr",
      "attributes": {
        "name": "France",
        "slug": "france",
        "codes": { "iso_3166_2": "FR", "iso_3166_3": "FRA", "wikipedia": null },
        "region": "Western Europe",
        "continent": "Europe",
        "continent_id": "EU",
        "status": "officially-assigned",
        "stats": { "surface": "549393.44", "stats": null },
        "timezones": { "utc": ["+01:00", "+02:00"] },
        "official_name": { "fr-FR": "République française", "en-US": "French Republic" },
        "latitude": 46,
        "longitude": 2
      }
    }
  ],
  "links": { "self": "https://api.example/api/countries" },
  "meta":  { "totalHits": 249, "count": 249 }
}
```

- **Garde-fou catalogue** : si `meta.totalHits > COUNTRIES_MAX_RESULTS`, la réponse ajoute `meta.truncated = true` + `meta.maxResults = <cap>`.
- **Réponses d'erreur** :
  - `503 Search backend unavailable` — Meilisearch injoignable.

### `GET /api/countries/search`

Même schéma que `/api/establishments/search` : trois modes.

1. **Full-text** : `?q=<term>`.
2. **Geo-rayon** : `?lat=&lng=&distance=` (mètres) — les trois ensemble, sinon `422`.
3. **Combiné** : `q` + `lat`+`lng`+`distance`.

- **Auth** : aucune.
- **Action** : [SearchCountriesAction](../src/Http/Action/Api/Country/SearchCountriesAction.php).
- **Query** :
  - `q` (string, optionnel) — recherche full-text.
  - `lat` (float [-90, 90]), `lng` (float [-180, 180]), `distance` (int positif, mètres) — tous trois requis pour le mode géo, sinon `422 Incomplete geo parameters`.
  - `limit` (int, défaut `20`, plafond `50`).
  - `offset` (int ≥ 0, défaut `0`).
- **Plafond distance** : `COUNTRY_NEARBY_MAX_DISTANCE_METERS` (défaut **5 000 000 m = 5 000 km**). Les coordonnées pays sont des centroïdes à l'échelle continentale, un cap serré n'aurait pas de sens. Dépassement → `422 Distance too large`.
- **Tri géo** : en mode géo, tri par `_geoPoint(lat, lng):asc` (distance croissante depuis le centre). En mode full-text pur, tri par pertinence Meili.

- **Réponse `200 OK`** :

```json
{
  "data": [
    { "type": "countries", "id": "fr", "attributes": { "name": "France", "...": "..." } }
  ],
  "links": {
    "self":  "https://api.example/api/countries/search?q=france",
    "first": "https://api.example/api/countries/search?q=france&limit=20",
    "prev":  null,
    "next":  null,
    "last":  null
  },
  "meta": {
    "totalHits": 1,
    "limit": 20,
    "offset": 0,
    "query": "france",
    "center": { "lat": 48.85, "lng": 2.35 },
    "distance": 1000000
  }
}
```

  > Les attributs `center` et `distance` ne sont présents qu'en mode géo.

- **Réponses d'erreur** :
  - `422 Incomplete geo parameters` — un des `lat`/`lng`/`distance` fourni mais pas les autres.
  - `422 Invalid latitude` / `Invalid longitude` / `Invalid distance` — valeurs hors bornes ou non numériques.
  - `422 Distance too large` — `distance > COUNTRY_NEARBY_MAX_DISTANCE_METERS`.
  - `503 Search backend unavailable`.

### `GET /api/countries/{code}`

Détail d'un pays par son code ISO 3166-2 (2 ou 3 caractères, case-insensitive). Filtre Meili **exact** sur `id = "<UPPER>"` — pas de recherche full-text (le scorer pourrait surfacer le mauvais continent sur des codes de bord).

- **Auth** : aucune.
- **Action** : [GetCountryAction](../src/Http/Action/Api/Country/GetCountryAction.php).
- **Path** :
  - `{code}` — regex `[a-zA-Z]{2,3}`. Casse normalisée à la résolution (majuscules pour Meili, minuscules pour le `id` JSON:API). Format invalide → `404`.
- **Différence clé avec le listing** : ajoute un attribut `description` chargé depuis `resources/lang/<locale>/countries/<code>.md` (voir intro de section).

- **Réponse `200 OK`** (locale `fr-FR`) :

```json
{
  "data": {
    "type": "countries",
    "id":   "fr",
    "attributes": {
      "name": "France",
      "slug": "france",
      "codes": { "iso_3166_2": "FR", "iso_3166_3": "FRA", "wikipedia": null },
      "region": "Western Europe",
      "continent": "Europe",
      "continent_id": "EU",
      "status": "officially-assigned",
      "stats": { "surface": "549393.44", "stats": null },
      "timezones": { "utc": ["+01:00", "+02:00"] },
      "official_name": { "fr-FR": "République française", "en-US": "French Republic" },
      "latitude": 46,
      "longitude": 2,
      "description": "# France\n\nLa **France**, officiellement la *République française*, …"
    }
  }
}
```

- **Réponses d'erreur** :
  - `404 Country not found` — code invalide (regex non matchée) OU code valide mais aucun document Meili correspondant.
  - `503 Search backend unavailable`.

> **Pré-requis index Meilisearch** (one-shot ops) :
> - `id` dans `filterableAttributes` (pour le lookup exact du détail)
> - `_geo` dans `filterableAttributes` ET `sortableAttributes` (pour `?lat=&lng=&distance=`)
> - les champs texte recherchables (`name`, `slug`, …) dans `searchableAttributes` (`*` couvre tout sur le déploiement courant)

---

## Régions

Catalogue ISO 3166-2 **non borné** (~5 300 documents) hébergé dans l'index Meilisearch `MEILISEARCH_REGIONS_INDEX` (défaut `regions`). Lecture seule depuis Meili — aucun domaine `Region` côté MySQL, l'index est alimenté par un processus externe. Contrairement aux pays, le volume impose une **pagination obligatoire** (les 5 300 entrées ne tiennent pas dans un seul appel raisonnable).

**Identité côté API** : le `id` JSON:API d'une ressource `regions` est le **code ISO 3166-2 en minuscules** (`fr-idf`, `ad-02`, `gb-eng`, …). Les documents Meili stockent le code en majuscules dans le champ `id` (`FR-IDF`, `AD-02`) — Hydrogen normalise à la sérialisation. Les URLs sont **case-insensitive** end-to-end (`/api/regions/fr-idf` ≡ `/api/regions/FR-IDF`).

**Descriptions Markdown** : la ressource détail (`GET /api/regions/{code}`) embarque un attribut `description` dont la valeur est le **contenu brut Markdown** du fichier `resources/lang/<locale>/regions/<code>/description.md`. Le layout est **un dossier par région** (`<code>/`) contenant `description.md` — mêmes propriétés et même fallback que pour les pays. La locale est résolue par `LocaleResolverMiddleware` (header `Accept-Language` ou param). Si le fichier n'existe pas pour la locale courante, fallback sur la locale `SupportedLocales::DEFAULT` ; si aucune des deux n'existe, `description = null` (la région sert quand même, juste sans blurb). Le Markdown n'est **pas rendu côté serveur**.

**Bloc `country` inline** : chaque ressource `regions` (listing, search, détail) embarque un attribut `country` qui est un **sous-ensemble léger** du document pays parent — assez d'info pour afficher "Île-de-France · France · Europe" sans nécessiter un appel à `/api/countries/<code>`. Champs exposés : `id` (ISO alpha-2 minuscule, ex: `fr`), `name`, `slug`, `continent`, `continentId`. Pour la description longue, la liste des fuseaux ou les `official_name` multi-locales, appeler `/api/countries/<code>`. Le bloc résout en **un seul** appel Meili sur l'index `countries` par requête (batch-loader request-scoped), peu importe le nombre de hits. Si le `country_id` d'une région est manquant ou ne résout pas (code orphelin), `country = null`.

> **Garde anti-path-traversal** : le code passe par une regex `^[a-z0-9-]{2,7}$` avant toute lecture disque. Toute tentative d'injection court-circuite la résolution → `description = null`.

### `GET /api/regions`

Listing paginé du catalogue, avec filtre optionnel par pays.

- **Auth** : aucune.
- **Action** : [ListRegionsAction](../src/Http/Action/Api/Region/ListRegionsAction.php).
- **Query** :
  - `limit` (int, défaut `20`, plafond `100`).
  - `offset` (int ≥ 0, défaut `0`).
  - `country` (string, optionnel) — code ISO 3166-1 alpha-2 (2 lettres, case-insensitive). Filtre Meili exact sur `country_id`. Format invalide → `422 Invalid country code`.
- **Pas de `description` dans le listing** : charger des milliers de fichiers Markdown sur un endpoint de catalogue est gaspilleur. Le blurb n'est disponible que sur le détail.

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "regions",
      "id":   "fr-idf",
      "attributes": {
        "name": "Île-de-France",
        "slug": "ile-de-france",
        "type": "Région",
        "codes": { "osm": "8649", "wikidata": "Q13917", "wikipedia": "fr:Île-de-France" },
        "names": { "fr-FR": "Île-de-France", "en-US": "Île-de-France" },
        "native_name": "Île-de-France",
        "alt_names": null,
        "official_names": null,
        "country_id": "FR",
        "stats": { "stats": null, "surface": "12011.4" },
        "latitude": 48.85,
        "longitude": 2.35,
        "country": {
          "id":          "fr",
          "name":        "France",
          "slug":        "france",
          "continent":   "Europe",
          "continentId": "EU"
        }
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/regions?country=fr&limit=20",
    "first": "https://api.example/api/regions?country=fr&limit=20",
    "prev":  null,
    "next":  "https://api.example/api/regions?country=fr&limit=20&offset=20",
    "last":  "https://api.example/api/regions?country=fr&limit=20&offset=0"
  },
  "meta": {
    "totalHits": 18,
    "limit":     20,
    "offset":    0,
    "count":     18,
    "country":   "FR"
  }
}
```

- **Réponses d'erreur** :
  - `422 Invalid country code` — `country` ne matche pas la regex `[a-zA-Z]{2}`.
  - `503 Search backend unavailable`.

### `GET /api/regions/search`

Trois modes combinables :

1. **Full-text** : `?q=<term>`.
2. **Geo-rayon** : `?lat=&lng=&distance=` (mètres) — les trois ensemble, sinon `422`.
3. **Filtre pays** : `?country=FR` — combinable avec n'importe quel autre mode.

- **Auth** : aucune.
- **Action** : [SearchRegionsAction](../src/Http/Action/Api/Region/SearchRegionsAction.php).
- **Query** :
  - `q` (string, optionnel) — recherche full-text.
  - `lat` (float [-90, 90]), `lng` (float [-180, 180]), `distance` (int positif, mètres) — tous trois requis ensemble pour le mode géo, sinon `422 Incomplete geo parameters`.
  - `country` (string, optionnel) — ISO 3166-1 alpha-2.
  - `limit` (int, défaut `20`, plafond `50`).
  - `offset` (int ≥ 0, défaut `0`).
- **Plafond distance** : `REGION_NEARBY_MAX_DISTANCE_METERS` (défaut **2 000 000 m = 2 000 km**). Dépassement → `422 Distance too large`.
- **Tri géo** : en mode géo, tri par `_geoPoint(lat, lng):asc` (distance croissante). En mode full-text pur, tri par pertinence Meili. **Pré-requis ops** : `_geo` dans `sortableAttributes` de l'index `regions` (NON activé par défaut sur le déploiement, voir bloc Pré-requis en fin de section).

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "regions",
      "id":   "fr-idf",
      "attributes": {
        "name":       "Île-de-France",
        "country_id": "FR",
        "country":    { "id": "fr", "name": "France", "slug": "france", "continent": "Europe", "continentId": "EU" },
        "...":        "..."
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/regions/search?q=ile",
    "first": "https://api.example/api/regions/search?q=ile&limit=20",
    "prev":  null,
    "next":  null,
    "last":  null
  },
  "meta": {
    "totalHits": 4,
    "limit":     20,
    "offset":    0,
    "query":     "ile",
    "country":   "FR",
    "center":    { "lat": 48.85, "lng": 2.35 },
    "distance":  500000
  }
}
```

  > `country`, `center` et `distance` ne sont présents que quand le mode correspondant est actif.

- **Réponses d'erreur** :
  - `422 Invalid country code` — `country` mal formé.
  - `422 Incomplete geo parameters` — un des `lat`/`lng`/`distance` fourni mais pas les autres.
  - `422 Invalid latitude` / `Invalid longitude` / `Invalid distance` — valeurs hors bornes ou non numériques.
  - `422 Distance too large` — `distance > REGION_NEARBY_MAX_DISTANCE_METERS`.
  - `503 Search backend unavailable`.

### `GET /api/regions/{code}`

Détail d'une région par son code ISO 3166-2 (2 à 7 caractères, case-insensitive). Filtre Meili **exact** sur `id = "<UPPER>"` — pas de recherche full-text (le scorer pourrait surfacer la mauvaise subdivision sur des codes de bord).

- **Auth** : aucune.
- **Action** : [GetRegionAction](../src/Http/Action/Api/Region/GetRegionAction.php).
- **Path** :
  - `{code}` — regex `[a-zA-Z0-9-]{2,7}` (accepte `XX`, `XX-YYY`, `XX-99`, …). Casse normalisée à la résolution (majuscules pour Meili, minuscules pour le `id` JSON:API). Format invalide → `404`.
- **Différence clé avec le listing** : ajoute un attribut `description` chargé depuis `resources/lang/<locale>/regions/<code>/description.md` (voir intro de section).

- **Réponse `200 OK`** (locale `fr-FR`) :

```json
{
  "data": {
    "type": "regions",
    "id":   "fr-idf",
    "attributes": {
      "name":           "Île-de-France",
      "slug":           "ile-de-france",
      "type":           "Région",
      "codes":          { "osm": "8649", "wikidata": "Q13917", "wikipedia": "fr:Île-de-France" },
      "names":          { "fr-FR": "Île-de-France", "en-US": "Île-de-France" },
      "native_name":    "Île-de-France",
      "alt_names":      null,
      "official_names": null,
      "country_id":     "FR",
      "stats":          { "stats": null, "surface": "12011.4" },
      "latitude":       48.85,
      "longitude":      2.35,
      "country":        { "id": "fr", "name": "France", "slug": "france", "continent": "Europe", "continentId": "EU" },
      "description":    "# Île-de-France\n\nL'**Île-de-France** est la région la plus peuplée…"
    }
  }
}
```

- **Réponses d'erreur** :
  - `404 Region not found` — code invalide (regex non matchée) OU code valide mais aucun document Meili correspondant.
  - `503 Search backend unavailable`.

> **Pré-requis index Meilisearch** (one-shot ops) :
> - `id` dans `filterableAttributes` (pour le lookup exact du détail) — **déjà actif**.
> - `country_id` dans `filterableAttributes` (pour le filtre `?country=`) — **déjà actif**.
> - `_geo` dans `filterableAttributes` (pour `?lat=&lng=&distance=`) — **déjà actif**.
> - `_geo` dans `sortableAttributes` (pour trier par distance dans le mode géo) — **NON activé par défaut** ; sans lui, le filtre `_geoRadius(...)` fonctionne mais le tri par distance retombe sur l'ordre de pertinence Meili.
> - les champs texte recherchables dans `searchableAttributes` (`*` couvre tout sur le déploiement courant).

---

## Sous-régions

Catalogue ISO 3166-2 **petit mais paginé** (~99 documents aujourd'hui) hébergé dans l'index Meilisearch `MEILISEARCH_SUBREGIONS_INDEX` (défaut `subregions`). Lecture seule depuis Meili — aucun domaine `Subregion` côté MySQL, l'index est alimenté par un processus externe. Le pattern paginé est conservé par symétrie avec `/api/regions` et pour qu'une croissance du catalogue ne casse pas les clients.

**Identité côté API** : le `id` JSON:API d'une ressource `subregions` est le **code ISO 3166-2 en minuscules** (`ag-10`, `ag-11`, …). Les documents Meili stockent le code en majuscules dans le champ `id` (`AG-10`, `AG-11`) — Hydrogen normalise à la sérialisation. Les URLs sont **case-insensitive** end-to-end.

**Hiérarchie** : chaque sous-région porte `country_id` (ISO 3166-1 alpha-2 du pays parent, ex: `AG`) et `region_id` (ISO 3166-2 de la région parente, ex: `AG-10`). Les deux sont filtrables sur tous les endpoints listing/search.

**Descriptions Markdown** : la ressource détail (`GET /api/subregions/{code}`) embarque un attribut `description` dont la valeur est le **contenu brut Markdown** du fichier `resources/lang/<locale>/subregions/<code>/description.md`. Mêmes propriétés et même fallback que pour les pays / régions (locale courante puis `SupportedLocales::DEFAULT`, sinon `null`). Le Markdown n'est **pas rendu côté serveur**.

**Blocs `country` + `region` inline** : chaque ressource `subregions` (listing, search, détail) embarque **deux** sous-objets résolvant la hiérarchie complète sans appel supplémentaire client :

- `country` — sous-ensemble léger du pays parent (`id` lowercase, `name`, `slug`, `continent`, `continentId`). Forme identique à celui exposé sur `/api/regions`.
- `region` — sous-ensemble léger de la région parente (`id` lowercase ISO 3166-2, `name`, `slug`, `countryId` lowercase).

Côté serveur, **deux appels Meili** sont émis par requête HTTP quel que soit le nombre de hits (un sur l'index `countries`, un sur `regions`), via les batch-loaders `CountrySummaryResolver` / `RegionSummaryResolver`. `country` ou `region` retombent sur `null` quand le `country_id` / `region_id` est manquant ou ne résout pas (code orphelin). Pour les attributs riches d'une région ou d'un pays (description longue, native_name, alt_names…), passer par `/api/regions/<code>` / `/api/countries/<code>`.

> **Garde anti-path-traversal** : le code passe par une regex `^[a-z0-9-]{2,7}$` avant toute lecture disque.

### `GET /api/subregions`

Listing paginé du catalogue, avec filtres optionnels par pays et/ou région parente.

- **Auth** : aucune.
- **Action** : [ListSubregionsAction](../src/Http/Action/Api/Subregion/ListSubregionsAction.php).
- **Query** :
  - `limit` (int, défaut `20`, plafond `100`).
  - `offset` (int ≥ 0, défaut `0`).
  - `country` (string, optionnel) — code ISO 3166-1 alpha-2 (2 lettres, case-insensitive). Filtre Meili exact sur `country_id`. Format invalide → `422 Invalid country code`.
  - `region` (string, optionnel) — code ISO 3166-2 de la région parente (case-insensitive). Filtre Meili exact sur `region_id`. Format invalide → `422 Invalid region code`.
  - Combinables (filtre AND). Combiner `country` + `region` est typiquement redondant (la région implique son pays) mais accepté en défense en profondeur.
- **Pas de `description` dans le listing** : charger les fichiers Markdown sur un endpoint de catalogue est gaspilleur. Le blurb n'est disponible que sur le détail.

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "subregions",
      "id":   "ag-10",
      "attributes": {
        "name":           "Barbuda",
        "slug":           "barbuda",
        "codes":          { "osm": null, "wikidata": "Q238752", "wikipedia": "en:Barbuda" },
        "names":          { "en-US": "Barbuda", "fr-FR": "Barbuda" },
        "official_names": null,
        "country_id":     "AG",
        "region_id":      "AG-10",
        "stats":          { "stats": null, "surface": "144" },
        "latitude":       17.62,
        "longitude":      -61.78,
        "country": {
          "id":          "ag",
          "name":        "Antigua and Barbuda",
          "slug":        "antigua-and-barbuda",
          "continent":   "Americas",
          "continentId": "AM"
        },
        "region": {
          "id":        "ag-10",
          "name":      "Barbuda",
          "slug":      "barbuda",
          "countryId": "ag"
        }
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/subregions?country=ag&limit=20",
    "first": "https://api.example/api/subregions?country=ag&limit=20",
    "prev":  null,
    "next":  null,
    "last":  "https://api.example/api/subregions?country=ag&limit=20&offset=0"
  },
  "meta": {
    "totalHits": 2,
    "limit":     20,
    "offset":    0,
    "count":     2,
    "country":   "AG"
  }
}
```

- **Réponses d'erreur** :
  - `422 Invalid country code` — `country` ne matche pas `[a-zA-Z]{2}`.
  - `422 Invalid region code` — `region` ne matche pas `[a-zA-Z0-9-]{2,7}`.
  - `503 Search backend unavailable`.

### `GET /api/subregions/search`

Quatre modes combinables :

1. **Full-text** : `?q=<term>`.
2. **Geo-rayon** : `?lat=&lng=&distance=` (mètres) — les trois ensemble, sinon `422`.
3. **Filtre pays** : `?country=AG`.
4. **Filtre région** : `?region=AG-10`.

Tous combinés via AND (conjonction de filtres Meili).

- **Auth** : aucune.
- **Action** : [SearchSubregionsAction](../src/Http/Action/Api/Subregion/SearchSubregionsAction.php).
- **Query** :
  - `q` (string, optionnel) — recherche full-text.
  - `lat` / `lng` / `distance` — tous trois requis ensemble pour le mode géo.
  - `country` / `region` — comme sur le listing.
  - `limit` (int, défaut `20`, plafond `50`).
  - `offset` (int ≥ 0, défaut `0`).
- **Plafond distance** : `SUBREGION_NEARBY_MAX_DISTANCE_METERS` (défaut **1 000 000 m = 1 000 km**). Dépassement → `422 Distance too large`.
- **Tri géo** : en mode géo, tri par `_geoPoint(lat, lng):asc`. Pré-requis index satisfait par défaut (`_geo` dans `sortableAttributes`).

- **Réponse `200 OK`** : structure identique à `/api/regions/search` modulo `subregions` comme `type` et la présence de `region_id` dans les attributs. Les blocs `country` et `region` sont présents sur chaque hit (voir intro de section).

- **Réponses d'erreur** :
  - `422 Invalid country code` / `Invalid region code` — formats malformés.
  - `422 Incomplete geo parameters` — un des `lat`/`lng`/`distance` fourni mais pas les autres.
  - `422 Invalid latitude` / `Invalid longitude` / `Invalid distance`.
  - `422 Distance too large` — `distance > SUBREGION_NEARBY_MAX_DISTANCE_METERS`.
  - `503 Search backend unavailable`.

### `GET /api/subregions/{code}`

Détail d'une sous-région par son code ISO 3166-2 (case-insensitive). Filtre Meili **exact** sur `id = "<UPPER>"`.

- **Auth** : aucune.
- **Action** : [GetSubregionAction](../src/Http/Action/Api/Subregion/GetSubregionAction.php).
- **Path** :
  - `{code}` — regex `[a-zA-Z0-9-]{2,7}`. Casse normalisée à la résolution. Format invalide → `404`.
- **Différence clé avec le listing** : ajoute un attribut `description` chargé depuis `resources/lang/<locale>/subregions/<code>/description.md`.

- **Réponse `200 OK`** (locale `fr-FR`) :

```json
{
  "data": {
    "type": "subregions",
    "id":   "ag-10",
    "attributes": {
      "name":           "Barbuda",
      "slug":           "barbuda",
      "codes":          { "osm": null, "wikidata": "Q238752", "wikipedia": "en:Barbuda" },
      "names":          { "en-US": "Barbuda", "fr-FR": "Barbuda" },
      "official_names": null,
      "country_id":     "AG",
      "region_id":      "AG-10",
      "stats":          { "stats": null, "surface": "144" },
      "latitude":       17.62,
      "longitude":      -61.78,
      "country":        { "id": "ag", "name": "Antigua and Barbuda", "slug": "antigua-and-barbuda", "continent": "Americas", "continentId": "AM" },
      "region":         { "id": "ag-10", "name": "Barbuda", "slug": "barbuda", "countryId": "ag" },
      "description":    "# Barbuda\n\n**Barbuda** est l'une des deux îles principales…"
    }
  }
}
```

- **Réponses d'erreur** :
  - `404 Subregion not found` — code invalide OU aucun document Meili correspondant.
  - `503 Search backend unavailable`.

> **Pré-requis index Meilisearch** (one-shot ops) — **tous déjà actifs** sur le déploiement :
> - `id`, `country_id`, `region_id`, `_geo`, `name` dans `filterableAttributes`
> - `_geo` dans `sortableAttributes`
> - les champs texte recherchables dans `searchableAttributes` (`*` couvre tout)

---

## Thèmes / centres d'intérêt

Catalogue de thèmes éditorialisé en back-office (≈ 50 entrées) parmi lesquels chaque utilisateur en choisit **5 à 10** lors de l'onboarding. Sert ensuite à recouper le feed (médias / établissements taggés sur les mêmes thèmes).

### Modèle de données

- **`topic`** — entrée du catalogue. `id` BINARY(16), `slug` VARCHAR(64) UNIQUE (identité publique stable), `icon`, `position` (ordre d'affichage), `is_active` (un thème retiré du catalogue reste référencé par les utilisateurs qui l'avaient coché — il est juste invisible dans le picker).
- **`topic_translation`** — `(topic_id, locale)` → `label`, `description`. Résolution avec **double LEFT JOIN** côté repo : locale demandée + `SupportedLocales::DEFAULT`. Le `label` retombe sur le slug si aucune traduction n'existe.
- **`user_topic`** — `(user_id, topic_id)` PK composite, index inverse `(topic_id, user_id)` pour les futures requêtes "utilisateurs intéressés par le thème X". FK ON DELETE CASCADE des deux côtés.

### Identité côté API

L'**identifiant JSON:API d'une ressource `topics` est le `slug`**, pas l'UUID hex. L'UUID interne reste exposé en attribut `internalId` pour le debug / cross-référence outils admin uniquement.

### Variables d'environnement

| Variable             | Défaut | Effet                                                                                                                |
|----------------------|--------|----------------------------------------------------------------------------------------------------------------------|
| `TOPIC_MAX_RESULTS`  | `100`  | Cap serveur sur `/api/topics` (`meta.truncated = true` + `meta.maxResults` si atteint).                              |
| `USER_TOPICS_MIN`    | `5`    | Nombre minimum de thèmes dans un `PUT /api/users/me/topics`. En-dessous → `422 userTopic.tooFew`.                    |
| `USER_TOPICS_MAX`    | `10`   | Nombre maximum de thèmes. Au-dessus → `422 userTopic.tooMany`.                                                       |

### Codes d'erreur `userTopic.*`

Tous les guards du `setMine` produisent un `422` avec un `meta.code` stable côté front :

| `meta.code`                | Détail                                                              |
|----------------------------|---------------------------------------------------------------------|
| `userTopic.payloadInvalid` | Le tableau `slugs` est absent ou n'est pas une liste de strings.    |
| `userTopic.tooFew`         | Moins de `USER_TOPICS_MIN` slugs envoyés.                            |
| `userTopic.tooMany`        | Plus de `USER_TOPICS_MAX` slugs envoyés.                             |
| `userTopic.duplicates`     | Au moins un slug envoyé deux fois (échos dans `meta.unknownSlugs`). |
| `userTopic.unknownSlugs`   | Au moins un slug n'est pas dans le catalogue actif (idem).          |

---

### `GET /api/topics`

Catalogue public des thèmes actifs, localisé. Aucun paramètre de query, aucune pagination (le catalogue est borné par produit). Tri systématique `(position, id)` — c'est le même ordre que `GET /api/users/me/topics`, donc le picker d'onboarding peut cocher la sélection courante par simple correspondance.

- **Auth** : aucune
- **Action** : [ListTopicsAction](../src/Http/Action/Api/Topics/ListTopicsAction.php)
- **Réponse `200`** :

```json
{
  "data": [
    {
      "type": "topics",
      "id":   "outdoor",
      "attributes": {
        "slug":        "outdoor",
        "label":       "Plein air",
        "description": "Randonnée, kayak, escalade…",
        "icon":        "mountain",
        "position":    10,
        "isActive":    true,
        "internalId":  "0190f4b5-1c2a-7a3d-b3f1-1e4a8b2c0011",
        "createdAt":   "2026-06-10T12:00:00+00:00",
        "updatedAt":   null
      }
    }
  ],
  "links": { "self": "https://api.example/api/topics" },
  "meta":  { "total": 42 }
}
```

- **Cap serveur atteint** (`meta.total === TOPIC_MAX_RESULTS`) : la réponse ajoute `meta.truncated = true` et `meta.maxResults = <cap>`.

---

### `GET /api/users/me/topics`

Sélection courante de l'utilisateur authentifié (de 0 à `USER_TOPICS_MAX` entrées). Avant onboarding, retourne un tableau vide. La réponse **inclut les thèmes que le BO a depuis désactivés** (`isActive = false`) — la sélection historique reste lisible, le front peut les griser ou les filtrer côté UI.

- **Auth** : requise
- **Action** : [GetMyTopicsAction](../src/Http/Action/Api/Users/Topic/GetMyTopicsAction.php)
- **Réponse `200`** : identique à `GET /api/topics` (même forme, mêmes attributs, même tri `(position, id)`).

---

### `PUT /api/users/me/topics`

Remplace **toute** la sélection de l'utilisateur. Pas d'`add` / `remove` granulaires : le picker UX est "coche les thèmes voulus, sauvegarde la liste entière". L'opération est atomique (DELETE + INSERT en une transaction) — pas de fenêtre où l'utilisateur se retrouve sans sélection.

- **Auth** : requise
- **Action** : [SetMyTopicsAction](../src/Http/Action/Api/Users/Topic/SetMyTopicsAction.php)
- **Corps accepté** : forme aplatie OU forme JSON:API.

```json
// flat
{ "slugs": ["outdoor", "gastronomie", "famille", "culture", "sport"] }

// JSON:API
{ "data": { "attributes": { "slugs": ["outdoor", "gastronomie", "famille", "culture", "sport"] } } }
```

- **Réponse `200`** : la sélection persistée, hydratée dans la locale active — même forme que `GET /api/users/me/topics`. Le front peut donc remplacer son état local par cette réponse sans GET de suivi.

- **Pipeline de validation** (l'ordre fixe quel `meta.code` sort en premier) :
  1. **Shape** — chaque entrée doit être une string non vide après trim ;
  2. **Duplicates** — détectés avant la cardinalité, pour qu'un envoi `["outdoor","outdoor","outdoor"]` retourne `userTopic.duplicates` plutôt que `userTopic.tooFew` ;
  3. **Cardinalité** — `count(slugs) ∈ [USER_TOPICS_MIN, USER_TOPICS_MAX]` ;
  4. **Catalogue** — chaque slug doit pointer un `topic` `is_active = 1`. Les inactifs / inconnus sont énumérés dans `meta.unknownSlugs` ;
  5. **Persistance** — DELETE intégral des `user_topic` du user puis INSERT multi-VALUES, le tout dans une transaction unique (pattern tx-join : rejoint la tx en cours si une est ouverte par un middleware).

- **Réponses d'erreur** : toutes en `422` avec `source.pointer = "/data/attributes/slugs"` et un `meta.code` parmi la table plus haut. Exemple :

```json
{
  "errors": [{
    "status": "422",
    "title":  "Unknown slugs",
    "detail": "One or more slugs are not in the active catalogue.",
    "source": { "pointer": "/data/attributes/slugs" },
    "meta":   { "code": "userTopic.unknownSlugs", "unknownSlugs": ["foo", "bar"] }
  }]
}
```

---

## Badges (gamification)

Système de badges gagnés à mesure que l'utilisateur déclenche des actions tracées (upload, follower reçu, like reçu, commentaire posté, etc.). Le catalogue est seedé via le BO ; le code applicatif ne fait que **lire** la table `badge` + ses tiers et **écrire** dans `user_badge` au passage de palier.

### Modèle de données

Trois tables :
- `badge` — entrée du catalogue : `slug` (identité publique stable), `type` (`unique` | `level`), `metric` (clé du compteur déclencheur, ex. `media.uploaded`), `position`, `is_active`. Index couvrant `(metric, is_active, position)` pour le chemin chaud du service d'award.
- `badge_tier` — palier achievable : `level` (1..5), `threshold` (valeur min du compteur), `xp_reward` (XP cumulés à l'obtention). PK composite `(badge_id, level)`. Un badge `unique` a exactement 1 tier ; un badge `level` en a 1..5.
- `user_badge` — possession : `(user_id, badge_id) → level`. PK composite, monotone (`level` ne descend jamais — `BadgeAwardService` interroge le niveau actuel avant l'upsert et ignore une replay obsolète). `earned_at` figé à la première obtention, `updated_at` bumpé à chaque montée de niveau.

> Les libellés et descriptions vivent dans les catalogues `resources/lang/<locale>/badges.php`, indexés par `slug` (clés `badges.<slug>.label` et `badges.<slug>.description`). La table `badge_translation` a été supprimée — toute évolution de wording passe par une PR sur ces fichiers (et non par le BO).
>
> Les **icônes** sont des assets statiques (par défaut SVG) hébergés dans un dépôt séparé : leurs URLs sont reconstruites à partir du `slug` et du `level` par le sérialiseur — voir _Icônes_ ci-dessous.

### Identité API : `slug`

L'**identifiant JSON:API d'une ressource `badges` est le `slug`** (`first-upload`, `level-uploader`, …), pas l'UUID hex. L'UUID interne reste exposé en attribut `internalId` pour le debug / cross-référence outils admin uniquement.

### Métriques supportées (`BadgeMetric`)

Chaque badge cible **une** métrique :

| Valeur de `metric`          | Déclencheur côté code                                                       |
|-----------------------------|-----------------------------------------------------------------------------|
| `media.uploaded`            | Compteur d'upload d'un user (incrémente sur chaque `POST /users/me/media`). |
| `follower.received`         | Nombre de followers de l'utilisateur (compteur sur `user_stats`).           |
| `following.count`           | Nombre d'utilisateurs suivis.                                               |
| `reaction.like.received`    | Compteur monotone des `j'aime` reçus sur l'ensemble des médias du user (`user_stats.num_likes_received`, alimenté par trigger SQL `AFTER INSERT WHERE value='like'` ; jamais décrémenté sur unlike/flip). Alimente `crowd-favorite`. |
| `comment.posted`            | Compteur monotone des commentaires postés par le user **sur les médias des autres** (self-comments exclus côté trigger SQL `user_stats.num_external_comments`, jamais décrémenté). Alimente `wordsmith`. |
| `sponsorship.valid`         | Filleuls confirmés (mail validé).                                           |
| `user.level`                | Niveau utilisateur (dérivé de `experience`, voir formule plus haut).        |

Le caller passe **directement la nouvelle valeur du compteur** à `BadgeAwardService::onMetric()` — pas de `COUNT(*)` à la volée (les compteurs sont déjà dénormalisés sur `user_stats` ou autre, le service y croit).

### Mécanique d'award (`BadgeAwardService`)

1. Pour chaque badge actif tracant la métrique mutée, `Badge::tierFor($newValue)` calcule le tier le plus haut atteint.
2. `UserBadgeRepository::upsertLevel()` est un upsert **monotone** (le `ON DUPLICATE KEY UPDATE` est protégé par un guard applicatif qui refuse de redescendre le niveau). Retourne le `previousLevel`.
3. Pour **chaque palier traversé** dans le bump (ex. L0 → L3 = 3 paliers), l'`xp_reward` correspondant est cumulé puis crédité via `ExperienceService::award()` (qui rejoint la tx ouverte par le service d'award — XP et état badge restent atomiques).
4. **Après commit**, une notification `badge.earned` est dispatchée **par palier traversé** (pas seulement le dernier). Backfill L0 → L3 produit donc 3 lignes de feed, 3 entrées d'audit XP, 1 seule ligne `user_badge` à L3. Chaque dispatch porte un `dedupKey` `badge.earned:<badgeHex>:<level>` — un replay ne crée pas de doublon.
5. Failure du dispatch après commit = soft-fail (le feed est best-effort, l'état persisté est correct).

### Visibilité

Par décision produit : **les badges sont publics** (même pattern que `/followers`). N'importe qui peut consulter les badges gagnés par n'importe quel utilisateur via `/users/{userId}/badges`. Pas de gate de confidentialité.

### Icônes

Le serveur ne sert pas les SVG : il ne fait que construire leurs URLs (`BadgeIconUrlBuilder`). Conventions :

- `lockedIcon`  → `<BADGE_ICON_BASE_URL>/<slug>/locked.<BADGE_ICON_EXT>` — silhouette grisée commune au badge, exposée à la racine de la ressource.
- `tiers[].icon` → `<BADGE_ICON_BASE_URL>/<slug>/<level>.<BADGE_ICON_EXT>` — icône débloquée propre à chaque tier.

Le front choisit `lockedIcon` tant que `earned` est `null` (ou si `earned.level < tier.level`), `tiers[i].icon` sinon.

### Variables d'environnement

| Var                    | Défaut                                       | Effet                                                                                            |
|------------------------|----------------------------------------------|--------------------------------------------------------------------------------------------------|
| `BADGE_MAX_RESULTS`    | `200`                                        | Cap serveur sur `/api/badges` (`meta.truncated = true` + `meta.maxResults` si atteint).          |
| `BADGE_ICON_BASE_URL`  | `http://hexatrip-static.dev.com/badges`      | Racine publique des assets icônes (pas de slash final). Pointe sur le CDN en prod.               |
| `BADGE_ICON_EXT`       | `svg`                                        | Extension des icônes (sans point). Permet de basculer SVG ↔ WebP/PNG sans toucher au code.       |

Les `threshold` et `xp_reward` **ne sont pas** des envs : ils vivent dans `badge_tier` pour rebalancer l'économie depuis le BO sans redéploiement.

### `GET /api/badges`

Catalogue public des badges actifs, localisé. Aucun paramètre de query, aucune pagination (catalogue borné par produit). Tri systématique `(position, id)`.

- **Auth** : optionnelle. Si un Bearer valide est fourni, chaque ressource porte un `earned` non-null avec le niveau et l'`earnedAt` du viewer. Sinon `earned` vaut `null` partout.
- **Action** : [ListBadgesAction](../src/Http/Action/Api/Badges/ListBadgesAction.php)
- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "badges",
      "id":   "first-upload",
      "attributes": {
        "slug":         "first-upload",
        "type":         "unique",
        "metric":       "media.uploaded",
        "label":        "Premier cliché",
        "description":  "Votre tout premier média publié.",
        "lockedIcon":   "https://cdn.example/badges/first-upload/locked.svg",
        "position":     1,
        "isActive":     true,
        "maxLevel":     1,
        "maxThreshold": 1,
        "tiers": [
          {
            "level":     1,
            "threshold": 1,
            "xpReward":  100,
            "icon":      "https://cdn.example/badges/first-upload/1.svg"
          }
        ],
        "earned": {
          "level":         1,
          "earnedAt":      "2026-06-10T14:32:11+00:00",
          "updatedAt":     null,
          "nextThreshold": null,
          "nextXpReward":  null
        },
        "internalId": "9c3c1a18-…",
        "createdAt":  "2026-05-01T10:00:00+00:00",
        "updatedAt":  null
      }
    }
  ],
  "meta":  { "total": 24 },
  "links": { "self": "https://api.example/api/badges" }
}
```

`earned = null` quand l'appel est anonyme ou que le viewer n'a pas (encore) ce badge. `nextThreshold` / `nextXpReward` sont les attributs du tier au-dessus du niveau actuel (utiles pour la barre de progression front) ; ils valent `null` quand le viewer est au sommet.

`meta.truncated = true` + `meta.maxResults` apparaissent si le BO a inséré plus de `BADGE_MAX_RESULTS` lignes actives.

---

### `GET /api/users/me/badges`

Badges actuellement détenus par l'utilisateur authentifié (niveau ≥ 1). Tri `(position, id)`.

- **Auth** : requise (Bearer)
- **Action** : [GetMyBadgesAction](../src/Http/Action/Api/Users/Badge/GetMyBadgesAction.php)
- **Réponse `200`** : identique à `GET /api/badges`, mais ne contient **que les badges gagnés** et `earned` est toujours non-null. Les badges depuis désactivés par le BO restent présents (`isActive = false`) pour ne pas casser la galerie historique.
- **Collection vide** : `data: []`, `meta.total: 0`, `links.self` — jamais de 404 si l'utilisateur n'a juste rien gagné.

---

### `GET /api/users/{userId}/badges`

Variante publique du précédent : badges gagnés par n'importe quel utilisateur.

- **Auth** : non requise (badges publics)
- **Action** : [GetUserBadgesAction](../src/Http/Action/Api/Users/Badge/GetUserBadgesAction.php)
- **Erreurs** :
  - `422` si `userId` malformé (`pointer = "/data/id"`)
  - `404` si l'utilisateur n'existe pas
- **Réponse `200`** : même forme que `/users/me/badges`. Collection vide légitime si l'utilisateur n'a aucun badge.

---

## Titres (gamification)

Système de titres dérivé du `level` utilisateur (lui-même dérivé d'`experience` via `UserLevelCalculator`). Le titre **n'est pas stocké par utilisateur** : il est résolu à la volée par `UserTitleResolver` au moment où la ressource `users` est sérialisée. Seul l'attribut rendu `displayTitle` est exposé.

### Mécanique

- Tous les **5 niveaux** = nouveau bucket de titre. `titleIndex = floor((level - 1) / 5)` ; `rankIndex = (level - 1) % 5`.
- Dans un bucket, le `rankIndex ∈ [0, 4]` est mappé sur 5 templates **figés par produit** (jamais administrables) :
  - `0` → `{title}` (titre nu, ex. « Touriste »)
  - `1` → `{title} averti`
  - `2` → `{title} confirmé`
  - `3` → `{title} expert`
  - `4` → `{title} légendaire`
- Les templates vivent dans le catalogue i18n (`resources/lang/<locale>/titles.php`) — ils ne sont PAS dans la base. La traduction de `{title}` provient de `title_translation` pour la locale demandée, avec fallback double : locale demandée → locale par défaut → `slug`.
- **Plafond** : si `level` excède le bucket actif le plus haut, le resolver garde le bucket plafond et fait monter le `rankIndex` jusqu'à 4. Un user lv 30 alors que le BO n'a seedé que jusqu'au bucket « Pionnier » (lv 11..15) voit donc « Pionnier légendaire », pas un titre fantôme.
- **Catalogue vide** (aucun bucket seedé par le BO) : `resolve()` retourne `null` et le sérialiseur **omet purement et simplement** `displayTitle` — pas de fallback bidon, pas de crash côté front.

### Schéma

- `title` — un bucket : `slug` (identité publique stable), `position` (titleIndex, UNIQUE — deux titres ne peuvent pas se battre pour la même bande), `is_active` (retrait BO sans rupture historique : le resolver ne considère que les buckets actifs, mais une ligne archivée reste hydratable pour l'outillage admin), timestamps. Index couvrant `(is_active, position)` pour le chemin chaud du resolver.
- `title_translation` — un libellé par locale : PK composite `(title_id, locale)`, FK CASCADE, KEY `(locale, title_id)` pour le join inverse.

### Catalogue

Le BO **seede** les buckets au fur et à mesure que le ladder s'étend — pas de redéploiement nécessaire. Un seed type :

```sql
INSERT INTO `title` (`id`, `slug`, `position`, `is_active`) VALUES
  (UNHEX(REPLACE(UUID(),'-','')), 'tourist',  0, 1),
  (UNHEX(REPLACE(UUID(),'-','')), 'explorer', 1, 1),
  (UNHEX(REPLACE(UUID(),'-','')), 'pioneer',  2, 1);
-- + INSERT title_translation (title_id, locale, label) par (bucket, locale).
```

### Cache

`UserTitleResolver` mémoïse la liste active **par locale, pour la durée du conteneur DI** (request-scoped). Une requête qui sérialise N utilisateurs n'effectue donc qu'**une seule** lecture par locale, indépendamment de N. Le catalogue est borné par construction (un row par 5 niveaux).

### Accès depuis l'objet `User`

L'entité de domaine [`User`](../src/Domain/User/User.php) expose trois getters dérivés (même pattern que `displayName()` / `isConfirmed()`), les dépendances étant passées en paramètres pour garder `User` `readonly` et sans état :

```php
$level        = $user->level($levels);                              // int
$userTitle    = $user->title($titles, $levels, $locale);            // ?UserTitle (bucket + rank + displayTitle)
$displayTitle = $user->displayTitle($titles, $levels, $locale);     // ?string  (shorthand)
```

N'importe quel code applicatif qui détient déjà un `User` peut donc résoudre son titre sans repasser par le sérialiseur. Le sérialiseur lui-même utilise ces getters — pas de chemin parallèle.

### Exposition côté ressource `users`

```json
{
  "level":         7,
  "levelProgress": 42.50,
  "experience":    1050,
  "displayTitle":  "Explorateur averti"
}
```

`levelProgress` est la progression dans le **niveau courant**, exprimée en
pourcentage `[0, 100]` arrondi à 2 décimales. La formule s'appuie sur la
courbe quadratique d'XP : pour un niveau `N` avec `factor = F`, le palier
court de `F·N·(N-1)` à `F·N·(N+1)` XP — `levelProgress` mesure la position
relative dans ce palier. `0.00` au passage de niveau, monte vers `100`,
remis à `0.00` au niveau suivant. Disponible aussi sur le bloc public
[`author`](#auteur-public-author-sur-la-ressource-medias).

`displayTitle` est rendu dans la **locale du viewer** (pas celle de l'utilisateur affiché — l'entité `user` n'a pas de colonne `locale`). Les actions d'authentification (`POST /api/auth/login`, `POST /api/auth/register`, `GET /api/auth/me`, `GET /api/auth/confirm-email`, OAuth) propagent la locale active de la requête au sérialiseur ; les autres listings utilisent la locale par défaut.

### Notifications associées

Voir [Notifications](#notifications). Deux types sont dispatchés DANS la même transaction que l'UPDATE XP, depuis `ExperienceService::award()` :

- `user.level.up` — **un par niveau franchi**. Un award qui fait sauter L4 → L7 émet 3 lignes.
- `user.title.up` — **un par bucket traversé**. Le même award émet aussi 1 ligne pour le passage L5 → L6 (frontière de bucket). Pas de title-up sur le plafond (le slug reste identique).

Les deux types respectent la préférence `inApp` du destinataire et le soft-dedup ; un rollback de la tx caller emporte les deux avec lui (cohérence stricte).

---

## Signalements (modération)

API permettant aux utilisateurs authentifiés de signaler un média, un compte ou un commentaire pour examen par la modération. Les lignes sont persistées dans la base back-office (`hxa_bo.report`) — la base applicative ne voit jamais ces données. Les modérateurs lisent la file via `/admin/reports` (voir [docs/admin.md](admin.md)).

### Modèle de données

Table polymorphe `hxa_bo.report` :

| Colonne                | Type                                              | Notes |
|------------------------|---------------------------------------------------|-------|
| `id`                   | `BINARY(16)`                                      | UUID v4. |
| `reporter_user_id`     | `BINARY(16)`                                      | Auteur du signalement. **Jamais exposé** côté API publique. |
| `target_type`          | `ENUM('media','user','comment')`                  | Discriminateur. |
| `target_id`            | `BINARY(16)`                                      | Référence vers `media.id` / `user.id` / `media_comment.id`. Pas de FK déclarée : la modération doit survivre à un hard-delete de la cible. |
| `reason_code`          | `VARCHAR(64)`                                     | Slug whitelisté par `config/report_reasons.php`. |
| `details`              | `TEXT NULL`                                       | Texte libre optionnel (capé à `REPORT_DETAILS_MAX_LENGTH`). |
| `status`               | `ENUM('pending','reviewed','action_taken','dismissed')` | Cycle de vie côté modération. |
| `resolved_by_user_id`  | `BINARY(16) NULL`                                 | Opérateur qui a tranché (peut être NULL pour un job automatisé). |
| `resolved_at`          | `DATETIME NULL`                                   | Horodatage du verdict. |
| `resolution_note`      | `TEXT NULL`                                       | Commentaire interne du modérateur. |
| `created_at` / `updated_at` | `DATETIME`                                   | |

Index :
- `UNIQUE (reporter_user_id, target_type, target_id)` — idempotence du POST + check « ai-je déjà signalé X ? ».
- `KEY (target_type, target_id, status)` — agrégat « combien de signalements ouverts sur cette cible ? ».
- `KEY (status, created_at)` — scan de file modérateur.

### Codes de motif (`reason_code`)

Whitelist statique (`config/report_reasons.php`), différente par `target_type` :

| Motif            | media | user | comment | Description |
|------------------|:-----:|:----:|:-------:|-------------|
| `spam`           | ✓     | ✓    | ✓       | Contenu/compte/commentaire spam ou répétitif. |
| `harassment`     | ✓     | ✓    | ✓       | Harcèlement ciblé. |
| `hate_speech`    | ✓     | ✓    | ✓       | Discours de haine. |
| `sexual_content` | ✓     | —    | ✓       | Contenu sexuel explicite non signalé. |
| `violence`       | ✓     | —    | ✓       | Violence graphique. |
| `copyright`      | ✓     | —    | —       | Atteinte aux droits d'auteur. |
| `fake_account`   | —     | ✓    | —       | Compte bot ou fake. |
| `impersonation`  | —     | ✓    | —       | Usurpation d'identité. |
| `other`          | ✓     | ✓    | ✓       | Motif libre (à expliciter dans `details`). |

Un motif hors whitelist pour le `target_type` ⇒ 422 `report.invalidReason`.

### Variables d'environnement

- `REPORT_DETAILS_MAX_LENGTH` (défaut `1000`) — cap glyphes UTF-8 sur `details`.
- `REPORT_RATE_LIMIT_PER_DAY` (défaut `20`) — nombre maximal de signalements distincts qu'un même utilisateur peut soumettre sur une fenêtre glissante de 24h. Un re-signalement de la même cible (qui retourne la ligne existante) ne consomme PAS de quota.

### Règles métier (validées)

- **Auth requise** — un signalement anonyme serait du bruit.
- **NO self-report** — un utilisateur ne peut PAS signaler son propre média / compte / commentaire (403 `report.selfReport`).
- **NO XP**, **NO notification** au signalé, **NO compteur public** — l'acte reste strictement privé entre le rapporteur et la modération.
- **Idempotence** : POST répété sur la même cible par le même rapporteur ⇒ retour 200 avec la ligne existante (le verdict d'une cible déjà résolue est **préservé**, pas réouvert).
- **Anonymat côté API** : la ressource `reports` renvoyée n'expose **jamais** `reporterUserId` / `resolvedByUserId` / `resolutionNote`. Ces champs ne sont accessibles que via l'API admin.

### Codes d'erreur (`report.*`)

| `meta.code`                | HTTP | Quand |
|----------------------------|:----:|-------|
| `report.actorNotConfirmed` | 403  | L'utilisateur n'a pas confirmé son email. |
| `report.actorBanned`       | 403  | Compte banni. |
| `report.selfReport`        | 403  | Le rapporteur est le propriétaire de la cible. |
| `report.targetNotFound`    | 404  | La cible n'existe pas. |
| `report.reasonMissing`     | 422  | Champ `reason` manquant ou vide. |
| `report.invalidReason`     | 422  | `reason` hors whitelist pour ce `target_type`. |
| `report.detailsTooLong`    | 422  | `details` dépasse `REPORT_DETAILS_MAX_LENGTH`. |
| `report.rateLimited`       | 429  | `REPORT_RATE_LIMIT_PER_DAY` atteint sur 24h. |

### Ressource `reports`

```jsonc
{
  "type": "reports",
  "id":   "<hex>",
  "attributes": {
    "targetType": "media | user | comment",
    "targetId":   "<hex>",
    "reason":     "spam | harassment | …",
    "status":     "pending | reviewed | action_taken | dismissed",
    "createdAt":  "2026-06-13T12:34:56+00:00"
  }
}
```

`reporterUserId`, `details`, `resolvedByUserId`, `resolvedAt`, `resolutionNote` sont **volontairement absents** côté public.

---

### `POST /api/media/{mediaId}/reports`

Signale un média. `{mediaId}` au format UUID dashed (cohérent avec les autres routes `/media/{mediaId}/*`).

**Auth** : requise.

**Corps** (formes acceptées) :

```jsonc
// Plate
{ "reason": "spam", "details": "optional context" }

// JSON:API
{
  "data": {
    "type": "reports",
    "attributes": { "reason": "spam", "details": "optional context" }
  }
}
```

`details` est optionnel.

**Réponses** :
- `201 Created` — première fois (la ressource `reports` est retournée).
- `200 OK`     — re-signalement de la même cible par le même utilisateur ; la ligne existante est retournée telle quelle (`status` préservé, même si déjà résolue).
- `403`        — voir codes d'erreur.
- `404`        — média introuvable.
- `422`        — payload invalide.
- `429`        — quota journalier atteint.

---

### `POST /api/users/{userHex}/reports`

Signale un compte utilisateur. `{userHex}` au format hex (32 caractères, sans tirets), aligné sur les autres endpoints user-centric.

**Auth** : requise.

**Corps** : identique à `/api/media/{mediaId}/reports` ; les motifs `fake_account` et `impersonation` deviennent valides ici.

**Réponses** : mêmes statuts.

---

### `POST /api/media/comments/{commentId}/reports`

Signale un commentaire. `{commentId}` au format UUID dashed (cohérent avec `/media/comments/{commentId}/*`).

**Auth** : requise.

**Réponses** : mêmes statuts. Le check self-report s'appuie sur `media_comment.user_id` — signaler son propre commentaire est un 403 `report.selfReport`, pas un 404.

---

## Notifications

Système de notifications adossé à une table polymorphe `notification` (clé primaire = UUID, `data` JSON figé au dispatch, `dedup_key` optionnel, `read_at`/`pushed_at` nullables). Les libellés (`title`, `body`) **ne sont pas stockés** : le serveur les rend à la lecture / à l'envoi en utilisant la locale du destinataire — un seul catalogue (`resources/lang/<locale>/notifications.php`) sert à la fois l'in-app feed et la push OneSignal.

**Canaux** (`NotificationChannel`) : `inApp` (l'écriture en base est conditionnée à cette préférence), `push` (filtré au moment du flush cron). La matrice est `type × channel → bool`, défaut à `true` pour toutes les cellules (voir `NotificationPreferenceService::defaultFor()`).

**Types pris en charge aujourd'hui** :
- `follow.received` — émis depuis `FollowService::follow()` après un INSERT réussi sur `user_follow`. `data` contient `{actorId, actorUsername, actorDisplayName}`. `dedupKey = "follow.received:<actorHex>"` pour fondre les bascules follow/unfollow/follow rapprochées en une seule entrée.
- `badge.earned` — émis depuis `BadgeAwardService` une fois par palier traversé (un backfill L0 → L3 émet 3 lignes). `data` contient `{badgeId, badgeSlug, level, maxLevel, label, icon, xpReward}`. `dedupKey = "badge.earned:<badgeHex>:<level>"` (réplay-safe par palier). Voir [Badges (gamification)](#badges-gamification).
- `user.level.up` — émis depuis `ExperienceService::award()` **une fois par niveau franchi** lorsqu'une attribution d'XP fait passer `level` à une valeur supérieure (un award qui fait passer L4 → L7 produit donc 3 lignes). `data` contient `{level, previousLevel, experience}`. `dedupKey = "user.level.up:<recipientHex>:<level>"` — un replay du même award (même tx ré-exécutée) ne crée pas de doublons. **Atomique avec l'UPDATE XP** : la dispatch a lieu DANS la transaction, donc un rollback emporte la notification avec lui (pas d'orphelin). Voir [Titres (gamification)](#titres-gamification).
- `user.title.up` — émis depuis `ExperienceService::award()` quand la montée en niveau traverse une **frontière de bucket de titre** (tous les 5 niveaux, plafonné au bucket actif le plus haut). Une fois par bucket traversé. `data` contient `{level, titleSlug, titleLabel, rankIndex, displayTitle}` — le `displayTitle` est figé dans la locale **par défaut** au dispatch (le destinataire n'a pas de colonne `locale` côté `user`). `dedupKey = "user.title.up:<recipientHex>:<titleSlug>"` (un user ne peut entrer dans un bucket donné qu'une fois — XP monotone — donc tout replay collapse proprement). Voir [Titres (gamification)](#titres-gamification).

**Dispatch (`NotificationService::dispatch`)** :
1. court-circuit si le destinataire a désactivé le canal `inApp` pour ce type — aucune ligne en base ;
2. soft-dedup : s'il existe déjà une ligne non lue avec le même `(user_id, dedup_key)` dans la fenêtre `NOTIFICATION_DEDUP_WINDOW_MINUTES` (défaut `5`), seule la colonne `created_at` est mise à jour (`pushed_at` n'est PAS réinitialisé — la push éventuelle a déjà été envoyée) ;
3. sinon INSERT classique avec UUID v4.

**Flush / push OneSignal (cron, stratégie G'.3)** :
- Un script CLI `bin/notifications-flush.php` est exécuté toutes les `NOTIFICATION_DIGEST_INTERVAL_MINUTES` minutes (2 en dev, 5 en prod) via Task Scheduler / cron.
- Par destinataire avec au moins une ligne `pushed_at IS NULL` :
  - 1 ligne → push individuel rendu via `notifications.<type>.title|body` ;
  - ≥ 2 lignes → push **digest** rendu via `notifications.digest.title|body` (avec `{count}` ICU).
- Les lignes dont le type a désactivé le canal `push` reçoivent quand même un `pushed_at` (sinon elles s'accumuleraient), seule la push est suppressed.
- L'aliasing OneSignal est `external_id = <recipientHex>` (32 char) ; côté SDK mobile/web, appeler `OneSignal.login(externalId)` au moment du provisioning.
- Failover : un échec OneSignal sur un utilisateur ne stoppe pas la boucle — l'erreur est loggée (`onesignal.pushFailed`) et le tick continue.

Voir [src/Domain/Notification](../src/Domain/Notification) et [src/Infrastructure/OneSignal](../src/Infrastructure/OneSignal).

**Pagination** : keyset opaque sur `(createdAt, id)` desc. `?cursor=…` (page suivante), `?before=…` (page précédente), `?limit=<1..100>` (défaut `20`). Navigation via l'objet `links` racine JSON:API.

---

### `GET /api/users/me/notifications`

Liste paginée du feed in-app de l'utilisateur authentifié (plus récents d'abord).

- **Auth** : requise (Bearer)
- **Action** : [ListNotificationsAction](../src/Http/Action/Api/Notifications/ListNotificationsAction.php)
- **Query** :
  - `?cursor=<opaque>` (page suivante) / `?before=<opaque>` (page précédente) — `400` si malformé
  - `?limit=<1..100>` (défaut `20`)
  - `?filter=unread` (optionnel) — restreint à `read_at IS NULL`
- **Réponse `200 OK`** : collection JSON:API de ressources `notifications`. Chaque ressource :

```json
{
  "type": "notifications",
  "id":   "<uuid>",
  "attributes": {
    "type":      "follow.received",
    "title":     "Nouveau follower",
    "body":      "alice vous suit désormais.",
    "locale":    "fr-FR",
    "data":      { "actorId": "…", "actorUsername": "alice", "actorDisplayName": "Alice" },
    "isRead":    false,
    "readAt":    null,
    "createdAt": "2026-06-07T12:34:56+00:00"
  }
}
```

`meta` : `{ "limit": 20, "unreadOnly": false, "total": 137 }`. `meta.total` reflète le même filtre que la requête (avec `?filter=unread`, il ne compte que les non-lues). Navigation paginée via `links.{self,first,prev,next}`.

---

### `GET /api/users/me/notifications/unread-count`

Endpoint léger pour le badge de la navbar.

- **Auth** : requise
- **Action** : [UnreadNotificationCountAction](../src/Http/Action/Api/Notifications/UnreadNotificationCountAction.php)
- **Réponse `200 OK`** : ressource `notificationUnreadCount` avec `attributes.count` (entier).

---

### `PATCH /api/users/me/notifications/{id}/read`

Marque une notification comme lue. Idempotent (200 si déjà lue).

- **Auth** : requise
- **Action** : [MarkNotificationReadAction](../src/Http/Action/Api/Notifications/MarkNotificationReadAction.php)
- **Path** : `{id}` UUID canonique de la notification
- **Erreurs** :
  - `422` UUID invalide
  - `404` notification inexistante ou appartenant à un autre utilisateur
- **Réponse `200 OK`** : `{ "data": { "type": "notifications", "id": "<uuid>", "attributes": { "isRead": true } } }`.

---

### `POST /api/users/me/notifications/mark-all-read`

Passe en lecture tout l'inbox de l'utilisateur.

- **Auth** : requise
- **Action** : [MarkAllNotificationsReadAction](../src/Http/Action/Api/Notifications/MarkAllNotificationsReadAction.php)
- **Réponse `200 OK`** : ressource `notificationBulkRead` avec `attributes.affected` (entier = nombre de lignes flipées).

---

### `DELETE /api/users/me/notifications/{id}`

Suppression dure d'une notification. Le WHERE inclut `user_id`, impossible de supprimer celle d'un tiers.

- **Auth** : requise
- **Action** : [DeleteNotificationAction](../src/Http/Action/Api/Notifications/DeleteNotificationAction.php)
- **Erreurs** : `422` UUID invalide, `404` inexistante.
- **Réponse `204 No Content`** (corps vide).

---

### `GET /api/users/me/notifications/preferences`

Matrice complète `type × channel` (avec défauts overlayés sur les overrides) — telle quelle pour la page de réglages.

- **Auth** : requise
- **Action** : [ListNotificationPreferencesAction](../src/Http/Action/Api/Notifications/ListNotificationPreferencesAction.php)
- **Réponse `200 OK`** :

```json
{
  "data": {
    "type": "notificationPreferences",
    "id":   "<userUuid>",
    "attributes": {
      "matrix": {
        "follow.received": { "inApp": true, "push": true }
      }
    }
  }
}
```

---

### `PATCH /api/users/me/notifications/preferences`

Patch partiel (cellules absentes intouchées). Tout-ou-rien : la moindre erreur de validation rejette l'ensemble du patch en `422`.

- **Auth** : requise
- **Action** : [UpdateNotificationPreferencesAction](../src/Http/Action/Api/Notifications/UpdateNotificationPreferencesAction.php)
- **Body** (flat ou `data.attributes`) :

```json
{ "follow.received": { "push": false } }
```

- **Erreurs `422`** (par cellule, avec `meta.code`) :
  - `unknownType` — type inconnu
  - `unknownChannel` — canal inconnu
  - `expectedObject` — entrée non-objet pour un type connu
  - `expectedBool` — feuille non-booléenne
- **Erreur `400`** : corps non-JSON / non-objet.
- **Réponse `200 OK`** : la matrice post-update (même forme que le GET).

---

## Newsletter

Inscription publique à la newsletter, sous **double opt-in** (RGPD). Une
adresse email seule suffit ; aucun compte n'est requis. La ligne est
persistée à l'état `pending` et ne devient `confirmed` qu'après que le
destinataire ait cliqué le lien envoyé par email.

Les confirmation et désinscription se font **côté web** uniquement
(`GET /newsletter/confirm?token=…` et `GET /newsletter/unsubscribe?token=…`) :
ce sont les URLs incluses dans les emails. Aucune API JSON équivalente
n'est exposée — le client web suffit, et limiter à un seul transport
réduit la surface d'attaque sur les tokens.

### Modèle de données

- Table `newsletter_subscriber` : `id` (UUID binaire), `email` (unique,
  toujours stocké en minuscules), `status` (`pending` / `confirmed` /
  `unsubscribed`), `locale`, `source`, `user_id` (FK nullable vers le
  compte si présent au moment de la signup), `ip_address` (VARBINARY(16)),
  `user_agent`, deux tokens (hash SHA-256 binaire) + leurs timestamps.
- Table `newsletter_subscribe_attempt` : bucket sliding-window IP utilisé
  par le throttle (même forme que `login_attempt`).

### Tokens

Convention identique à `EmailConfirmationService` : 32 octets de CSPRNG,
transportés en base64url dans les liens, seule la SHA-256 binaire est
stockée. Le **token de confirmation** est à usage unique et a une
expiration (env `NEWSLETTER_CONFIRM_TTL_HOURS`, défaut 72 h). Le **token
de désinscription** est émis à la confirmation, n'expire pas et reste le
même dans chaque email envoyé à cette adresse.

### Variables d'environnement

| Variable                                | Défaut | Rôle |
| --------------------------------------- | ------ | ---- |
| `NEWSLETTER_CONFIRM_TTL_HOURS`          | `72`   | Durée de vie du lien de confirmation (heures). |
| `NEWSLETTER_RATE_LIMIT_WINDOW_MINUTES`  | `60`   | Fenêtre du throttle IP. |
| `NEWSLETTER_RATE_LIMIT_PER_HOUR`        | `5`    | Nombre maximum d'appels acceptés par IP sur la fenêtre. |

### Anti-spam

1. **Honeypot** — un champ `website` invisible aux humains ; toute valeur
   non vide trip le piège, l'IP est throttlée silencieusement et
   l'erreur renvoyée ne révèle pas la nature du piège.
2. **Rate-limit** — sliding-window IP (envs ci-dessus).
3. **Validation format** — `filter_var(..., FILTER_VALIDATE_EMAIL)` +
   longueur ≤ 254.
4. **Blocklist** — providers jetables (`yopmail.com`, `mailinator.com`,
   etc.) rejetés au niveau du host + parent-zone walk pour bloquer
   `inbox.yopmail.com` également. Code statique
   ([`DisposableEmailBlocklist`](../src/Domain/Newsletter/DisposableEmailBlocklist.php)).
5. **Déliverabilité** — lookup DNS MX puis A/AAAA en repli. Fail-OPEN sur
   erreur DNS pour ne pas pénaliser un domaine valide quand le résolveur
   est lent.

### Anti-énumération

Soumettre une adresse **déjà confirmée** renvoie **exactement la même
réponse `202 Accepted`** qu'une nouvelle inscription — aucun email n'est
ré-envoyé, mais le caller ne peut pas distinguer les deux cas. Cela
empêche d'utiliser le endpoint pour tester l'existence d'une adresse
dans la liste.

### `POST /api/newsletter/subscribe`

Inscrit une adresse email à la newsletter (état `pending`) et déclenche
l'envoi de l'email de confirmation.

- **Auth** : optionnelle (Bearer). Si présent, le `userId` du viewer est
  stampé sur la ligne ; sinon la ligne est anonyme.
- **CSRF** : non (route `/api/*`).
- **Action** : [SubscribeAction](../src/Http/Action/Api/Newsletter/SubscribeAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{
  "email":   "alice@example.com",
  "locale":  "fr-FR",
  "source":  "landing-page",
  "website": ""
}
```

| Attribut  | Type   | Obligatoire | Sémantique |
| --------- | ------ | :---------: | ---------- |
| `email`   | string | ✓          | Adresse à inscrire (normalisée en minuscules côté serveur). |
| `locale`  | string |             | Locale pour rendre l'email de confirmation. Défaut : locale résolue de la requête. |
| `source`  | string |             | Tag libre stocké tel quel (ex : `footer`, `landing-page`). Défaut : `api`. |
| `website` | string |             | **Honeypot** — doit rester vide. Toute valeur non vide trip le piège. |

- **Réponse `202 Accepted`** — la persistance a eu lieu et l'email part.
  Forme identique pour une nouvelle inscription, un re-submit en
  `pending` (le token est renouvelé), un re-subscribe après
  `unsubscribed` (soft-undo vers `pending`), ou une adresse **déjà
  confirmée** (no-op silencieux, anti-énumération) :

```json
{
  "jsonapi": { "version": "1.1" },
  "meta":    { "confirmationRequired": true }
}
```

- **Réponse `422`** — `errors[]` avec `meta.code` parmi :
  - `newsletter.invalidEmail`          — format invalide.
  - `newsletter.disposableEmail`       — provider jetable rejeté.
  - `newsletter.undeliverableDomain`   — pas de MX/A/AAAA pour le domaine.
  - `newsletter.honeypot`              — piège déclenché.
- **Réponse `429`** — `Retry-After: <seconds>` + `meta.code = newsletter.throttled` +
  `meta.retryAfterSeconds`. Trop d'appels depuis cette IP sur la fenêtre.

### Endpoints web associés

Pour mémoire, les retours utilisateur (links cliqués depuis l'email) :

- **`GET /newsletter/confirm?token=<base64url>`** — landing du double
  opt-in. Rend `templates/newsletter/confirmed.twig` avec l'état
  (`confirmed`, `expired`, `unknown`). Idempotent : re-cliquer après une
  confirmation réussie rend l'état `unknown` (le token a été consommé).
- **`GET /newsletter/unsubscribe?token=<base64url>`** — désabonnement
  un-clic, idempotent. Le token de désinscription reste valable tant
  qu'il n'a pas été ré-émis ; un même lien peut donc resservir.

Le widget Twig réutilisable (`templates/partials/newsletter-form.twig`)
poste vers `POST /newsletter/subscribe` (web, CSRF requis) et reçoit un
redirect `303` avec `?newsletter=<flash-code>` que la page d'embedding
affiche via `partials.newsletter.flash.*`.

---

## Internationalisation

L'API est traduite **côté serveur** : les champs `errors[].detail` et toute chaîne destinée à l'affichage sont rendus dans la locale active (en plus du `meta.code` toujours présent et stable, qui reste la source de vérité programmatique pour le client).

### Locales supportées

- `fr-FR` — locale par défaut applicative
- `en-US`

Catalogues sous `resources/lang/<locale>/` (`auth.php`, `users.php`, `validation.php`, `errors.php`, `mails.php`). Format ICU MessageFormat (interpolation `{name}`, pluriels `{count, plural, =1{...} other{...}}`).

### Résolution de la locale active

Le [`LocaleResolverMiddleware`](../src/Http/Middleware/LocaleResolverMiddleware.php) pose `i18n.locale` sur la requête et renseigne le header `Content-Language` sur la réponse. Ordre de priorité (première règle qui matche) :

1. Setting `locale` de l'utilisateur authentifié (lu via `UserSettingService`). Le middleware tourne **après** `AuthenticationMiddleware` sur les routes protégées.
2. Négociation du header `Accept-Language` contre la liste des locales supportées (q-values respectés, fallback langue-only `fr` → `fr-FR`).
3. Locale applicative par défaut (`fr-FR`).

### Fallback de clés (B.c)

Si une clé manque dans la locale active, le `Translator` la cherche dans la locale **`fr-FR`** (locale historique, présumée complète à 100%). Si elle manque aussi là-bas, la clé brute est renvoyée et un warning est loggé.

### Emails

Les emails transactionnels (`email_confirmation`, `password_reset`, …) sont **toujours** rendus dans la locale du **destinataire** (lue sur son setting `locale`), jamais celle de l'acteur qui déclenche l'envoi. Voir [`ConfirmationEmailSender`](../src/Domain/Auth/ConfirmationEmailSender.php) et [`PasswordResetEmailSender`](../src/Domain/Auth/PasswordResetEmailSender.php).

### Réglage utilisateur `locale`

Le setting [`locale`](#patch-apiusersmesettings) est **restreint** aux locales effectivement supportées (= dossiers présents sous `resources/lang/`). Choisir `fr-FR` ou `en-US` actuellement ; toute autre valeur est rejetée avec `setting.invalidLocale`. Cette restriction est volontaire : accepter `ja-JP` produirait une UI en français (fallback) — autant le refuser à la source.

---

### `GET /api/i18n/locales`

Liste les locales **effectivement supportées** par l'application (présence d'un catalogue sous `resources/lang/`), enrichies avec les libellés (anglais et natif) issus de la table SQL `locale`. Différent de la liste des 377 entrées BCP-47 que contient cette même table.

- **Auth** : aucune
- **Action** : [ListLocalesAction](../src/Http/Action/Api/I18n/ListLocalesAction.php)
- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    {
      "type": "locales",
      "id":   "en-US",
      "attributes": {
        "name":       "English",
        "nativeName": "English",
        "isDefault":  false
      }
    },
    {
      "type": "locales",
      "id":   "fr-FR",
      "attributes": {
        "name":       "French",
        "nativeName": "français",
        "isDefault":  true
      }
    }
  ],
  "meta": { "count": 2, "default": "fr-FR" }
}
```

- **Notes** :
  - Tri alphabétique par `id`.
  - `isDefault` flag la locale applicative par défaut (`fr-FR`).
  - Header de réponse `Content-Language` reflète la locale active de la requête courante.
  - Ajout d'une nouvelle locale = créer le dossier `resources/lang/<tag>/` avec les 5 catalogues (`auth.php`, `users.php`, `validation.php`, `errors.php`, `mails.php`). Vérifier ensuite que la ligne `locale.id = '<tag>'` existe en base.

---

## Format d'erreur JSON:API

Toutes les erreurs `/api/*` suivent ce gabarit :

```json
{
  "jsonapi": { "version": "1.1" },
  "errors": [
    {
      "status": "422",
      "title":  "Missing or invalid attribute",
      "detail": "Attribute 'email' is required and must be a non-empty string.",
      "source": { "pointer": "/data/attributes/email" }
    }
  ]
}
```

En mode debug (`APP_DEBUG=true`), les `500` exposent un `meta.exception`/`file`/`line` pour faciliter le diagnostic. Désactiver en prod.
