# API d'administration (`/admin/*`)

Surface HTTP **out-of-band** destinée aux jobs Talend (ETL) et aux runs manuels via Postman / curl. Elle est **distincte** de l'API publique `/api/*` :

| | `/api/*` | `/admin/*` |
|---|---|---|
| Format | JSON:API 1.1 | JSON plat (listings + mutations) / JSON:API 1.1 (détails « 360° ») |
| Auth | session utilisateur (Bearer token = `user_session.token`) | token statique partagé (`ADMIN_API_TOKEN`) |
| Notion de viewer | oui (`auth.user`, locale) | **non** (service-to-service) |
| Middleware locale | oui | non |
| Idempotence | géré par endpoint | géré par endpoint |

> Cette doc n'est pas exposée publiquement. Elle décrit la surface admin pour les opérateurs Hydrogen.

### Convention de format : détails « 360° » en JSON:API

Les endpoints de **détail d'une ressource unique** (`GET /admin/<domaine>/{id}`) renvoient désormais la **même enveloppe JSON:API que l'API publique** (`{ "jsonapi", "data": { "type", "id", "attributes" } }`, `Content-Type: application/vnd.api+json`). Ils en sont un **sur-ensemble strict** : mêmes `type`/`id`/`attributes` que `GET /api/<domaine>/{id}`, plus toutes les données qu'un viewer public ne voit pas (blocs masqués, champs internes, et — pour les domaines Meilisearch — **tous les champs bruts de l'index** rétro-remplis). Objectif : un opérateur voit *absolument toutes les données* sans changer de schéma de lecture.

Les **listings** (`GET /admin/...`, collections paginées) et les **mutations** (`POST`/`PUT`/`PATCH`/`DELETE`) restent en **JSON plat** pour l'ergonomie curl/Talend.

Domaines avec détail JSON:API : `media`, `users`, `reports`, `staff`, `establishments`, `offers`, `countries`, `regions`, `subregions`, `cities`, `social-feeds`.

---

## Authentification

Toutes les routes `/admin/*` sont gardées par [AdminAuthenticationMiddleware](../src/Http/Middleware/AdminAuthenticationMiddleware.php) :

- Header attendu : `Authorization: Bearer <token>`.
- Le token est comparé à `ADMIN_API_TOKEN` (env) en **temps constant** (`hash_equals()`).
- **Fail-closed** : si `ADMIN_API_TOKEN` est vide (ou absent), **toutes** les requêtes renvoient `403`. C'est l'état par défaut — un déploiement qui n'a pas configuré l'env n'expose pas la surface admin.

Génération d'un token :
```bash
openssl rand -hex 32
# ⇒ par ex. 4a1b… (64 caractères hex)
```

À renseigner dans `.env` :
```
ADMIN_API_TOKEN=4a1b...
```

### Réponses d'auth

```http
HTTP/1.1 403 Forbidden
Content-Type: application/json

{ "error": "Missing or malformed Authorization header." }
```

Les messages d'erreur 403 sont volontairement précis pour faciliter le debug côté Talend — la surface est censée n'être atteignable que par des callers déjà authentifiés réseau (allowlist IP au reverse-proxy recommandée).

---

## Endpoints

### `GET /admin/media/stats`

Tableau de bord media. Tous les compteurs viennent de la table `hxa.media` (base unique) : contrairement à `GET /admin/stats` (multi-bases), il n'y a **pas** de dégradation par section — si la base est injoignable, l'appel échoue via le gestionnaire d'erreurs admin.

Couvre : total de médias, en attente de validation, uploads **par jour** (courbe), **top 5 des pays** et **total de médias par pays**.

**Paramètres**

| Paramètre | Défaut | Sens |
|---|---|---|
| `days` | `30` | longueur de la courbe d'uploads par jour, bornée **1..366**. |

**Réponse (200)**

```json
{
  "total":     53120,
  "published": 51002,
  "rejected":  88,
  "pending":   2030,
  "perDay": {
    "days": 30,
    "from": "2026-05-23",
    "to":   "2026-06-21",
    "total": 1480,
    "series": [
      { "date": "2026-05-23", "count": 0 },
      { "date": "2026-05-24", "count": 61 }
    ]
  },
  "topCountries": [
    { "country": "FR", "name": "France",        "count": 21044 },
    { "country": "US", "name": "United States",  "count": 9032 },
    { "country": "ES", "name": "Spain",          "count": 4110 },
    { "country": "IT", "name": "Italy",          "count": 3897 },
    { "country": "DE", "name": "Germany",        "count": 2510 }
  ],
  "byCountry": [
    { "country": "FR", "name": "France",         "count": 21044 },
    { "country": "US", "name": "United States",   "count": 9032 }
  ],
  "withoutCountry": 1200
}
```

| Champ | Sens |
|---|---|
| `total` / `published` / `rejected` / `pending` | mêmes définitions que la section `media` de `GET /admin/stats` (`pending` = ni publié ni rejeté). |
| `perDay.series` | uploads par jour (`media.created_at`), **zero-fillé** : chaque jour de la fenêtre est présent, `count: 0` les jours sans upload. Courbe continue. |
| `perDay.total` | somme des uploads sur la fenêtre. |
| `topCountries` | 5 premiers pays par nombre de médias (code ISO 3166-1 alpha-2 + `name`), ordre décroissant. |
| `byCountry` | **tous** les pays avec leur nombre de médias, ordre décroissant (`topCountries` en est la tête). |
| `country` / `name` | code ISO et **nom** du pays, résolu en **un seul appel batch** à l'index Meili `countries`. `name` vaut `null` si le code est absent de l'index (ou Meili injoignable — les compteurs restent servis, seul le libellé manque). |
| `withoutCountry` | médias sans pays (`country_id NULL`, upload sans GPS) — exclus des buckets pays. |

> Les axes `created_at` et `country_id` sont désormais **indexés** (migration `2026_06_21_120000_add_media_stats_indexes.sql`) : la courbe par jour devient un range scan et la répartition par pays un parcours d'index ordonné. Pour des tendances **historiques précalculées** multi-domaines, voir `GET /admin/stats/trends`.

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/stats?days=90"
```

> `GET /admin/media/stats` est un **instantané live** (top pays = total all-time au moment de l'appel). Pour les **tendances pays dans le temps** (uploads par pays **par jour**), voir `GET /admin/media/stats/countries` ci-dessous.

---

### `GET /admin/media/stats/countries`

Tendances d'uploads de médias **par pays et par jour**. Contrairement à `GET /admin/media/stats` (live), ces séries sont **précalculées** une fois par jour par le worker `bin/platform-metrics-rollup.php` dans la table `hxa_bo.media_country_daily` — l'endpoint ne lit **que** `hxa_bo`, jamais `hxa.media`.

**Sélection des pays** (par ordre de priorité) :
1. `?country=FR,US` — liste CSV explicite de codes ISO 3166-1 alpha-2. Un code mal formé → **400**.
2. Aucun → les **top `?limit` pays** par uploads sur la fenêtre.

**Paramètres**

| Paramètre | Défaut | Sens |
|---|---|---|
| `days` | `30` | longueur de la fenêtre en jours, bornée **1..366**. |
| `limit` | `10` | nombre de pays quand `?country` est absent, borné **1..50** (ignoré si `?country` est fourni). |
| `country` | — | CSV de codes ISO ; force la sélection sur ces pays. |

**Réponse (200)**

```json
{
  "from": "2026-05-23",
  "to":   "2026-06-21",
  "days": 30,
  "countries": [
    {
      "country": "FR",
      "name":    "France",
      "total":   1200,
      "series": [
        { "date": "2026-05-23", "count": 0 },
        { "date": "2026-05-24", "count": 61 }
      ]
    }
  ]
}
```

| Champ | Sens |
|---|---|
| `countries[].series` | uploads du pays **par jour**, **zero-fillé** sur toute la fenêtre (courbe continue). |
| `countries[].total` | somme des uploads du pays sur la fenêtre. |
| `countries[].name` | nom résolu en **un seul appel batch** à l'index Meili `countries` (`null` si code absent / Meili injoignable). |

Les pays sont triés par `total` décroissant. Les médias **sans pays** (upload sans GPS) ne sont pas dans cette table — ils restent visibles via `withoutCountry` de `GET /admin/media/stats`.

> Source : table `hxa_bo.media_country_daily` (migration `2026_06_21_140000_create_media_country_daily.sql`), alimentée par le **même** worker quotidien que `GET /admin/stats/trends`. La série pour un pays jamais vu sur la fenêtre est entièrement à `0`.

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/stats/countries?country=FR,US,ES&days=90"
```

---

### `GET /admin/media/{hex}`

Fiche **360°** d'un media unique en **JSON:API 1.1** (cf. [Convention de format](#convention-de-format--détails-360-en-jsonapi)) : **strict superset** du public `GET /api/media/{id}` — exactement la même enveloppe et la même forme d'attributs (via `MediaResourceSerializer`), enrichie des champs masqués (toujours visibles) et des annexes admin-only fusionnées dans `data.attributes`.

| Paramètre | Emplacement | Description |
|-----------|-------------|-------------|
| `hex`     | path        | id du media, 32 hex minuscules (sans tirets). |

Le media est rendu **avec son propriétaire comme viewer**, ce qui fait remonter le bloc de modération owner-only (`flag` / `isRejected`).

**Robustesse** : seule la ligne `media` est requise — `404` (erreur JSON:API) si elle est absente (ou si le `hex` est malformé). Chaque bloc **annexe** est chargé dans son propre try/catch ; une base annexe injoignable dégrade **ce bloc** en `{ "error": "<raison>" }` au lieu de faire échouer toute la fiche (même esprit fail-soft que `GET /admin/stats`).

**Attributs ajoutés au superset public** (fusionnés dans `data.attributes`, sans écraser une clé déjà émise par le serializer public)

| Clé | Base | Table / source | Type en cas d'absence |
|------|------|----------------|-----------------------|
| `userId`         | `hxa`    | `media.user_id` (hex à plat — le public expose `author.id`) | — |
| `countryId` / `regionId` / `subregionId` | `hxa` | ids géo bruts | `null` |
| `flags`          | `hxa`    | décomposition lisible de `media.flag` (bitmask) | `[]` |
| `impressionsCount` | `hxa`  | `media_stats.impressions` | `0` |
| `exif`           | `hxa_bo` | `media_exif` (JSON EXIF brut décodé) | `null` |
| `fileMeta`       | `hxa_bo` | `media_meta` (mime/taille/dimensions source/marque/modèle) | `null` |
| `perceptualHash` | `hxa_bo` | `media_perceptual_hash` (16 hex réassemblés depuis les 4 shards) | `null` |
| `describeQueue`  | `work`   | `media_to_describe` (`{ "inQueue": bool }`) | — |

Le champ `flag` est le bitmask de modération brut ; `flags` en donne la décomposition lisible (`illegal` 1, `violent` 2, `sexual` 4, `selfie` 8, `screenshot` 16, `ai_generated` 32).

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Accept: application/vnd.api+json" \
  "http://hydrogen.dev.com/admin/media/4f3c1a2b5d6e7f8091a2b3c4d5e6f700"
```

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "medias",
    "id": "4f3c1a2b-5d6e-7f80-91a2-b3c4d5e6f700",
    "attributes": {
      "type": "photo",
      "name": "…",
      "blurHash": "…",
      "latitude": 48.85, "longitude": 2.35,
      "openLocationCode": "8FW4V75V+8Q",
      "width": 1920, "height": 1080,
      "orientation": "landscape",
      "isPublished": true,
      "flag": 0,
      "isRejected": false,
      "stats": { "likes": 12, "dislikes": 0, "views": 340, "comments": 3 },
      "hashtags": [ { "slug": "paris", "display": "Paris" } ],
      "author":  { "id": "…", "username": "…", "displayName": "…", "level": 4 },
      "country": { "id": "fr", "name": "France", "slug": "france" },

      "userId": "…",
      "countryId": "FR", "regionId": "FR-IDF", "subregionId": null,
      "flags": [],
      "impressionsCount": 980,
      "exif":    { "Make": "Canon", "Model": "EOS R6" },
      "fileMeta": { "mimeType": "image/jpeg", "sizeBytes": 4823100, "width": 6000, "height": 4000, "cameraBrand": "Canon", "cameraModel": "EOS R6" },
      "perceptualHash": "f0e1d2c3b4a59687",
      "describeQueue": { "inQueue": false }
    }
  }
}
```

```json
// 404 — media inexistant (erreur JSON:API)
{ "jsonapi": { "version": "1.1" }, "errors": [ { "status": "404", "title": "Media not found" } ] }
```

---

### `POST /admin/media/{hex}/reindex`

Re-pousse un media unique dans Meilisearch, en relisant la DB (media + description + stats + hashtags) via [MediaIndexService::reindex()](../src/Domain/Media/MediaIndexService.php).

À appeler par Talend dès qu'un script SQL mute un media (`is_published`, `score`, description AI, etc.) ou manuellement pour résoudre une drift entre DB et index.

**Path params**
- `hex` : id du media en 32 hex (format `media.id` BINARY(16) → hex lowercase).

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "reindexed", "mediaId": "<hex>" }` | document Meili mis à jour |
| `200` | `{ "status": "removed",   "mediaId": "<hex>" }` | media supprimé en DB depuis → le doc Meili stale est purgé |
| `400` | `{ "error": "Invalid media id." }` | hex mal formé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/reindex"
```

---

### `POST /admin/media/reindex-all`

Backfill complet de l'index Meili `media` par **lots keyset-paginés**. Chaque appel traite UN batch et renvoie le curseur du suivant. Le client (Talend / Postman) boucle jusqu'à `done = true`.

Pagination par clé primaire BINARY(16) ASC : pas de drift offset, robuste aux insertions/suppressions concurrentes.

**Query params**

| Param | Type | Défaut | Min | Max |
|---|---|---|---|---|
| `cursor` | hex (32 chars) | `null` (début) | — | — |
| `batchSize` | int | `200` | `1` | `1000` |

`cursor` exclu : passer l'id du dernier média traité par l'appel précédent. Vide ou absent ⇒ on part du début.

**Réponse (200)**

```json
{
  "processed":  198,
  "removed":    2,
  "failed": [
    { "mediaId": "a1b2…", "error": "Meilisearch: connection refused" }
  ],
  "lastId":     "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "nextCursor": "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "done":       false,
  "totalAll":   12_487,
  "durationMs": 3421
}
```

| Champ | Sens |
|---|---|
| `processed` | médias indexés avec succès dans ce batch |
| `removed`   | rows manquants en DB (déjà supprimés) dont le doc Meili stale a été purgé |
| `failed`    | liste des erreurs par-média — **n'interrompt pas le batch** |
| `lastId`    | dernier id parcouru dans le batch (`null` si batch vide) |
| `nextCursor`| à passer en `?cursor=` au prochain appel ; `null` quand `done=true` |
| `done`      | `true` quand le batch a renvoyé moins de rows que demandé → fin du backfill |
| `totalAll`  | `COUNT(*) media` au moment de l'appel — pour reporter une progression côté caller |
| `durationMs`| latence serveur du batch |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid cursor." }` |
| `400` | `{ "error": "Invalid batchSize." }` |
| `403` | `{ "error": "..." }` |

**Pattern d'utilisation (Talend / curl boucle)**

```bash
cursor=""
while : ; do
  resp=$(curl -s -X POST \
    -H "Authorization: Bearer $ADMIN_API_TOKEN" \
    "http://hydrogen.dev.com/admin/media/reindex-all?batchSize=500&cursor=$cursor")
  echo "$resp" | jq '{processed, removed, done, durationMs}'

  done=$(echo "$resp" | jq -r '.done')
  cursor=$(echo "$resp" | jq -r '.nextCursor // empty')

  [ "$done" = "true" ] && break
done
```

Côté Talend : un tLoop sur l'appel HTTP, condition de sortie `done == true`, variable de contexte `cursor` mise à jour entre itérations.

---

### `POST /admin/media/backfill-geo`

Backfill **massif** des 4 colonnes administratives (`city_id`, `subregion_id`, `region_id`, `country_id`) sur les médias qui ont des coordonnées GPS mais au moins un des 4 ids manquant.

Pour chaque ligne candidate, l'endpoint :

1. Appelle la procédure stockée `geo.locate(latitude, longitude)` via [GeoLookupService](../src/Domain/Media/GeoLookupService.php).
2. **Écrase** les 4 colonnes avec ce que `locate` renvoie (peut inclure des `NULL` partiels — toujours cohérent avec la résolution la plus fraîche).
3. Bump `updated_at`.
4. Réindexe le média via [MediaIndexService::reindex()](../src/Domain/Media/MediaIndexService.php) pour que les 4 blocs hiérarchiques (`city`/`subregion`/`region`/`country`) apparaissent immédiatement sur les listings publics.

Pagination keyset sur la PK BINARY(16), même pattern que `reindex-all`. Boucle Talend / Postman jusqu'à `done = true`.

**Sélection des candidats (SQL)**

```sql
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
  AND (country_id IS NULL OR region_id IS NULL
       OR subregion_id IS NULL OR city_id IS NULL)
```

**Query params**

| Param | Type | Défaut | Min | Max |
|---|---|---|---|---|
| `cursor` | hex (32 chars) | `null` (début) | — | — |
| `batchSize` | int | `200` | `1` | `1000` |

**Réponse (200)**

```json
{
  "processed":       200,
  "updated":         171,
  "skipped":         27,
  "failed": [
    { "mediaId": "a1b2…", "error": "SQLSTATE[…]" }
  ],
  "lastId":          "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "nextCursor":      "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "done":            false,
  "totalCandidates": 4_812,
  "durationMs":      6125
}
```

| Champ | Sens |
|---|---|
| `processed` | nombre de rows parcourus dans ce batch |
| `updated`   | rows dont les 4 ids ont été ré-écrits avec succès |
| `skipped`   | `locate(lat,lng)` n'a rien matché (point hors polygones connus) — row laissée intacte, sera retentée au prochain run si `geo_v2` s'enrichit |
| `failed`    | erreurs par-média (UPDATE / reindex) — **n'interrompent pas le batch** |
| `lastId`    | dernier id parcouru dans le batch (`null` si batch vide) |
| `nextCursor`| à passer en `?cursor=` au prochain appel ; `null` quand `done=true` |
| `done`      | `true` quand le batch a renvoyé moins de rows que demandé → fin du backfill |
| `totalCandidates` | snapshot `COUNT(*)` des rows encore éligibles au moment de l'appel — décroît au fil de la progression |
| `durationMs`| latence serveur du batch (inclut les appels Meili) |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid cursor." }` |
| `400` | `{ "error": "Invalid batchSize." }` |
| `403` | `{ "error": "..." }` |

**Pattern d'utilisation (curl boucle)**

```bash
cursor=""
while : ; do
  resp=$(curl -s -X POST \
    -H "Authorization: Bearer $ADMIN_API_TOKEN" \
    "http://hydrogen.dev.com/admin/media/backfill-geo?batchSize=500&cursor=$cursor")
  echo "$resp" | jq '{processed, updated, skipped, totalCandidates, done, durationMs}'

  done=$(echo "$resp" | jq -r '.done')
  cursor=$(echo "$resp" | jq -r '.nextCursor // empty')

  [ "$done" = "true" ] && break
done
```

> Remarque : `skipped` reste positif tant que `geo_v2` n'a pas de polygones pour la zone (ex. Tokyo, NYC). Ces médias seront automatiquement re-sélectionnés au prochain appel de l'endpoint.

---

### `PUT /admin/media/{hex}/published`

Mute le drapeau de publication d'un media et propage les side-effects techniques. **Hydrogen ne juge pas** de la pertinence du flip — Talend a déjà tranché. C'est l'endpoint que le pipeline IA appelle après avoir généré la description.

**Body (JSON)**

```json
{ "isPublished": true }
```

`isPublished` est obligatoire, doit être un booléen strict (`true` ou `false`, pas `"true"` ni `1`).

**Comportement par transition**

| Transition | UPDATE `media` | DELETE `work.media_to_describe` | Notif followers | Reindex Meili |
|---|---|---|---|---|
| `none` (déjà à l'état demandé) | non | non | non (jamais de fake "X a publié" sur un republish toggle) | non |
| `publish` (0 → 1) | oui | oui | oui (`media.published` à tous les followers du créateur) | oui |
| `unpublish` (1 → 0) | oui | non (la description reste, pas un retour en arrière du pipeline) | non | oui |

La notif `media.published` est dispatchée via le système existant : elle honore la préférence `inApp` de chaque follower (un follower qui a opt-out reçoit `null` et n'est pas comptabilisé dans `notificationsSent`). La fenêtre de dedup (`NOTIFICATION_DEDUP_WINDOW_MINUTES`, défaut 5min) collapse les republish toggles rapides sur le même media en une seule ligne de feed.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "mediaId": "<hex>", "isPublished": true, "transition": "publish", "notificationsSent": 142, "notificationsFailed": 0 }` | flip 0→1 OK, 142 followers notifiés |
| `200` | `{ "status": "ok", "mediaId": "<hex>", "isPublished": true, "transition": "none", "notificationsSent": 0, "notificationsFailed": 0 }` | déjà publié, no-op idempotent |
| `200` | `{ "status": "ok", "mediaId": "<hex>", "isPublished": false, "transition": "unpublish", "notificationsSent": 0, "notificationsFailed": 0 }` | dépublié (modération) |
| `400` | `{ "error": "Body must be JSON object with 'isPublished' boolean." }` | body mal formé |
| `404` | `{ "error": "Media not found." }` | media absent en DB |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Publier (cas standard pipeline IA)
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isPublished": true}' \
  http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/published

# Dépublier (modération)
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isPublished": false}' \
  http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/published
```

**Notes**

- Le compteur `notificationsFailed` regroupe les échecs d'insert per-follower (DB lock, blip…). Chaque échec individuel est silencieux côté logs — on préfère que le fan-out aille jusqu'au bout que d'avorter à la première transient. Si ce nombre n'est pas zéro, Talend peut journaliser et relancer la commande (idempotente, transition=none donc pas de double notif).
- Les transitions `none` ne touchent ni DB ni Meili ni followers — aucun coût.
- Le dispatch des notifs respecte la dedup-window (cf. `NOTIFICATION_DEDUP_WINDOW_MINUTES`) : si vous re-publish/unpublish/re-publish le même media dans la fenêtre, la ligne notification existante est bumpée plutôt que dupliquée.

---

### `GET /admin/media/{hex}/base64`

Renvoie un thumbnail d'un media existant, redimensionné à la volée par Glide et encodé en base64 (data URI). À utiliser pour embarquer une miniature directement dans une payload externe (prompt LLM, e-mail, rapport, etc.) sans avoir à fetcher le binaire puis l'encoder soi-même côté caller.

**Comportement**

- Source : WebP canonique `MEDIA_STORAGE_PATH/AA/BB/CC/<hex>.webp`.
- Resize : `w = h = MEDIA_ADMIN_BASE64_MAX_SIZE` (default `800`), `fit = max` → bestfit dans une boîte carrée, proportions **préservées**, image jamais upscalée. **Aucun des deux côtés** ne dépasse la borne : un media portrait est donc plafonné en hauteur aussi, pas seulement en largeur (un media déjà ≤ max retourne ses dimensions d'origine).
- Format de sortie : WebP (héritage de la source — pas d'option de transcodage exposée côté caller, on optimise pour le poids du data URI).
- Cache : partagé avec `/media/{hex}.{ext}` public via Glide → les appels suivants avec la même `MEDIA_ADMIN_BASE64_MAX_SIZE` sont servis depuis disque (sub-100 ms typique).

**Path params**
- `hex` : id du media en 32 hex lowercase.

**Réponse (200)**

```json
{
  "status":  "ok",
  "mediaId": "01a3471992e44c60a8f08321f713635a",
  "maxSize": 800,
  "image":   "data:image/webp;base64,UklGRmgoAQBXRUJQVlA4WAo..."
}
```

| Champ | Sens |
|---|---|
| `mediaId` | echo du hex demandé |
| `maxSize` | valeur effective de l'env `MEDIA_ADMIN_BASE64_MAX_SIZE` au moment de l'appel — borne max de chaque côté du thumbnail, pour que le caller sache à quoi correspond le data URI sans introspect |
| `image`   | data URI complet (`data:<mime>;base64,<…>`) directement utilisable dans `<img src=…>` ou un attribut JSON tiers |

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Media not found." }` | row absente en DB |
| `404` | `{ "error": "Media file not found on disk." }` | row présente mais WebP source manquant (incohérence DB/disque) |
| `500` | `{ "error": "Image processing failed: …" }` | exception Glide / Flysystem non récupérable |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/01a3471992e44c60a8f08321f713635a/base64" \
  | jq -r .image \
  | head -c 80
# data:image/webp;base64,UklGRmgoAQBXRUJQVlA4WAo...
```

**Notes**

- Pas de query param accepté — la largeur max est fixée côté serveur via env pour borner la taille du payload (les data URI dépassant quelques centaines de KB sont contre-productifs).
- Pour changer la largeur en prod sans redéployer : modifier l'env et relancer le pool PHP-FPM. Le cache Glide existant n'est pas purgé automatiquement — les vieilles dérivées resteront jusqu'à wipe manuel de `MEDIA_CACHE_PATH`.

---

### `POST /admin/media/describe`

Ingestion de l'enrichissement produit par le pipeline IA (description) pour **un** média. Le pipeline émet un document JSON autonome par média, donc l'id voyage **dans le corps**, pas dans l'URL.

Sémantique de **remplacement intégral** : le pipeline est propriétaire de l'enrichissement complet, on écrase l'existant (jamais de merge partiel). Les quatre écritures partagent la connexion `hxa` et tournent dans **une seule transaction** — un enrichissement partiel ne peut donc jamais atterrir. Le réindex Meili est best-effort, **après** le commit (un incident d'index ne doit pas annuler une écriture MySQL committée).

**Body (JSON)**

```json
{
  "id":          "b086801b-46b3-4cdc-b3b9-6ed26c132d5d",
  "flag":        8,
  "focus":       ["city", "experience", "nightlife", "tourism"],
  "title":            "Vue nocturne sur la Tour Eiffel depuis un ponton fluvial",
  "meta_title":       "Tour Eiffel nocturne depuis un ponton fluvial",
  "meta_description": "Découvrez la Tour Eiffel illuminée vue depuis la Seine…",
  "description":      "Cette image captée…",
  "objects": [
    { "name": "Tour Eiffel",  "probability": 1.0 },
    { "name": "Ciel nocturne", "probability": 0.9 }
  ]
}
```

| Champ | Sens / destination |
|---|---|
| `id` | UUID **dashé** du média (pas le hex 32). 404 si la row n'existe pas. |
| `flag` | Masque binaire de modération → `media.flag`. `0` = valide, `1` = illégal, `2` = violent, `4` = sexuel, `8` = selfie, `16` = screenshot, `32` = généré par IA. Indexé dans Meili (filterable), mais exposé dans l'API **au seul auteur** du média (gating dans le serializer). |
| *(dérivé)* | `media.is_rejected` = `(flag & ~8) > 0` : rejeté dès qu'un motif **autre** que selfie est levé. Un selfie seul (`flag = 8`) n'est **pas** rejeté. |
| *(dérivé)* | `media.is_published` = `!is_rejected` : le verdict pilote la publication. Un média non rejeté (selfie inclus) est **publié** (`1`) ; un média rejeté est **dépublié** (`0`). L'étape describe fait donc aussi office de barrière de publication. |
| `title` | → `media_description.title` (nullable). |
| `meta_title` | → `media_description.meta_title` (nullable). |
| `meta_description` | → `media_description.meta_description` (nullable). |
| `description` | → `media_description.description` (chaîne ; `""` accepté). |
| `focus` | Liste de `focus.name`. Résolus en ids puis écrits dans `media_focus` (DELETE + ré-INSERT). Les noms inconnus sont **silencieusement ignorés** et remontés dans `focusUnknown`. |
| `objects` | Liste `{name, probability}` → table `media_object` (DELETE + ré-INSERT). |

Champs optionnels : `flag` défaut `0`, `focus`/`objects` défaut `[]`, `title`/`meta_title`/`meta_description` défaut `null`, `description` défaut `""`.

**Réponse (200)**

```json
{
  "status":        "ok",
  "mediaId":       "b086801b46b34cdcb3b96ed26c132d5d",
  "flag":          8,
  "isRejected":    false,
  "isPublished":   true,
  "status":        "published",
  "focusMatched":  ["city", "experience"],
  "focusUnknown":  ["nightlife", "tourism"],
  "objectsStored": 2
}
```

| Champ | Sens |
|---|---|
| `mediaId` | hex 32 du média enrichi |
| `flag` | echo du masque appliqué |
| `isRejected` | décision dérivée effectivement persistée |
| `isPublished` | état de publication appliqué (`!isRejected`) |
| `status` | étape terminale du cycle de vie posée : `published` (non rejeté) ou `rejected` |
| `focusMatched` | noms de focus résolus en ids (liés) |
| `focusUnknown` | noms de focus absents de la table `focus` (ignorés) |
| `objectsStored` | nombre d'objets écrits dans `media_object` |

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Body must be a JSON object." }` | corps vide ou JSON invalide |
| `400` | `{ "error": "Field 'id' is required (UUID string)." }` | `id` absent / vide |
| `400` | `{ "error": "Field 'id' is not a valid UUID." }` | `id` mal formé |
| `400` | `{ "error": "Field 'flag' must be a non-negative integer." }` | `flag` invalide |
| `400` | `{ "error": "..." }` | `focus` / `objects` / `description` / `title` mal typés |
| `404` | `{ "error": "Media not found." }` | aucun média pour cet `id` |
| `500` | `{ "error": "Failed to persist enrichment: …" }` | transaction rollback (l'enrichissement n'a rien écrit) |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"b086801b-46b3-4cdc-b3b9-6ed26c132d5d","flag":8,"focus":["city"],"title":"…","meta_title":"…","description":"…","objects":[{"name":"Tour Eiffel","probability":1.0}]}' \
  "http://hydrogen.dev.com/admin/media/describe"
```

**Notes**

- `flag` est indexé dans Meili (`filterableAttributes`) au même titre que `is_rejected` → après le premier déploiement, relancer `bin/media-meili-apply-settings.php` pour que les nouveaux attributs filtrables soient acceptés par l'index.

---

### Cycle de vie du traitement (`media.status`)

La colonne `media.status` matérialise l'avancement du traitement d'un média — l'état que le **propriétaire** sonde (polling) pour savoir « où en est mon upload ? ». Distinct de `is_published` (visibilité, pilotable à part via `PUT /admin/media/{hex}/published`) : les deux concordent sur les états terminaux mais `processing`/`failed` n'ont pas d'équivalent côté `is_published`.

| `status` | int | Posé par | Sens |
|---|---|---|---|
| `pending`    | `0` | upload | fichier stocké + mis en file `work.media_to_describe`, en attente du worker IA |
| `processing` | `1` | `POST /admin/media/{hex}/claim` | le worker a pris le média et l'analyse |
| `published`  | `2` | `POST /admin/media/describe` (verdict propre) | terminal succès, mis en ligne |
| `rejected`   | `3` | `POST /admin/media/describe` (flag rejetant) | terminal refus de modération |
| `failed`     | `4` | `POST /admin/media/{hex}/fail` | le worker a abandonné (erreur/timeout), **retryable** |

Transitions autorisées (gardées par `MediaStatus::canTransitionTo()`, sinon `409`) :

```
pending    → processing | published | rejected | failed
processing → published | rejected | failed
failed     → processing | published | rejected      (retry via claim)
published  → rejected                                (re-modération)
rejected   → processing | published                 (re-traitement)
```

Le slug `status` est exposé sur la ressource média publique (API JSON:API) ; les libellés traduits vivent dans `media.status.*` (`resources/lang/<locale>/media.php`).

> **Ops** : après déploiement, jouer la migration `2026_06_18_140000_backfill_media_status_lifecycle.sql` (backfill des lignes existantes depuis `is_published`/`is_rejected` + index `idx_media_status`). Aucun `ALTER` de colonne — `status` existait déjà.

---

### `POST /admin/media/{hex}/claim`

Le worker IA signale qu'il **commence** la description : `pending` (ou `failed` lors d'un retry) → `processing`. Permet à l'UI du propriétaire d'afficher « analyse en cours » au lieu d'un trou silencieux jusqu'au describe. Ne dé-file PAS `media_to_describe` (c'est describe / publish qui le font). Réindex Meili best-effort.

**Path params** — `hex` : id du média en 32 hex lowercase.

**Réponse (200)**

```json
{ "status": "ok", "mediaId": "<hex>", "state": "processing", "transition": "claim" }
```

| Status | Body | Sens |
|---|---|---|
| `200` | `… "transition": "claim"` | passage `→ processing` effectué |
| `200` | `… "transition": "none"` | déjà `processing`, no-op idempotent (retry worker) |
| `404` | `{ "error": "Media not found." }` | hex inconnu / mal formé |
| `409` | `{ "error": "Cannot claim a media in state '<state>'." }` | transition interdite (ex. média déjà `published`) |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/claim"
```

---

### `POST /admin/media/{hex}/fail`

Le worker IA **abandonne** le média (erreur d'inférence, timeout répété) : → `failed`. Distinct de `rejected` (verdict de modération) — `failed` est un échec **technique**, rien de mal sur le média. `is_published` n'est pas touché (un média `failed` n'a jamais été en ligne). Le média reste en file `media_to_describe` ; un nouveau `claim` le renvoie en `processing` pour un retry. Réindex Meili best-effort.

**Path params** — `hex` : id du média en 32 hex lowercase.

**Réponse (200)**

```json
{ "status": "ok", "mediaId": "<hex>", "state": "failed", "transition": "fail" }
```

| Status | Body | Sens |
|---|---|---|
| `200` | `… "transition": "fail"` | passage `→ failed` effectué |
| `200` | `… "transition": "none"` | déjà `failed`, no-op idempotent |
| `404` | `{ "error": "Media not found." }` | hex inconnu / mal formé |
| `409` | `{ "error": "Cannot fail a media in state '<state>'." }` | transition interdite (ex. média déjà `published`) |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/fail"
```

---

### `POST /admin/media/{hex}/recompute-stats`

Répare la dérive de `media_stats` pour **un** média en recalculant les compteurs dérivables depuis leurs tables source :

- `likes_count` / `dislikes_count` ← `COUNT` sur `media_reaction` (`value = 'like'` / `'dislike'`),
- `comments_count` ← commentaires **racine** non supprimés (`parent_id IS NULL AND deleted_at IS NULL`).

`views_count` / `impressions_count` **ne sont pas** recalculés : ils proviennent du pipeline compteurs (deltas append-only, sans lignes source), les re-dériver écraserait du trafic réel à zéro.

En temps normal ces compteurs sont tenus par les triggers (`media_reaction`) et par `MediaCommentService` (transactionnel). Cet endpoint est l'**unique** point qui UPDATE directement les colonnes — un outil de réparation hors-bande pour réaligner après un trigger manqué, une transaction commentaire avortée, un fix SQL manuel, etc. Après réparation, le média est repoussé dans Meili (best-effort) pour que l'index reflète les compteurs réparés.

**Path params**
- `hex` : id du media en 32 hex lowercase.

**Réponse (200)**

```json
{
  "status":  "ok",
  "mediaId": "01a3471992e44c60a8f08321f713635a",
  "before":  { "likes": 5, "dislikes": 1, "views": 1280, "impressions": 9931, "comments": 3 },
  "after":   { "likes": 6, "dislikes": 1, "views": 1280, "impressions": 9931, "comments": 4 },
  "changed": true
}
```

| Champ | Sens |
|---|---|
| `before` / `after` | snapshot des 5 compteurs avant / après recalcul (`views`/`impressions` reportés à l'identique) |
| `changed` | `true` si l'un des 3 compteurs dérivables a bougé (réparation effective) |

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Invalid media id." }` | hex mal formé |
| `404` | `{ "error": "Media not found." }` | aucune row pour ce média |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/01a3471992e44c60a8f08321f713635a/recompute-stats"
```

---

### `PUT /admin/media/{hex}/flag`

**Override manuel de la modération** par un humain. Le verdict est normalement posé automatiquement par le pipeline IA (`POST /admin/media/describe`) ; cet endpoint donne à un opérateur le levier pour corriger un faux positif / faux négatif. Le `flag` (bitmask) fourni **remplace** la valeur courante et tout l'état dépendant est re-dérivé **exactement** comme dans describe, dans une transaction `hxa` unique :

- `is_rejected` ← `(flag & ~8) > 0` (rejeté si flaggé pour autre chose qu'un selfie),
- `is_published` ← `!is_rejected`,
- `status` ← `rejected` si rejeté, sinon `published`.

Réindex Meili best-effort après le commit.

Bits combinables : `1` illégal, `2` violent, `4` sexuel, `8` selfie, `16` capture d'écran, `32` généré par IA. `flag = 0` ⇒ média valide (publié).

**Path params** — `hex` : id du média en 32 hex lowercase.

**Body**

| Champ | Type | Requis | Sens |
|---|---|---|---|
| `flag` | int ≥ 0 | oui | nouveau bitmask de modération (0 = valide) |

**Réponse (200)**

```json
{
  "status":      "ok",
  "mediaId":     "d26d1600cde54bd095e09f8b68ace05f",
  "flag":        4,
  "isRejected":  true,
  "isPublished": false,
  "mediaStatus": "rejected"
}
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Body must be JSON object with 'flag' non-negative integer." }` | corps absent / `flag` manquant ou invalide |
| `404` | `{ "error": "Media not found." }` | hex inconnu / mal formé |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "flag": 4 }' \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/flag"
```

---

### `DELETE /admin/media/{hex}`

**Hard-delete** d'un média par la modération, quel que soit son propriétaire (le même service que `DELETE /api/users/me/media/{mediaId}`, jusqu'ici réservé au propriétaire). Supprime le WebP publié + le compagnon blurhash, l'original archivé, toutes les lignes des tables annexes (`media_meta` / `media_exif` / `media_perceptual_hash`), la ligne principale `hxa.media`, et best-effort le document Meilisearch. Les erreurs disque / index n'interrompent pas la suppression de la ligne DB (source de vérité). **Irréversible.**

**Path params** — `hex` : id du média en 32 hex lowercase.

**Réponse (200)**

```json
{ "status": "deleted", "mediaId": "d26d1600cde54bd095e09f8b68ace05f" }
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Media not found." }` | hex inconnu / mal formé |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f"
```

---

### `POST /admin/jobs/cleanup-orphan-files`

Récupère les fichiers media publiés dont la row DB a disparu (média hard-deleté hors-bande, upload avorté ayant écrit le WebP avant la row, purge SQL manuelle…). Scanne le root publié (`MEDIA_STORAGE_PATH`), ne retient que les WebP **primaires** `<hex>.webp`, batch les ids et interroge la DB : un fichier sans row est un orphelin. Le supprimer retire aussi son compagnon `-blurhash.webp`.

**Sécurité — dry-run par défaut.** Rien n'est supprimé sans `?delete=1`. Un dry-run rapporte exactement ce qu'une vraie passe supprimerait.

Le scan est **plafonné** (`?limit`, défaut 1000, max 50000 fichiers examinés par appel) pour rester borné sur un arbre à plusieurs millions de fichiers. Quand `capped = true`, la borne a été atteinte — relancer pour continuer (en mode `delete`, l'ensemble des orphelins rétrécit au fil des suppressions).

Les **originaux** (root séparé, extension source) ne sont pas touchés : leur extension n'est pas déductible de l'id seul, et ce sont des archives froides — hors périmètre du nettoyage du root publié.

**Query params**
- `limit` (int, optionnel) : 1..50000 fichiers à examiner. Défaut `1000`.
- `delete` (bool, optionnel) : `1` / `true` pour réellement supprimer. Défaut off (dry-run).

**Réponse (200)**

```json
{
  "status":       "ok",
  "dryRun":       true,
  "scanned":      1000,
  "orphansFound": 7,
  "deleted":      0,
  "capped":       true,
  "sample":       ["01a3471992e44c60a8f08321f713635a", "..."],
  "durationMs":   142
}
```

| Champ | Sens |
|---|---|
| `dryRun` | `true` tant que `?delete=1` n'est pas passé |
| `scanned` | nombre de WebP primaires examinés |
| `orphansFound` | fichiers sans row DB |
| `deleted` | fichiers réellement supprimés (`0` en dry-run) |
| `capped` | `true` si la borne `limit` a été atteinte (il peut rester des orphelins) |
| `sample` | aperçu des ids orphelins (max 100) |

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Invalid limit." }` | `limit` non entier ou hors 1..50000 |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Dry-run (audit)
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/jobs/cleanup-orphan-files?limit=5000"

# Suppression effective
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/jobs/cleanup-orphan-files?limit=5000&delete=1"
```

---

### `POST /admin/jobs/flush/{job}`

Force un **drain immédiat** d'un tampon habituellement vidé par cron, sans accès shell (Postman / Talend). L'endpoint n'est qu'un déclencheur HTTP : il appelle exactement le même `FlushService::flush()` que les entry-points `bin/*-flush.php` (aucune logique dupliquée).

**`{job}`** (la route restreint déjà aux valeurs valides) :

| `job` | Service(s) | Équivaut au cron |
|---|---|---|
| `counters` | media **+** user (les deux en un appel) | `bin/media-counters-flush.php` + `bin/user-counters-flush.php` |
| `notifications` | digest OneSignal | `bin/notifications-flush.php` |
| `tracking` | tampon de clics d'affiliation | `bin/tracking-flush.php` |

Idempotent par nature : un flush sur un tampon vide renvoie des compteurs à zéro. Un échec de transaction laisse remonter l'exception → `500` JSON plat (l'opérateur relance), comme un cron qui sortirait en code 2.

**Réponse (200)**

```json
{
  "job": "counters",
  "summary": {
    "media": { "drained": 0, "bumped": 0, "deletedEvents": 0, "gcRows": 0 },
    "user":  { "drained": 0, "bumped": 0, "deletedEvents": 0, "gcRows": 0 }
  }
}
```

```json
{ "job": "notifications", "summary": { "recipients": 3, "pushed": 3, "skipped": 0, "failed": 0 } }
```

```json
{ "job": "tracking", "summary": { "drained": 42, "bumped": 12, "deletedEvents": 42 } }
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Unknown job '…'. Expected: counters, notifications, tracking." }` | garde défensive (la regex de route 404 avant, en principe) |
| `403` | `{ "error": "..." }` | auth KO |
| `500` | `{ "error": "..." }` | échec de flush (transaction) — relancer |

**Exemples curl**

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/jobs/flush/counters"

curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/jobs/flush/tracking"
```

---

### `GET /admin/jobs/describe-queue`

**Observabilité de la file IA** `work.media_to_describe` (FIFO des médias en attente de description, consommée par un worker hors-bande). Jusqu'ici l'admin était aveugle sur ce backlog. Lecture seule, un seul round-trip vers la base `work`.

**Réponse (200)**

```json
{
  "size":             42,
  "oldestEnqueuedAt": "2026-06-19T08:12:00+00:00",
  "oldestAgeSeconds": 1834,
  "enqueuedLastHour": 7,
  "enqueuedLast24h":  120
}
```

| Champ | Sens |
|---|---|
| `size` | profondeur de la file (médias en attente) |
| `oldestEnqueuedAt` | timestamp de la tête de file (`null` si vide) |
| `oldestAgeSeconds` | âge de la tête en secondes — grandit ⇒ le worker décroche |
| `enqueuedLastHour` / `enqueuedLast24h` | taux d'arrivée récent, à comparer au débit du worker |

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/jobs/describe-queue"
```

---

### `POST /admin/jobs/describe-queue/requeue`

**Réinjecte un média bloqué** dans la file IA (worker mort en plein `processing`, ligne de queue perdue, retry après `failed`). `INSERT IGNORE` idempotent + remise de `media.status` à `pending` (sauf s'il l'est déjà) pour que le cycle de vie reste cohérent et qu'un futur `claim` soit légal.

Refuse un média en état **terminal** (`published` / `rejected`) : il a déjà un verdict, le redécrire serait une régression (`409`).

**Body**

| Champ | Type | Requis | Sens |
|---|---|---|---|
| `mediaId` | string (32 hex) | oui | id du média à réinjecter |

**Réponse (200)**

```json
{
  "status":         "ok",
  "mediaId":        "d26d1600cde54bd095e09f8b68ace05f",
  "previousStatus": "failed",
  "mediaStatus":    "pending",
  "alreadyQueued":  false
}
```

| Champ | Sens |
|---|---|
| `previousStatus` | état du média avant requeue |
| `mediaStatus` | toujours `pending` après requeue |
| `alreadyQueued` | `true` si la ligne était déjà dans la file (réinjection no-op) |

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Body must be JSON object with 'mediaId' 32-hex string." }` | corps absent / `mediaId` manquant ou mal formé |
| `404` | `{ "error": "Media not found." }` | id inconnu |
| `409` | `{ "error": "Cannot requeue a media in terminal state '<state>'." }` | média `published` / `rejected` |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "mediaId": "d26d1600cde54bd095e09f8b68ace05f" }' \
  "http://hydrogen.dev.com/admin/jobs/describe-queue/requeue"
```

---

### `POST /admin/cache/purge`

Vide le cache de dérivées Glide (`MEDIA_CACHE_PATH`). Le cache est de la donnée purement dérivée, régénérée paresseusement à la prochaine requête image — il est donc toujours sûr à vider (au coût d'un recalcul ponctuel sur les hits suivants). Cas d'usage : récupérer du disque, ou forcer la régénération après un changement de config Glide / de source. Le répertoire racine est préservé (recréé si absent) : seul son **contenu** est supprimé.

**Sécurité — dry-run par défaut.** Sans `?delete=1`, l'endpoint ne fait que **mesurer** (nombre de fichiers + octets) ce qu'il supprimerait.

**Query params**
- `delete` (bool, optionnel) : `1` / `true` pour réellement vider. Défaut off (dry-run).

**Réponse (200)**

```json
{
  "status":     "ok",
  "dryRun":     true,
  "files":      48213,
  "bytes":      1734209922,
  "deleted":    0,
  "durationMs": 5310
}
```

| Champ | Sens |
|---|---|
| `dryRun` | `true` tant que `?delete=1` n'est pas passé |
| `files` / `bytes` | volume trouvé dans le cache |
| `deleted` | fichiers réellement supprimés (`0` en dry-run) |

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Mesure
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/cache/purge"

# Purge effective
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/cache/purge?delete=1"
```

---

### `GET /admin/stats`

Compteurs globaux pour un tableau de bord back-office. Agrège **4 sections** réparties sur les 3 bases de l'app : `user` / `media` (base `hxa`), la file du pipeline IA `media_to_describe` (base `work`), la file de modération `report` (base `hxa_bo`).

Chaque section est collectée **isolément** : si une base est injoignable, seule sa section est remplacée par `{ "error": "<raison>" }` — le reste du tableau de bord répond quand même. L'endpoint renvoie **toujours 200** ; la présence d'une clé `error` dans une section EST le signal de santé.

Aucun paramètre.

**Réponse (200)**

```json
{
  "users":         { "total": 1284, "confirmed": 1190, "verified": 12, "banned": 3, "deleted": 7 },
  "media":         { "total": 53120, "published": 51002, "rejected": 88, "pending": 2030 },
  "describeQueue": { "size": 2030, "oldestEnqueuedAt": "2026-06-18T09:12:44+00:00" },
  "reports":       { "total": 145, "pending": 9, "resolved": 136 }
}
```

| Champ | Sens |
|---|---|
| `users.confirmed` | comptes avec `confirmed_at` renseigné. |
| `users.banned` | bannissement **actif** (`banned_until > NOW()`). |
| `users.deleted` | comptes soft-deleted RGPD (en grâce, pas encore purgés). |
| `media.pending` | ni publié ni rejeté (en attente du verdict describe/modération). |
| `describeQueue.oldestEnqueuedAt` | âge de la tête de file FIFO (`null` si vide) ; un écart croissant à « maintenant » = worker en retard. |
| `reports.pending` | backlog de modération ouvert. |

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/stats"
```

> `GET /admin/stats` est un **instantané live** (compteurs au moment de l'appel). Pour suivre des **tendances dans le temps**, voir `GET /admin/stats/trends` ci-dessous, qui sert des séries **journalières** précalculées.

---

### `GET /admin/stats/trends`

Séries **agrégées par jour** des KPI de la plateforme, pour suivre les tendances multi-domaines (inscriptions, uploads, signalements, croissance de la base…). Lecture seule.

Les valeurs ne sont **pas** calculées à la volée : elles sont précalculées une fois par jour par le worker `bin/platform-metrics-rollup.php` dans la table `hxa_bo.platform_metric_daily`. Cet endpoint ne lit donc **que** `hxa_bo` — il ne touche jamais les tables de production `user` / `media` (c'est tout l'intérêt : le `COUNT(*)` lourd est déplacé hors du chemin requête, exécuté une seule fois en heure creuse).

**Deux familles de métriques** (champ `kind`) :

- `flow` — un **nombre d'évènements survenus ce jour-là** (ex. `users.registered`), dérivé d'une colonne date indexée via `GROUP BY DATE(...)`. Série **continue** : les jours sans évènement sont renvoyés à `0`. Historiquement reconstructible (le worker re-plie une fenêtre glissante, cf. `PLATFORM_METRICS_LOOKBACK_DAYS`).
- `snapshot` — un **stock** compté une fois par exécution (ex. `users.total`). La série ne contient **que** les jours déjà enregistrés : un trou = un jour où le worker n'a pas tourné (ce n'est PAS un `0`). Non reconstructible dans le passé — la série se construit point par point à partir de la 1re exécution.

**Métriques disponibles**

| Métrique | Domaine | `kind` | Sens |
|---|---|---|---|
| `users.registered` | users | flow | inscriptions du jour (`joined_at`). |
| `users.confirmed` | users | flow | e-mails confirmés le jour (`confirmed_at`). |
| `users.deleted` | users | flow | soft-deletes RGPD du jour (`deleted_at`). |
| `users.total` | users | snapshot | total de comptes. |
| `users.confirmed.total` | users | snapshot | comptes confirmés. |
| `users.verified.total` | users | snapshot | comptes badge bleu (`is_verified`). |
| `users.banned.active` | users | snapshot | bannissements **actifs** (`banned_until > NOW()`). |
| `media.uploaded` | media | flow | médias uploadés le jour (`created_at`). |
| `media.total` | media | snapshot | total de médias. |
| `media.published.total` | media | snapshot | médias publiés. |
| `media.rejected.total` | media | snapshot | médias rejetés. |
| `media.pending.total` | media | snapshot | médias en attente de verdict. |
| `reports.created` | reports | flow | signalements ouverts le jour (`created_at`). |
| `reports.pending.total` | reports | snapshot | backlog de modération ouvert. |
| `describe.queue.size` | describe | snapshot | taille de la file IA `media_to_describe`. |

**Paramètres**

| Paramètre | Défaut | Sens |
|---|---|---|
| `metric` | toutes | liste CSV de slugs (ex. `users.registered,media.uploaded`). Un slug inconnu → **400**. |
| `days` | `30` | longueur de la fenêtre en jours, bornée **1..366**. |

**Réponse (200)**

```json
{
  "from": "2026-05-22",
  "to":   "2026-06-20",
  "days": 30,
  "metrics": {
    "users.registered": {
      "domain": "users",
      "kind":   "flow",
      "latest": { "date": "2026-06-20", "value": 42 },
      "series": [
        { "date": "2026-05-22", "value": 0 },
        { "date": "2026-05-23", "value": 17 }
      ]
    },
    "users.total": {
      "domain": "users",
      "kind":   "snapshot",
      "latest": { "date": "2026-06-20", "value": 987325 },
      "series": [
        { "date": "2026-06-19", "value": 987018 },
        { "date": "2026-06-20", "value": 987325 }
      ]
    }
  }
}
```

`latest` est le **dernier point connu** de la métrique, indépendamment de la fenêtre `days` (pratique pour afficher la valeur courante sans série). `null` tant qu'aucun rollup n'a tourné.

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/stats/trends?metric=users.registered,users.total&days=90"
```

**Worker** — planifier `php bin/platform-metrics-rollup.php` **une fois par jour** en heure creuse. Réexécution idempotente (UPSERT) ; fenêtre de re-pliage des métriques `flow` via `PLATFORM_METRICS_LOOKBACK_DAYS` (défaut 7). Migration : `database/migrations/2026_06_20_120000_create_platform_metric_daily.sql` (à jouer manuellement).

---

### `GET /admin/search/health`

Nombre de documents et état d'indexation de **chaque index Meilisearch** lu par l'app (`media`, `users`, `establishments`, `offers`, `brands`, `countries`, `regions`, `subregions`, `cities`). Permet de repérer une dérive d'indexation (un index `media` bloqué très en dessous du `COUNT(*)` MySQL, ou un index resté en `isIndexing`).

Un seul appel : l'endpoint global `/stats` de Meilisearch renvoie les stats de tous les index d'un coup, projetées sur la map *label logique → uid physique* (le uid est piloté par l'env et peut être versionné, ex. `offers` → `offers_v2`).

**Fail-soft** : si Meilisearch est injoignable, la réponse passe `reachable: false` et marque chaque index `available: false` plutôt que de renvoyer une 500 (un endpoint de santé ne doit pas masquer le signal). Réponse **toujours 200**.

Aucun paramètre.

**Réponse (200)**

```json
{
  "reachable":    true,
  "databaseSize": 13631488,
  "lastUpdate":   "2026-06-18T09:30:00.000000Z",
  "indexes": {
    "media":          { "index": "media_dev", "available": true, "numberOfDocuments": 51002, "isIndexing": false },
    "users":          { "index": "users_dev", "available": true, "numberOfDocuments": 1190, "isIndexing": false },
    "establishments": { "index": "establishments_dev", "available": true, "numberOfDocuments": 348221, "isIndexing": false },
    "offers":         { "index": "offers_v2", "available": true, "numberOfDocuments": 4120, "isIndexing": false },
    "brands":         { "index": "brands", "available": true, "numberOfDocuments": 612, "isIndexing": false },
    "countries":      { "index": "countries", "available": true, "numberOfDocuments": 250, "isIndexing": false },
    "regions":        { "index": "regions", "available": true, "numberOfDocuments": 5300, "isIndexing": false },
    "subregions":     { "index": "subregions", "available": true, "numberOfDocuments": 99, "isIndexing": false },
    "cities":         { "index": "cities", "available": true, "numberOfDocuments": 10000, "isIndexing": false }
  }
}
```

Quand un index n'existe pas encore côté Meili : `available: false`, `numberOfDocuments: null`, `isIndexing: null` (mais `reachable` reste `true`).

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/search/health"
```

---

### `GET /admin/health`

Sonde de **disponibilité de l'infrastructure** dont dépend l'app : les **4 bases MySQL** (`hxa`, `geo`, `hxa_bo`, `work`) et **Meilisearch**, en un seul appel. Pour savoir d'un coup d'œil si une techno est joignable.

Chaque base est testée par un `SELECT 1` chronométré ; Meili via son `/stats` global (mêmes données que `GET /admin/search/health`).

**Fail-soft** : les connexions sont résolues **paresseusement** et chaque sonde est isolée (`try/catch`) — une base injoignable n'apparaît qu'en `available: false` sur sa ligne, sans faire échouer l'endpoint. Réponse **toujours 200** ; les drapeaux `status` / `healthy` / `available` portent le signal (une 500 masquerait justement ce qu'on cherche à mesurer).

Aucun paramètre.

**Statut agrégé** (`status`) :

| Valeur | Sens |
|---|---|
| `ok` | toutes les bases **et** Meili joignables. |
| `degraded` | Meili down mais toutes les bases up (recherche dégradée, l'API cœur répond). |
| `down` | au moins une base injoignable (API cœur impactée). |

**Réponse (200)**

```json
{
  "status":  "ok",
  "healthy": true,
  "databases": {
    "healthy": true,
    "connections": {
      "hxa":    { "available": true, "latencyMs": 0.8, "error": null },
      "geo":    { "available": true, "latencyMs": 1.2, "error": null },
      "hxa_bo": { "available": true, "latencyMs": 0.9, "error": null },
      "work":   { "available": true, "latencyMs": 1.0, "error": null }
    }
  },
  "search": {
    "reachable": true,
    "databaseSize": 13631488,
    "lastUpdate": "2026-06-21T09:30:00.000000Z",
    "indexes": { "media": { "index": "media_dev", "available": true, "numberOfDocuments": 51002, "isIndexing": false } }
  }
}
```

| Champ | Sens |
|---|---|
| `databases.connections.<db>.available` | la base a répondu au `SELECT 1`. |
| `databases.connections.<db>.latencyMs` | temps du round-trip (résolution + ping) en ms, `null` si injoignable. |
| `databases.connections.<db>.error` | raison de l'échec (message PDO), `null` si OK. |
| `search` | bloc identique à `GET /admin/search/health` (détail par index). |

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/health"
```

---

### `GET /admin/audit`

Relecture **filtrée et paginée** du [journal d'audit](#journal-daudit) (`var/admin_audit.sqlite`). C'est le pendant lecture de ce qui est déjà écrit automatiquement : enquêter sur les actions mutantes passées sans ouvrir le fichier SQLite à la main.

Cet endpoint étant un `GET`, il **n'est pas lui-même audité** (lire le journal ne doit pas le polluer).

Tri implicite : `id DESC` (événements les plus récents d'abord). Pagination **keyset** mono-colonne sur `id` (auto-incrément strictement monotone) : reporter `nextCursor.id` dans `?cursorId=` pour la page suivante.

**Paramètres de requête** (tous optionnels, combinés en `AND`) :

| Param | Type | Description |
|---|---|---|
| `operator` | string | empreinte de token (`token_fp`, 12 hex) — cible **un** opérateur |
| `method` | string | verbe HTTP exact (`POST` / `PUT` / `PATCH` / `DELETE`) |
| `pathPrefix` | string | préfixe de chemin (match `LIKE` échappé, ex. `/admin/users`) |
| `status` | int | code HTTP final exact (ex. `403`) |
| `from` | ISO 8601 | borne basse `created_at` (incluse) |
| `to` | ISO 8601 | borne haute `created_at` (incluse) |
| `cursorId` | int | `id` de la dernière ligne de la page précédente (keyset) |
| `limit` | int | `1..200` (défaut `50`) |

Un `from`/`to` non vide mais illisible ⇒ `400`. `pathPrefix` neutralise les jokers `LIKE` (`%`, `_`).

**Réponse (200)**

```json
{
  "items": [
    {
      "id":        4821,
      "createdAt": "2026-06-18T09:30:00+00:00",
      "method":    "DELETE",
      "path":      "/admin/media/0a1b2c3d4e5f60718293a4b5c6d7e8f9",
      "query":     null,
      "status":    200,
      "ip":        "127.0.0.1",
      "tokenFp":   "9f86d081884c",
      "userAgent": "Insomnia/2023.5.8"
    }
  ],
  "nextCursor": { "id": 4821 }
}
```

`nextCursor` est `null` dès qu'une page renvoie moins de `limit` lignes (fin de scan).

**Exemple** (les actions `DELETE` d'un opérateur depuis une date) :

```bash
curl -s -G -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  --data-urlencode "method=DELETE" \
  --data-urlencode "operator=9f86d081884c" \
  --data-urlencode "from=2026-06-01T00:00:00Z" \
  "http://hydrogen.dev.com/admin/audit"
```

---

### `GET /admin/maintenance`

État courant du **mode maintenance**. Combine deux leviers, par priorité :

1. **Kill-switch env** `MAINTENANCE_MODE=true` — coupe l'app au niveau du déploiement, **prioritaire** et non désactivable au runtime (`lockedByEnv: true`).
2. **Toggle runtime** — fichier flag (`var/maintenance.flag`) basculé à chaud via `PUT /admin/maintenance`, **sans redéploiement**. Volontairement hors base de données : la coupure doit fonctionner même DB injoignable.

Quand la maintenance est active, **toute** requête reçoit un `503` + en-tête `Retry-After` : page HTML pour le web, document JSON:API pour `/api/*`, JSON plat pour `/admin/*`. Une allowlist d'IP (`allowedIps`, lue sur `REMOTE_ADDR`) permet aux ops de contourner la coupure.

Aucun paramètre.

**Réponse (200)**

```json
{
  "active":      true,
  "source":      "runtime",
  "lockedByEnv": false,
  "retryAfter":  900,
  "allowedIps":  ["1.2.3.4"],
  "since":       "2026-06-18T20:39:20+00:00",
  "reason":      "deploy v2"
}
```

| Champ | Description |
|---|---|
| `active` | `true` si l'app est en maintenance (par env OU runtime). |
| `source` | `"env"` (kill-switch), `"runtime"` (fichier flag) ou `"off"`. |
| `lockedByEnv` | `true` si `MAINTENANCE_MODE=true` force la coupure → le `PUT` est verrouillé (409). |
| `retryAfter` | Secondes annoncées dans `Retry-After` (`null` = défaut env appliqué au runtime). |
| `allowedIps` | IP autorisées à contourner la coupure. |
| `since` | ISO 8601 de la dernière activation runtime (`null` hors runtime). |
| `reason` | Note libre fournie à l'activation (audit). |

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/maintenance"
```

---

### `PUT /admin/maintenance`

Active ou désactive la maintenance **runtime** à chaud (écrit/supprime `var/maintenance.flag`). Le kill-switch env est prioritaire : tant que `MAINTENANCE_MODE=true`, cet endpoint répond **409** (on ne peut pas rouvrir le site par fichier flag quand l'env force la coupure).

**Body**

```json
{
  "enabled":    true,
  "retryAfter": 900,
  "allowedIps": ["1.2.3.4", "5.6.7.8"],
  "reason":     "deploy v2"
}
```

| Champ | Requis | Description |
|---|---|---|
| `enabled` | oui | `true` active, `false` désactive (idempotent). |
| `retryAfter` | non | Secondes pour `Retry-After` (>0). Omis ⇒ défaut env (`MAINTENANCE_RETRY_AFTER`). |
| `allowedIps` | non | Allowlist de bypass (remplace, ne fusionne pas avec l'env). |
| `reason` | non | Note libre conservée dans le flag. |

Sur `enabled: false`, les autres champs sont ignorés.

**Réponses**

- `200` — même forme que `GET /admin/maintenance` (état après bascule).
- `400` — `{ "error": "Body must be JSON object with 'enabled' boolean." }`
- `409` — `{ "error": "Maintenance is forced by MAINTENANCE_MODE env; runtime toggle is locked." }`

**Exemples**

```bash
# Activer (avec bypass ops + raison)
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled":true,"retryAfter":900,"allowedIps":["1.2.3.4"],"reason":"deploy v2"}' \
  "http://hydrogen.dev.com/admin/maintenance"

# Désactiver
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled":false}' \
  "http://hydrogen.dev.com/admin/maintenance"
```

---

### `GET /admin/reports`

Scan paginé (keyset) de la file de modération `hxa_bo.report`. Renvoie les **données brutes**, sans anonymisation : la modération a besoin d'identifier le rapporteur pour les analyses de pattern (utilisateur qui signale en masse, etc.).

**Query params**

| Param        | Valeurs                                                          | Défaut | Notes |
|--------------|------------------------------------------------------------------|--------|-------|
| `status`     | `pending` \| `reviewed` \| `action_taken` \| `dismissed`         | _aucun_ (tous statuts) | Filtre exact. Une valeur inconnue est silencieusement ignorée (équivaut à _aucun_). |
| `targetType` | `media` \| `user` \| `comment`                                   | _aucun_ | Idem. |
| `limit`      | `1..100`                                                         | `50`   | Borné en dur côté serveur. |
| `cursorAt`   | ISO-8601 datetime                                                | _aucun_ | `created_at` du dernier item de la page précédente. À fournir avec `cursorId` (les deux ou aucun). |
| `cursorId`   | hex (32 chars)                                                   | _aucun_ | id du dernier item — discriminant pour les `created_at` identiques. |

Tri implicite : `created_at DESC, id DESC` (plus récents d'abord).

**Réponse (200)**

```jsonc
{
  "items": [
    {
      "id":                "01a3471992e44c60a8f08321f713635a",
      "reporterUserId":    "ff…",
      "targetType":        "media",
      "targetId":          "ab…",
      "reasonCode":        "spam",
      "details":           "…optional free text…",
      "status":            "pending",
      "resolvedByUserId":  null,
      "resolvedAt":        null,
      "resolutionNote":    null,
      "createdAt":         "2026-06-13T12:34:56+00:00",
      "updatedAt":         "2026-06-13T12:34:56+00:00"
    }
  ],
  "nextCursor": { "at": "2026-06-13T12:34:56+00:00", "id": "01a3…" }
  // `null` quand la page courante contient < `limit` items (= dernière page)
}
```

**Exemple curl**

```bash
# Première page de la file
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/reports?status=pending&limit=50"

# Page suivante
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/reports?status=pending&limit=50&cursorAt=2026-06-13T12:34:56%2B00:00&cursorId=01a3471992e44c60a8f08321f713635a"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | une moitié seulement du curseur a été envoyée |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | parsing Carbon KO |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

---

### `GET /admin/reports/{hex}`

Lecture unitaire d'un signalement, en **JSON:API 1.1** (cf. [Convention de format](#convention-de-format--détails-360-en-jsonapi)).

**Réponse (200)** : enveloppe `{ jsonapi, data:{ type:"reports", id, attributes } }`. `data.attributes` reprend les mêmes champs que `items[]` de la liste (`id` remonté au niveau `data.id`, pas dupliqué dans `attributes`) : `reporterUserId`, `targetType`, `targetId`, `reasonCode`, `details`, `status`, `resolvedByUserId`, `resolvedAt`, `resolutionNote`, `createdAt`, `updatedAt`.

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "reports",
    "id": "4f3c1a2b-5d6e-7f80-91a2-b3c4d5e6f700",
    "attributes": {
      "reporterUserId": "…", "targetType": "media", "targetId": "…",
      "reasonCode": "spam", "details": "…", "status": "pending",
      "resolvedByUserId": null, "resolvedAt": null, "resolutionNote": null,
      "createdAt": "2026-06-01T10:00:00+00:00", "updatedAt": "2026-06-01T10:00:00+00:00"
    }
  }
}
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | erreur JSON:API (`errors[].title = "Report not found"`) | row absente ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

---

### `PATCH /admin/reports/{hex}`

Transition du verdict modérateur.

**Body**

```jsonc
{
  "status":         "reviewed" | "action_taken" | "dismissed",
  "resolutionNote": "Optional internal note.",   // optionnel
  "resolvedBy":     "<reporterHex>"                // optionnel — id opérateur
}
```

- `status` ne peut pas valoir `pending` (pas de réouverture supportée dans cette itération).
- `resolvedBy` est optionnel : utile quand un opérateur humain valide via un BO ; à omettre pour un job automatisé (la colonne reste `NULL`).
- `resolutionNote` est trim+filtré (vide ⇒ NULL).

**Idempotence** : un PATCH sur une row déjà résolue retourne 200 avec le verdict existant sans toucher la base — le premier verdict modérateur est canonique.

**Réponse (200)** : la row complète mise à jour (même shape que `GET /admin/reports/{hex}`).

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "status must be one of: reviewed, action_taken, dismissed." }` | enum invalide |
| `400` | `{ "error": "resolvedBy is not a valid hex UUID." }` | hex malformé |
| `400` | `{ "error": "Body must be a JSON object." }` | JSON KO |
| `404` | `{ "error": "Report not found." }` | row absente |
| `403` | `{ "error": "..." }` | auth KO |

**Notes**

- Le verdict n'envoie **aucune** notification au rapporteur ni à la cible — c'est volontaire, la modération reste invisible côté produit. Si un comportement « décision communiquée à l'utilisateur » est requis plus tard, c'est un side-effect à brancher ici (et à documenter).
- Aucune action automatique sur la cible (suppression de média, ban de compte, …) : la modération applique ces gestes hors de Hydrogen via l'admin métier. Cet endpoint ne fait que figer le verdict côté file.

---

### Surface `/admin/users/*` — authentification HYBRIDE & traçabilité

Contrairement au reste de `/admin/*` (token statique seul), **toute** la surface
`/admin/users/*` accepte **deux** types d'identité (cf. `AdminOrStaffAuthenticationMiddleware`) :

- **Token statique** `ADMIN_API_TOKEN` (service-to-service / Talend) → autorisé,
  **acteur anonyme** (« système ») : les colonnes acteur du journal restent `NULL`.
- **Token staff nominatif** (cf. `POST /admin/staff/login`) → autorisé, **acteur
  attribué** : l'opérateur (`staffId` + `staffUsername`) est journalisé.

Toute **mutation** d'un compte (`profile`, `verified`, `ban`, `unban`, `anonymize`,
`avatar.set`/`avatar.delete`, `cover.set`/`cover.delete`)
écrit une ligne dans le journal `hxa_bo.user_action_log`, lisible via
`GET /admin/users/{hex}/history`. Le journal est **fail-soft** : un échec d'écriture
d'audit ne fait jamais échouer l'action admin sous-jacente. Seules les transitions
**effectives** sont journalisées (un no-op idempotent `transition: "none"` n'écrit rien).

> ⚠️ Ce middleware ne prouve que **l'identité**, il n'applique **aucun** rôle minimum
> (RBAC) : n'importe quel staff actif (même consultant) est accepté, exactement comme
> le token statique tout-puissant. Le gating par rôle est un suivi délibéré.

Le champ `reason` (string libre, optionnel) est accepté dans le body de **toutes** les
mutations et journalisé tel quel (tronqué à 500 caractères).

---

### `GET /admin/users`

Scan paginé (keyset) de la table `user` pour **enquêter sur n'importe quel compte sans passer par l'API publique filtrée**. Aucun filtrage de visibilité implicite : les comptes bannis, non confirmés et soft-deleted sont **tous** renvoyés. Données brutes (e-mail, statut, dates internes…) ; seul le hash de mot de passe n'est jamais exposé.

**Query params**

| Param        | Valeurs            | Défaut | Notes |
|--------------|--------------------|--------|-------|
| `confirmed`  | `true` \| `false`  | _aucun_ | `confirmed_at IS [NOT] NULL`. Valeur inconnue = filtre ignoré. |
| `banned`     | `true` \| `false`  | _aucun_ | Ban **actif** (`banned_until > NOW()`), pas la simple présence d'une empreinte. |
| `verified`   | `true` \| `false`  | _aucun_ | `is_verified`. |
| `deleted`    | `true` \| `false`  | _aucun_ | `deleted_at IS [NOT] NULL` (soft-delete RGPD). |
| `limit`      | `1..100`           | `50`   | Borné en dur côté serveur. |
| `cursorAt`   | ISO-8601 datetime  | _aucun_ | `joined_at` du dernier item de la page précédente. À fournir avec `cursorId` (les deux ou aucun). |
| `cursorId`   | hex (32 chars)     | _aucun_ | id du dernier item — discriminant pour les `joined_at` identiques. |

Tri implicite : `joined_at DESC, id DESC` (inscriptions les plus récentes d'abord).

**Réponse (200)**

```jsonc
{
  "items": [
    {
      "id":                 "d26d1600cde54bd095e09f8b68ace05f",
      "username":           "alice",
      "qrcodeUrl":          "https://hexatrip.dev.com/qrcode/alice.png",
      "nickname":           "Alice",
      "email":              "alice@example.com",
      "name":               "Doe",
      "firstname":          "Alice",
      "sex":                1,            // valeur DB brute (INT), pas le slug i18n
      "birthdate":          "1990-05-12",
      "birthplaceCityId":   "ab…",        // hex ou null
      "userType":           0,
      "bio":                "…",
      "status":             0,
      "isVerified":         false,
      "experience":         1250,
      "joinedAt":           "2026-01-02T10:00:00+00:00",
      "confirmedAt":        "2026-01-02T10:05:00+00:00",
      "isConfirmed":        true,
      "bannedUntil":        null,
      "isBanned":           false,        // ban ACTIF dérivé
      "deletedAt":          null,
      "isDeleted":          false,
      "purgedAt":           null,         // estampille d'anonymisation RGPD (irréversible)
      "isPurged":           false,
      "profileCompletedAt": "2026-01-03T09:00:00+00:00",
      "passwordSetAt":      "2026-01-02T10:00:00+00:00",
      "hasPassword":        true,
      "avatarUpdatedAt":    null,
      "hasAvatar":          false,
      "coverUpdatedAt":     null,
      "hasCover":           false,
      "updatedAt":          "2026-06-18T12:00:00+00:00"
    }
  ],
  "nextCursor": { "at": "2026-01-02T10:00:00+00:00", "id": "d26d…" }
  // `null` quand la page courante contient < `limit` items (= dernière page)
}
```

**Exemple curl**

```bash
# Première page (tous les comptes)
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users?limit=50"

# Comptes bannis uniquement
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users?banned=true"

# Page suivante
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users?limit=50&cursorAt=2026-01-02T10:00:00%2B00:00&cursorId=d26d1600cde54bd095e09f8b68ace05f"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | une moitié seulement du curseur a été envoyée |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | parsing Carbon KO |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

---

### `GET /admin/users/banned`

Surface **dédiée** aux comptes dont le **ban est actif** (`banned_until IS NOT NULL AND banned_until > NOW()`, miroir de `User::isBanned()`). Raccourci pratique sur `GET /admin/users` avec le filtre `banned` épinglé à `true` ; le query param `banned` est donc **ignoré** ici. Même pagination keyset, même bornage de `limit` et même enveloppe `{items, nextCursor}` (`AdminUserSerializer`, JSON plat brut).

Les filtres `confirmed`, `verified` et `deleted` restent applicables par-dessus (ex. lister les comptes bannis **et** soft-deleted).

**Query params** : `confirmed`, `verified`, `deleted`, `cursorAt`, `cursorId`, `limit` (`1..100`, défaut `50`). Cf. `GET /admin/users` pour la sémantique. Tri : `joined_at DESC, id DESC`.

**Réponse (200)** — `items[]` strictement identique à `GET /admin/users`.

**Exemple curl**

```bash
# Comptes actuellement bannis
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/banned?limit=50"

# Bannis ET soft-deleted
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/banned?deleted=true"
```

**Erreurs** : identiques à `GET /admin/users` (`400` curseur, `403` auth).

---

### `GET /admin/users/unconfirmed`

Surface **dédiée** aux comptes qui n'ont **jamais confirmé leur e-mail** (`confirmed_at IS NULL`). Raccourci pratique sur `GET /admin/users` avec le filtre `confirmed` épinglé à `false` ; le query param `confirmed` est donc **ignoré** ici. Utile pour relancer ou purger les inscriptions inachevées. Même pagination keyset, même bornage de `limit` et même enveloppe `{items, nextCursor}`.

Les filtres `banned`, `verified` et `deleted` restent applicables par-dessus.

**Query params** : `banned`, `verified`, `deleted`, `cursorAt`, `cursorId`, `limit` (`1..100`, défaut `50`). Cf. `GET /admin/users` pour la sémantique. Tri : `joined_at DESC, id DESC`.

**Réponse (200)** — `items[]` strictement identique à `GET /admin/users`.

**Exemple curl**

```bash
# Comptes jamais confirmés
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/unconfirmed?limit=50"
```

**Erreurs** : identiques à `GET /admin/users` (`400` curseur, `403` auth).

---

### `GET /admin/users/search`

Recherche **full-text** d'un compte via l'index Meilisearch `users` (typo-tolérant), pour l'enquête back-office. **Aucun filtre de visibilité** : la requête Meili ne pose aucun `filter`, donc tout document indexé est éligible. La forme de sortie est **identique** à `GET /admin/users` (`AdminUserSerializer`, JSON plat brut) — Meili ne sert qu'à matcher du texte, on ré-hydrate ensuite les entités depuis **MySQL** (source de vérité).

**⚠️ Limite inhérente à Meili.** Un compte jamais indexé, ou retiré de l'index (ex. anonymisation RGPD qui supprime le document), est **introuvable** ici → utiliser `GET /admin/users` (scan MySQL exhaustif) ou `GET /admin/users/{hex}` (lookup direct) pour ces cas.

**Query params**

| Param    | Valeurs            | Défaut | Notes |
|----------|--------------------|--------|-------|
| `q`      | string             | `""`   | Requête full-text (`username` / `nickname`). Vide = parcours pur. |
| `sort`   | `popular` \| `recent` | _pertinence_ | `popular` → `stats.num_user_follower:desc` ; `recent` → `joined_at:desc` ; sinon ranking Meilisearch. |
| `limit`  | `1..100`           | `50`   | Borné en dur (aligné sur `GET /admin/users`). |
| `offset` | `0+`               | `0`    | Pagination **offset** (≠ keyset de la liste). |

**Réponse (200)** — JSON plat, `items[]` strictement identique à `GET /admin/users` :

```jsonc
{
  "items": [ /* … mêmes champs bruts que items[] de GET /admin/users … */ ],
  "totalHits": 3,        // estimation Meilisearch
  "limit":     50,
  "offset":    0,
  "query":     "alice"
}
```

Les ids présents dans l'index mais absents de MySQL (orphelins) sont silencieusement ignorés ; l'ordre de pertinence Meilisearch est préservé.

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `503` | `{ "error": "Search backend unavailable.", "detail": "…" }` | Meilisearch injoignable |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Recherche par username/nickname (token statique ou staff)
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/search?q=alice&limit=20"

# Tri par popularité, page 2
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/search?q=alice&sort=popular&offset=20"
```

**Notes**

- Diffère du public `GET /api/users/search` qui filtre la visibilité (confirmés/actifs only) et renvoie du JSON:API. Ici : aucun filtre + JSON plat admin.
- La recherche par **e-mail** dépend de ce que le pipeline pousse dans l'index `users` (le champ `email` n'est pas garanti searchable) ; pour une recherche e-mail exacte fiable, préférer un scan `GET /admin/users`.

---

### `GET /admin/users/{hex}`

Lecture **360°** d'un compte unique en **JSON:API 1.1** (cf. [Convention de format](#convention-de-format--détails-360-en-jsonapi)) : **strict superset** du public `GET /api/users/{id}` — même enveloppe et même forme d'attributs (via `UserResourceSerializer`, `sex` en slug i18n, dates riches, stats incluses), enrichie de **tous** les champs internes bruts (via `AdminUserSerializer`) fusionnés dans `data.attributes`. Aucun filtrage de visibilité (un compte banni / non confirmé / soft-deleted est lu normalement) ; seul le hash de mot de passe n'est jamais exposé.

Les champs admin-only back-fillés (sans écraser une clé déjà émise par le serializer public) incluent : `email`, `sex` (INT brut), `status`, `bannedUntil`/`isBanned`, `deletedAt`/`isDeleted`, `purgedAt`/`isPurged`, `passwordSetAt`/`hasPassword`, `avatarUpdatedAt`, `coverUpdatedAt`, `confirmedAt`/`isConfirmed`, `profileCompletedAt`, `joinedAt`, `updatedAt` (cf. `items[]` de `GET /admin/users` pour la liste complète).

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | enveloppe `{ jsonapi, data:{ type:"users", id, attributes } }` (`id` = UUID dashé) | trouvé |
| `404` | erreur JSON:API (`errors[].title = "User not found"`) | row absente ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Accept: application/vnd.api+json" \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f
```

---

### `GET /admin/users/{hex}/address`

Lecture de l'**adresse postale** d'un compte. Endpoint **dédié** : il ne modifie pas `GET /admin/users/{hex}` (carte d'identité civile). Contrairement au reste de la surface détail admin (JSON:API 360°), il suit la convention **JSON plat brut** (dates ISO-8601 nu), car l'adresse est une simple ligne `user_address` (1:1 avec `user`).

Différence avec le self `GET /api/users/me/address` : ce endpoint expose **en plus** les deux colonnes géo `cityId` (FK `city`, hex 32) et `region`, volontairement masquées côté self (en attente de l'UX d'autocomplete ville). Données brutes : **aucune résolution** du nom de ville.

Forme stable : qu'une ligne existe ou non, les **mêmes clés** sont renvoyées. Si l'utilisateur existe mais n'a jamais renseigné d'adresse, toutes les valeurs sont à `null` (statut `200`, pas `404`).

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | objet plat (cf. ci-dessous) | utilisateur trouvé (adresse présente OU vide à null) |
| `404` | `{ "error": "User not found." }` | compte absent ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

Forme du `200` :

```json
{
  "userId":       "d26d1600cde54bd095e09f8b68ace05f",
  "unitNumber":   "4B",
  "streetNumber": "12",
  "addressLine1": "rue des Lilas",
  "addressLine2": null,
  "postalCode":   "75011",
  "cityId":       "0b7c1f2e3a4b5c6d7e8f90a1b2c3d4e5",
  "region":       "IDF",
  "updatedAt":    "2026-06-22T10:00:00+00:00",
  "createdAt":    "2026-06-20T08:30:00+00:00"
}
```

**Exemple curl**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/address
```

---

### `PUT /admin/users/{hex}/verified`

Flippe le drapeau `is_verified` d'un utilisateur (style « badge bleu Twitter »). **Réservé admin** : aucune route `/api/*` n'expose ce drapeau en écriture — un utilisateur ne peut pas s'auto-vérifier.

**Body (JSON)**

```json
{ "isVerified": true }
```

`isVerified` est obligatoire, doit être un booléen strict (`true` ou `false`, pas `"true"` ni `1`).

**Comportement par transition**

| Transition | UPDATE `user` | Side-effects |
|---|---|---|
| `none` (déjà à l'état demandé) | non | aucun |
| `verify` (0 → 1) | oui | aucun (pas de notif, pas de XP) |
| `unverify` (1 → 0) | oui | aucun |

Pas de notification produit côté utilisateur — le drapeau pilote uniquement l'icône côté UI, le contexte (preuve d'identité, etc.) est géré hors Hydrogen.

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase (`user.id` BINARY(16) → hex).

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "userId": "<hex>", "isVerified": true, "transition": "verify" }` | flip 0→1 OK |
| `200` | `{ "status": "ok", "userId": "<hex>", "isVerified": true, "transition": "none" }` | déjà vérifié, no-op idempotent |
| `200` | `{ "status": "ok", "userId": "<hex>", "isVerified": false, "transition": "unverify" }` | drapeau retiré |
| `400` | `{ "error": "Body must be JSON object with 'isVerified' boolean." }` | body mal formé |
| `404` | `{ "error": "User not found." }` | utilisateur absent en DB |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Vérifier un compte
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isVerified": true}' \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/verified

# Retirer la vérification
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isVerified": false}' \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/verified
```

**Notes**

- `isVerified` est exposé en lecture sur **toutes** les ressources `users` (privée et bloc public `author`) — c'est une info publique par construction (un badge se voit).
- Une transition `none` ne touche pas la base — aucun bump `updated_at`, aucun coût.

---

### `POST /admin/users/{hex}/avatar`

Pose / remplace l'**avatar** d'un utilisateur **au nom de l'admin** (modération, support). Réutilise le pipeline self-service (`AvatarUploadService`) : downscale bestfit dans un carré `AVATAR_MAX_DIMENSION` (def 256 px), strip EXIF, ré-encodage WebP, écriture atomique. L'avatar n'est **pas** indexé dans Meili → aucune réindexation.

**Body** — `multipart/form-data`, champ fichier **`avatar`** (obligatoire).

- Formats source acceptés : JPEG / PNG / WEBP / GIF / HEIC / HEIF.
- Taille max : `AVATAR_MAX_UPLOAD_BYTES` (def 256 000 octets).

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase (`user.id` BINARY(16) → hex).

**Query params**
- `reason` (optionnel) : motif libre journalisé dans l'audit.

**Réponses (JSON plat)**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"set", "avatarUpdatedAt":"<ISO8601>", "avatarUrl":"<url>" }` | premier avatar posé |
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"replace", "avatarUpdatedAt":"<ISO8601>", "avatarUrl":"<url>" }` | avatar existant remplacé |
| `422` | `{ "error":"Form field 'avatar' is required (multipart/form-data).", "code":"avatar.empty" }` | champ fichier manquant |
| `422` | `{ "error":"...", "code":"avatar.invalid_image" }` | image illisible / corrompue |
| `413` | `{ "error":"...", "code":"avatar.too_large" }` | dépasse `AVATAR_MAX_UPLOAD_BYTES` |
| `415` | `{ "error":"...", "code":"avatar.unsupported_format" }` | format hors whitelist |
| `400` | `{ "error":"...", "code":"avatar.upload_failed" }` | erreur transport multipart |
| `500` | `{ "error":"...", "code":"avatar.encoding_failed" \| "avatar.storage_write_failed" }` | échec ré-encodage / écriture disque |
| `404` | `{ "error":"User not found." }` | utilisateur absent |

**Audit** — journalise `user.avatar.set` (`changes: { avatar: { from, to } }`, timestamps ISO) à chaque upload réussi.

**Exemple curl**

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -F "avatar=@/path/to/photo.jpg" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/avatar?reason=support%20cleanup"
```

---

### `DELETE /admin/users/{hex}/avatar`

Retire l'avatar d'un utilisateur **au nom de l'admin** (modération d'un avatar inapproprié). L'utilisateur retombe sur l'avatar par défaut partagé. Idempotent.

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Query params**
- `reason` (optionnel) : motif libre journalisé.

**Réponses (JSON plat)**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"removed", "avatarUpdatedAt":null, "avatarUrl":"<default url>" }` | avatar supprimé |
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"none", "avatarUpdatedAt":null, "avatarUrl":"<default url>" }` | déjà sans avatar, no-op |
| `404` | `{ "error":"User not found." }` | utilisateur absent |

**Audit** — journalise `user.avatar.delete` **uniquement** si un avatar existait (`transition:"removed"`). Une suppression no-op (`transition:"none"`) n'écrit aucune entrée.

```bash
curl -X DELETE \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/avatar"
```

---

### `POST /admin/users/{hex}/cover`

Pose / remplace la **bannière (cover)** d'un utilisateur **au nom de l'admin**. Pendant de l'avatar : réutilise `CoverUploadService` (downscale bestfit dans `COVER_MAX_WIDTH × COVER_MAX_HEIGHT`, def 1500 × 500, strip EXIF, WebP, écriture atomique). Non indexé Meili.

**Body** — `multipart/form-data`, champ fichier **`cover`** (obligatoire).

- Formats source acceptés : JPEG / PNG / WEBP / GIF / HEIC / HEIF.
- Taille max : `COVER_MAX_UPLOAD_BYTES` (def 600 000 octets).

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Query params**
- `reason` (optionnel) : motif libre journalisé.

**Réponses (JSON plat)**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"set", "coverUpdatedAt":"<ISO8601>", "coverUrl":"<url>" }` | première cover posée |
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"replace", "coverUpdatedAt":"<ISO8601>", "coverUrl":"<url>" }` | cover existante remplacée |
| `422` | `{ "error":"Form field 'cover' is required (multipart/form-data).", "code":"cover.empty" }` | champ fichier manquant |
| `422` | `{ "error":"...", "code":"cover.invalid_image" }` | image illisible / corrompue |
| `413` | `{ "error":"...", "code":"cover.too_large" }` | dépasse `COVER_MAX_UPLOAD_BYTES` |
| `415` | `{ "error":"...", "code":"cover.unsupported_format" }` | format hors whitelist |
| `400` | `{ "error":"...", "code":"cover.upload_failed" }` | erreur transport multipart |
| `500` | `{ "error":"...", "code":"cover.encoding_failed" \| "cover.storage_write_failed" }` | échec ré-encodage / écriture disque |
| `404` | `{ "error":"User not found." }` | utilisateur absent |

**Audit** — journalise `user.cover.set` à chaque upload réussi.

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -F "cover=@/path/to/banner.jpg" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/cover"
```

---

### `DELETE /admin/users/{hex}/cover`

Retire la cover d'un utilisateur **au nom de l'admin**. Retombe sur la cover par défaut partagée. Idempotent.

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Query params**
- `reason` (optionnel) : motif libre journalisé.

**Réponses (JSON plat)**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"removed", "coverUpdatedAt":null, "coverUrl":"<default url>" }` | cover supprimée |
| `200` | `{ "status":"ok", "userId":"<hex>", "transition":"none", "coverUpdatedAt":null, "coverUrl":"<default url>" }` | déjà sans cover, no-op |
| `404` | `{ "error":"User not found." }` | utilisateur absent |

**Audit** — journalise `user.cover.delete` **uniquement** si une cover existait (`transition:"removed"`).

```bash
curl -X DELETE \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/cover"
```

---

### `PUT /admin/users/{hex}/ban`

Bannit un utilisateur. Le modèle est **temporel** : la colonne `user.banned_until` porte la date de fin de ban ; l'utilisateur est banni tant que ce timestamp est dans le futur (`banned_until > NOW()`). **Réservé admin** : aucune route `/api/*` n'expose ce drapeau en écriture.

**Body (JSON, tous les champs optionnels)**

```json
{ "until": "2026-12-31T00:00:00+00:00" }
```

- `until` = date ISO-8601 de levée du ban → **bannissement temporaire** (doit être strictement dans le futur).
- `until` à `null`, **absent**, ou body vide → **bannissement permanent** (sentinelle `9999-12-31T23:59:59`).

**Side-effects**

- Un `UPDATE user` (`banned_until` + bump `updated_at`).
- **Toutes les sessions actives de l'utilisateur sont révoquées** (`DELETE FROM user_session`), pour que le ban prenne effet immédiatement : le chemin d'authentification par token ne re-vérifie pas `isBanned()` à chaque requête, seul le login le contrôle. Le nombre de sessions supprimées est renvoyé dans `sessionsRevoked`.
- Un **reindex Meili best-effort** de l'utilisateur (`banned_until` est un champ indexé). Best-effort : un incident d'index n'échoue jamais le ban.
- Aucune notif, aucun XP.

**Comportement par transition**

| Transition | Sens | Sessions révoquées |
|---|---|---|
| `ban` | l'utilisateur n'était pas banni (aucun ban actif) | oui |
| `update` | l'utilisateur était déjà banni — la fenêtre est prolongée/raccourcie | oui (souvent 0, plus de session valide) |

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "userId": "<hex>", "isBanned": true, "bannedUntil": "<ISO8601>", "sessionsRevoked": 3, "transition": "ban" }` | ban posé |
| `200` | `{ ..., "transition": "update" }` | ban déjà actif, fenêtre mise à jour |
| `400` | `{ "error": "Body 'until' must be an ISO-8601 datetime string or null." }` | `until` mal typé / JSON invalide |
| `422` | `{ "error": "Ban expiry 'until' must be in the future." }` | `until` dans le passé |
| `404` | `{ "error": "User not found." }` | utilisateur absent en DB |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Ban permanent
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}' \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/ban

# Ban temporaire (7 jours, exemple)
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"until": "2026-06-26T00:00:00+00:00"}' \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/ban
```

**Notes**

- `bannedUntil` et `isBanned` sont exposés en lecture sur les ressources `users` ; sur un login refusé pour ban, l'API renvoie `meta.bannedUntil` pour que le front affiche la date de levée.
- Un compte banni renvoie `404` sur sa page profil publique web `/@username`.

---

### `PUT /admin/users/{hex}/unban`

Lève le ban d'un utilisateur en remettant `user.banned_until` à `NULL`. **Réservé admin**. Pas de body.

**Side-effects** : un seul `UPDATE user` (+ bump `updated_at`) sur transition `unban`, suivi d'un **reindex Meili best-effort** (`banned_until` est indexé). Aucune session n'est restaurée — l'utilisateur devra se reconnecter (ses sessions ont été révoquées lors du ban). Aucune notif, aucun XP. Une transition `none` ne touche ni la base ni l'index.

**Comportement par transition**

| Transition | UPDATE `user` | Sens |
|---|---|---|
| `unban` | oui | une empreinte de ban existait (`banned_until` non `NULL`, active **ou** expirée) → effacée |
| `none` | non | aucune empreinte de ban (`banned_until` déjà `NULL`) → no-op idempotent |

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "userId": "<hex>", "isBanned": false, "transition": "unban" }` | ban levé |
| `200` | `{ "status": "ok", "userId": "<hex>", "isBanned": false, "transition": "none" }` | rien à lever, no-op |
| `404` | `{ "error": "User not found." }` | utilisateur absent en DB |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/unban
```

---

### `POST /admin/users/{hex}/anonymize`

Déclenche **immédiatement** l'anonymisation RGPD d'un compte (« droit à l'effacement »), côté support — **sans attendre la période de grâce**. Réutilise le cœur par-utilisateur du pipeline de purge déjà exécuté en cron (`bin/account-purge.php`).

**⚠️ Irréversible.** Une fois `purged_at` posé, le compte ne peut plus être réactivé (contrairement au soft-delete self-service, réversible par re-login pendant la grâce). Pas de body.

**Side-effects (dans l'ordre)**

1. Révocation de **toutes les sessions actives** (le compte peut encore être vivant — l'anonymisation est directe, hors grâce).
2. Effacement de **tous les médias** de l'utilisateur : fichiers disque + lignes DB + tables annexes + index Meilisearch (un par un via `MediaDeleteService`). Le compteur est renvoyé dans `mediasErased`.
3. Scrub des colonnes PII du `user` (`email`/`username` remplacés par des sentinelles dérivées de l'id, `nickname`/`name`/`firstname`/`bio`/`sex`/`birthdate`/… mis à `NULL`) + estampille `purged_at`.
4. Retrait du document utilisateur de l'index de découverte.
5. Suppression de tout token de suppression résiduel.

**Comportement par transition**

| Transition | Sens | Écritures |
|---|---|---|
| `anonymize` | le compte n'était pas encore purgé → effacé/anonymisé | sessions + médias + scrub user + Meili + tokens |
| `none` | compte déjà anonymisé (`purged_at` non nul) → no-op idempotent | aucune |

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "userId": "<hex>", "mediasErased": 12, "transition": "anonymize" }` | anonymisation effectuée |
| `200` | `{ "status": "ok", "userId": "<hex>", "mediasErased": 0, "transition": "none" }` | déjà anonymisé, no-op |
| `404` | `{ "error": "User not found." }` | row absente ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/anonymize
```

**Notes**

- Diffère du soft-delete self-service (`/api/...` côté utilisateur) qui passe par une **période de grâce** réversible puis une purge cron. Cet endpoint **court-circuite la grâce** : à n'utiliser que sur instruction explicite (demande RGPD vérifiée).
- L'état d'anonymisation est lisible via `purgedAt` / `isPurged` sur `GET /admin/users` et `GET /admin/users/{hex}`.

---

### `PUT /admin/users/{hex}/profile`

Éditeur d'**identité** back-office : corrige les colonnes de profil d'un compte sous l'autorité d'un opérateur. **Réservé admin** : l'auto-édition utilisateur côté `/api/*` passe par d'autres parcours soumis à quarantaine/cooldown — pas par cet endpoint.

**Body** (objet JSON ; **tous les champs optionnels**, seuls les champs présents sont touchés) :

| Champ | Type | Validation |
|---|---|---|
| `username`  | string | normalisé (lowercase+trim), policy (3..32, `[a-z0-9._-]`, bornes, blocklist), **unique**. Appliqué via `changeUsername()` (history préservé) **sans** quarantaine/cooldown self-service. |
| `nickname`  | string \| `null` | normalisé (collapse espaces), policy (1..32, pas de caractères de contrôle). `null` (ou vide) = efface (fallback username). |
| `name`      | string \| `null` | texte libre ; vide ⇒ `null`. |
| `firstname` | string \| `null` | texte libre ; vide ⇒ `null`. |
| `email`     | string | format RFC + ≤ 255 chars, **unique**. |
| `bio`       | string \| `null` | texte libre ; vide ⇒ `null`. |
| `reason`    | string | raison libre journalisée (optionnel). |

**Comportement par transition**

| Transition | Sens | Écritures |
|---|---|---|
| `update` | au moins un champ change effectivement | 1..2 `UPDATE user` + 1 ligne d'audit (`changed` = liste des champs) + 1 reindex Meili best-effort (champs indexés touchés : `username`/`nickname`/`email`/`bio`) |
| `none`   | aucun changement effectif (valeurs identiques) | aucune (ni DB, ni audit, ni reindex) |

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "userId": "<hex>", "transition": "update", "changed": ["username","email"] }` | appliqué |
| `200` | `{ "status": "ok", "userId": "<hex>", "transition": "none", "changed": [] }` | no-op idempotent |
| `400` | `{ "error": "Body must be a JSON object." }` | JSON KO |
| `404` | `{ "error": "User not found." }` | row absente ou hex malformé |
| `409` | `{ "error": "Conflict.", "fields": { "username": ["username.taken"] } }` | `username`/`email` déjà pris |
| `422` | `{ "error": "Validation failed.", "fields": { "<champ>": ["<code>"] } }` | policy/format KO |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Corriger l'e-mail + le nom d'affichage (acteur staff nominatif)
curl -X PUT \
  -H "Authorization: Bearer $STAFF_BEARER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"new@example.com","nickname":"Alice B.","reason":"demande support #4213"}' \
  http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/profile
```

**Notes**

- `username` et `nickname` ne passent **pas** par `updateProfileFields()` : ils ont leurs chemins policy-aware dédiés (`changeUsername()` transactionnel avec `username_history`, `updateNickname()`).
- L'autorité admin **bypasse** volontairement la quarantaine/cooldown du parcours self-service de changement de username (correction/modération immédiate).

---

### `GET /admin/users/{hex}/history`

Trace nominative de **toutes les actions effectuées SUR ce compte** (ban/unban/verified/anonymize/profile/avatar/cover), du plus récent au plus ancien, depuis `hxa_bo.user_action_log`. Pagination keyset par `id` décroissant.

**Query params**

| Param | Valeurs | Défaut | Notes |
|---|---|---|---|
| `limit` | `1..100` | `50` | borné en dur. |
| `before` | entier positif | _aucun_ | `id` de la dernière ligne de la page précédente (keyset). |

**Réponse (200)**

```jsonc
{
  "items": [
    {
      "id":        4821,
      "action":    "user.ban",            // user.ban | user.unban | user.verified.set | user.anonymize | user.profile.update | user.avatar.set | user.avatar.delete | user.cover.set | user.cover.delete
      "staff":     { "id": 7, "username": "moderator_jo" },  // null = action via token statique (« système »)
      "changes":   { "bannedUntil": { "from": null, "to": "9999-12-31T23:59:59+00:00" }, "transition": "ban" },
      "reason":    "spam répété",          // string libre ou null
      "ip":        "203.0.113.7",          // IP lisible (inet_ntop) ou null
      "createdAt": "2026-06-22T14:30:00+00:00"
    }
  ],
  "nextCursor": 4810
  // `null` quand la page courante contient < `limit` items (= dernière page)
}
```

**Path params**
- `hex` : id de l'utilisateur en 32 hex lowercase.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | enveloppe `{ items, nextCursor }` | OK |
| `400` | `{ "error": "before must be a positive integer." }` | curseur malformé |
| `404` | `{ "error": "User not found." }` | row absente ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Première page
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/history?limit=50"

# Page suivante
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/history?before=4810"
```

**Notes**

- `changes` est le **diff effectif** propre à chaque action (forme `{ champ: { from, to } }`, plus quelques métadonnées comme `transition` ou `mediasErased`). Il reflète ce qui a réellement changé, pas le body brut reçu.
- `staff: null` signale une action exécutée avec le **token statique** (Talend/système) ; un objet `{ id, username }` identifie l'opérateur nominatif.

---

### `POST /admin/users/{hex}/reindex`

Re-pousse un utilisateur unique dans l'index Meilisearch `users`, en relisant la DB (`user` + snapshot de compteurs `user_stats`) via [UserIndexService::reindex()](../src/Domain/User/UserIndexService.php). Pendant de `POST /admin/media/{hex}/reindex`, pour réparer une dérive de l'index `users`.

**Note — réindexation automatique inline.** Depuis 2026-06-22, toute mutation d'un champ **indexé** de l'utilisateur déclenche un reindex `UserIndexService::reindex()` **best-effort** (jamais bloquant) à l'endroit de la mutation : changement de `username` ([UsernameChangeService](../src/Domain/User/UsernameChangeService.php) + `PUT /admin/users/{hex}/profile`), de `nickname` (`PUT /api/users/me/nickname` + profil admin), de `email`/`bio` (profil admin), `banned_until` (`PUT /admin/users/{hex}/ban` & `/unban`), `confirmed_at` (confirmation e-mail + reset de mot de passe qui confirme), et `experience`/XP (upload média, paliers de badge, coupon). Cet endpoint reste utile pour **réparer** une dérive (incident Meili au moment de la mutation, ou recoller un enrichissement Talend). Les champs **non indexés** (`name`, `firstname`, `is_verified`) ne déclenchent volontairement aucun reindex.

**⚠️ Merge partiel, pas un remplacement.** Le push utilise `updateDocuments` (MERGE) : seul le **cœur appartenant à MySQL** est réécrit (`username`, `nickname`, `email`, `sex`, `birthdate`, `bio`, `experience`, `status`, `user_type`, `confirmed_at`, `joined_at`, `banned_until`, `profile_complited_at`, `stats`). Les champs **enrichis par Talend** que MySQL ne sait pas reconstruire fidèlement (`birthplace_city.name`, `nationality`, `settings`, `focus`, `badges`, `sponsopship`) sont **laissés intacts** sur le document existant. Un document neuf reçoit donc uniquement le cœur ; les enrichissements seront recollés par le pipeline IA.

Le shape envoyé respecte l'index à l'identique, y compris ses bizarreries : dates en **timestamps UNIX** (pas ISO), `email` imbriqué `{value, domain}`, et la clé mal orthographiée `profile_complited_at` conservée telle quelle.

**Path params**
- `hex` : id de l'utilisateur en 32 hex (format `user.id` BINARY(16) → hex lowercase).

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "reindexed", "userId": "<hex>" }` | document Meili mis à jour (merge) |
| `200` | `{ "status": "removed",   "userId": "<hex>" }` | user absent en DB **ou purgé** → le doc Meili stale est purgé |
| `400` | `{ "error": "Invalid user id." }` | hex mal formé |
| `403` | `{ "error": "..." }` | auth KO |

Un compte anonymisé (`purged_at` non nul) est traité comme absent : `removed` (le tombstone ne doit jamais ressortir dans `/users/search`).

**Exemple curl**

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/reindex"
```

---

### `POST /admin/users/reindex-all`

Backfill complet de l'index Meili `users` par **lots keyset-paginés**. Chaque appel traite UN batch et renvoie le curseur du suivant. Le client (Talend / Postman) boucle jusqu'à `done = true`. Symétrique de `POST /admin/media/reindex-all`.

Pagination par clé primaire BINARY(16) ASC : pas de drift offset, robuste aux insertions concurrentes. Chaque ligne est poussée en merge partiel (mêmes règles que `POST /admin/users/{hex}/reindex`).

**Query params**

| Param | Type | Défaut | Min | Max |
|---|---|---|---|---|
| `cursor` | hex (32 chars) | `null` (début) | — | — |
| `batchSize` | int | `200` | `1` | `1000` |

`cursor` exclu : passer l'id du dernier utilisateur traité par l'appel précédent. Vide ou absent ⇒ on part du début.

**Réponse (200)**

```json
{
  "processed":  199,
  "removed":    1,
  "failed": [
    { "userId": "a1b2…", "error": "Meilisearch: connection refused" }
  ],
  "lastId":     "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "nextCursor": "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "done":       false,
  "totalAll":   8_421,
  "durationMs": 2987
}
```

| Champ | Sens |
|---|---|
| `processed` | utilisateurs indexés avec succès dans ce batch |
| `removed`   | rows absents en DB **ou purgés** dont le doc Meili stale a été purgé |
| `failed`    | liste des erreurs par-utilisateur — **n'interrompt pas le batch** |
| `lastId`    | dernier id parcouru dans le batch (`null` si batch vide) |
| `nextCursor`| à passer en `?cursor=` au prochain appel ; `null` quand `done=true` |
| `done`      | `true` quand le batch a renvoyé moins de rows que demandé → fin du backfill |
| `totalAll`  | `COUNT(*) user` au moment de l'appel — pour reporter une progression côté caller |
| `durationMs`| latence serveur du batch |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid cursor." }` |
| `400` | `{ "error": "Invalid batchSize." }` |
| `403` | `{ "error": "..." }` |

**Pattern d'utilisation (Talend / curl boucle)**

```bash
cursor=""
while : ; do
  resp=$(curl -s -X POST \
    -H "Authorization: Bearer $ADMIN_API_TOKEN" \
    "http://hydrogen.dev.com/admin/users/reindex-all?batchSize=500&cursor=$cursor")
  echo "$resp" | jq '{processed, removed, done, durationMs}'

  done=$(echo "$resp" | jq -r '.done')
  cursor=$(echo "$resp" | jq -r '.nextCursor // empty')

  [ "$done" = "true" ] && break
done
```

---

### `POST /admin/tracking/conversions`

Postback de conversion d'affiliation (notification serveur-à-serveur du réseau d'affiliation, ou rejeu Talend). Brique du modèle de commission : le **clic** est compté côté public via `/go/offer/{id}` (voir `docs/api.md`), la **conversion** (vente confirmée) remonte ici.

**Idempotent** sur `(targetType, externalRef)` : un rejeu du même postback renvoie `outcome: "duplicate"` et ne modifie aucun agrégat (table `tracking_conversion` à clé unique).

**Body (JSON)**

```json
{
  "targetType":  "offer",
  "targetId":    "0f1e2d3c4b5a69788796a5b4c3d2e1f0",
  "externalRef": "ORDER-2026-00042",
  "amount":      12345,
  "commission":  617,
  "currency":    "EUR",
  "occurredAt":  "2026-06-16T10:00:00Z",
  "clickRef":    "0193a1b2c3d4e5f60718293a4b5c6d7e"
}
```

| Champ | Type | Obligatoire | Sens |
|---|---|---|---|
| `targetType` | string | oui | type de cible, valeur de l'enum `TrackingTargetType` (v1 : `offer`) |
| `targetId` | string | oui | id de la cible (offre : 32 hex) |
| `externalRef` | string | oui | id de commande/transaction côté réseau — clé d'idempotence |
| `amount` | int | oui | valeur de la commande en **unités mineures** (centimes) |
| `commission` | int | oui | notre commission en **unités mineures** |
| `currency` | string | oui | code ISO-4217 (3 lettres, normalisé en majuscules) |
| `occurredAt` | string | non | date de l'événement (ISO-8601 ou `Y-m-d H:i:s`) ; défaut = maintenant (UTC). Détermine le bucket `tracking_daily`. |
| `clickRef` | string | non | **subid** renvoyé par le réseau (32 hex). Relie la conversion au clic précis (média + utilisateur) via `tracking_click`. Stocké tel quel ; un `clickRef` inconnu n'est pas une erreur (l'attribution reste best-effort). Résoluble ensuite via `GET /admin/tracking/clicks/{ref}`. |

> **Argent** : tous les montants sont des **entiers en unités mineures** (centimes) — jamais de float. v1 suppose **une seule devise par cible** : `tracking_stats.currency` garde la dernière vue. Une cible facturée dans plusieurs devises mélangerait ses sommes — à scinder en v2 si le besoin apparaît.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "outcome": "recorded" }` | nouvelle conversion enregistrée + agrégats bumpés |
| `200` | `{ "status": "ok", "outcome": "duplicate" }` | `externalRef` déjà connu, no-op idempotent |
| `400` | `{ "error": "<raison>" }` | body mal formé / champ manquant / devise invalide |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"targetType":"offer","targetId":"0f1e2d3c4b5a69788796a5b4c3d2e1f0","externalRef":"ORDER-42","amount":12345,"commission":617,"currency":"EUR"}' \
  http://hydrogen.dev.com/admin/tracking/conversions
```

---

### `GET /admin/tracking/{targetType}/{targetId}`

Agrégat **cumulé** (lifetime) d'une cible : clics, conversions, chiffre d'affaires et commission. Une cible sans activité renvoie `200` avec des compteurs à zéro (pas de `404`) pour un état vide propre côté dashboard.

**Path params**
- `targetType` : valeur de `TrackingTargetType` (v1 : `offer`).
- `targetId` : id de la cible.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "targetType": "offer", "targetId": "<id>", "clicks": 123, "conversions": 4, "revenueAmount": 49380, "commissionAmount": 2469, "currency": "EUR" }` | agrégat (montants en unités mineures) |
| `200` | `{ ..., "clicks": 0, "conversions": 0, "revenueAmount": 0, "commissionAmount": 0, "currency": null }` | cible sans activité |
| `400` | `{ "error": "Unknown targetType." }` | type inconnu |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/tracking/offer/0f1e2d3c4b5a69788796a5b4c3d2e1f0
```

**Notes**
- Les clics ne sont pas comptés ici en direct : ils transitent par un tampon (`tracking_event`) drainé par le worker `bin/tracking-flush.php`. Un clic met donc au plus un tick de worker à apparaître dans cet agrégat.
- Les conversions sont écrites de façon **synchrone** (transaction) par le postback ci-dessus : elles sont immédiatement visibles.

---

### `GET /admin/tracking/clicks/{ref}`

Résout un **subid** d'affiliation (`clickRef`) en son identité : qui a cliqué, depuis quel média, vers quelle cible. Contrepartie admin du subid que le réseau renvoie dans son postback de conversion.

Le clic est minté côté public par `/go/offer/{id}/media/{mediaId}` (voir `docs/api.md`) : un `clickRef` opaque (UUIDv7 en 32 hex) est persisté dans `tracking_click` puis ajouté à l'URL marchande comme paramètre `subid`. **Seul** ce `clickRef` opaque sort du système — aucun `userId`/PII n'est exposé au partenaire.

**Path params**
- `ref` : le `clickRef` opaque (32 hex minuscule).

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | voir ci-dessous | clic résolu |
| `404` | `{ "error": "Click not found." }` | ref mal formé ou inconnu |
| `403` | `{ "error": "..." }` | auth KO |

```json
{
  "clickRef":   "0193a1b2c3d4e5f60718293a4b5c6d7e",
  "targetType": "offer",
  "targetId":   "0f1e2d3c4b5a69788796a5b4c3d2e1f0",
  "mediaId":    "a1b2c3d4e5f600112233445566778899",
  "userId":     "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
  "visitorId":  "0193a1b2c3d4e5f6071829aabbccddee",
  "createdAt":  "2026-06-19T12:00:00+00:00"
}
```

| Champ | Sens |
|---|---|
| `mediaId` | média visité (32 hex) — **toujours présent** (obligatoire à la génération du lien) |
| `userId` | utilisateur connecté au clic (32 hex), `null` si **anonyme** |
| `visitorId` | cookie visiteur longue durée (32 hex) corrélant les clics d'un même invité ; `null` si le cookie est désactivé/non consenti |
| `createdAt` | horodatage du clic (ISO-8601) |

**Notes**
- Le clic est écrit **synchrone** avant le 302 (il doit exister avant qu'une conversion ne puisse le référencer), contrairement au **compteur** de clics par cible qui, lui, est bufferisé.
- Le cookie visiteur anonyme est **désactivé par défaut** (`TRACKING_VISITOR_COOKIE=false`) : à n'activer que derrière le consentement RGPD. Sans lui, les clics anonymes ne sont pas reliés entre eux mais le reste de l'attribution fonctionne.

---

### `POST /admin/coupons`

Crée un coupon et ses récompenses **sans SQL manuel** (remplace le seeding à la main côté BO). Le coupon (`coupon`) et ses lignes `coupon_reward` sont écrits dans **une seule transaction** ⇒ jamais de coupon à moitié configuré.

Le code (`id`) est **sensible à la casse** (la rédemption l'est aussi) et doit matcher `^[A-Za-z0-9._-]{1,32}$`. Un coupon **sans récompense** est autorisé (simple compteur d'inscriptions) mais n'accorde rien à la rédemption.

**Body (JSON)**

| Champ | Type | Requis | Notes |
|---|---|---|---|
| `id` | string | **oui** | code du coupon, 1..32 chars `[A-Za-z0-9._-]`, casse préservée |
| `userLimit` | int ≥ 0 | non (def `0`) | `0` = **illimité** (la colonne est NOT NULL, pas de NULL possible) |
| `start` | ISO-8601 \| null | non | début de validité |
| `end` | ISO-8601 \| null | non | fin de validité, doit être ≥ `start` |
| `rewards` | array | non | liste de `{ rewardId, value, badgeId? }` |
| `rewards[].rewardId` | int | oui (si présent) | doit référencer une ligne du catalogue `reward` (ex. `1`=experience, `2`=point) |
| `rewards[].value` | int > 0 | oui (si présent) | montant accordé |
| `rewards[].badgeId` | string(1..6) \| null | non | badge optionnel attaché à la récompense |

```json
{
  "id": "WELCOME2026",
  "userLimit": 100,
  "start": "2026-01-01T00:00:00+00:00",
  "end": "2026-12-31T23:59:59+00:00",
  "rewards": [
    { "rewardId": 1, "value": 500, "badgeId": null }
  ]
}
```

**Réponse (201)** — voir le shape commun dans `GET /admin/coupons` (un seul objet coupon, `redeemedCount` à `0`).

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Field 'id' must match ^[A-Za-z0-9._-]{1,32}$." }` | code absent/mal formé |
| `400` | `{ "error": "Field 'userLimit' must be an integer >= 0." }` | limite invalide |
| `400` | `{ "error": "Field 'end' must be greater than or equal to 'start'." }` | fenêtre incohérente |
| `400` | `{ "error": "rewards[0].rewardId must reference an existing reward." }` | reward inconnu |
| `409` | `{ "error": "Coupon 'WELCOME2026' already exists." }` | code déjà pris |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"WELCOME2026","userLimit":100,"rewards":[{"rewardId":1,"value":500}]}' \
  http://hydrogen.dev.com/admin/coupons
```

---

### `GET /admin/coupons`

Liste paginée (keyset) des coupons avec leurs **stats d'usage**, en remplacement de la requête SQL que l'opérateur reconstruisait à la main. Pour chaque coupon : configuration, compteur de slots dénormalisé (`userCount`), décompte réel des rédemptions tiré du ledger `coupon_user` (`redeemedCount`) et liste des récompenses. Les récompenses de toute la page sont chargées en **un appel batch**.

> `userCount` (compteur de slots, source de la limite) et `redeemedCount` (lignes réelles de `coupon_user`) peuvent légitimement diverger : les deux sont exposés.

**Query params**

| Param | Valeurs | Défaut | Notes |
|---|---|---|---|
| `limit` | `1..100` | `50` | borné en dur |
| `cursorAt` | ISO-8601 | _aucun_ | `created_at` du dernier item de la page précédente. À fournir avec `cursorId` (les deux ou aucun). |
| `cursorId` | code coupon | _aucun_ | id du dernier item — discriminant pour les `created_at` identiques. |

Tri implicite : `created_at DESC, id DESC`.

**Réponse (200)**

```jsonc
{
  "items": [
    {
      "id":            "WELCOME2026",
      "userLimit":     100,
      "isUnlimited":   false,         // true quand userLimit = 0
      "userCount":     12,            // slots consommés (compteur dénormalisé)
      "redeemedCount": 12,            // lignes réelles du ledger coupon_user
      "remaining":     88,            // null si illimité, sinon max(0, userLimit - userCount)
      "start":         "2026-01-01T00:00:00+00:00",
      "end":           "2026-12-31T23:59:59+00:00",
      "createdAt":     "2026-01-01T09:00:00+00:00",
      "rewards": [
        { "type": "experience", "value": 500, "badgeId": null }
      ]
    }
  ],
  "nextCursor": { "at": "2026-01-01T09:00:00+00:00", "id": "WELCOME2026" }
  // `null` quand la page courante contient < `limit` items (= dernière page)
}
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | une moitié seulement du curseur |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | parsing Carbon KO |
| `403` | `{ "error": "..." }` | auth KO |

---

### `GET /admin/coupons/{id}/redemptions`

Ledger des rédemptions d'**un** coupon (`coupon_user`) : qui l'a consommé et quand, en complément des compteurs agrégés de `GET /admin/coupons`. Sert l'enquête (fraude, double usage).

**Path params**
- `id` : code du coupon (`[A-Za-z0-9._-]{1,32}`, casse préservée).

**Query params**

| Param | Valeurs | Défaut | Notes |
|---|---|---|---|
| `limit` | `1..100` | `50` | borné en dur |
| `cursorAt` | ISO-8601 | _aucun_ | `consumed_at` du dernier item précédent. À fournir avec `cursorUserId` (les deux ou aucun). |
| `cursorUserId` | hex (32 chars) | _aucun_ | id du dernier item — discriminant pour les `consumed_at` identiques. |

Tri implicite : `consumed_at DESC, user_id DESC`.

**Réponse (200)**

```jsonc
{
  "items": [
    { "userId": "d26d1600cde54bd095e09f8b68ace05f", "consumedAt": "2026-01-02T10:00:00+00:00" }
  ],
  "nextCursor": { "at": "2026-01-02T10:00:00+00:00", "userId": "d26d…" }
}
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Coupon not found." }` | code inconnu |
| `400` | `{ "error": "Both cursorAt and cursorUserId must be supplied together." }` | demi-curseur |
| `400` | `{ "error": "cursorUserId is not a valid hex UUID." }` | hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/coupons/WELCOME2026/redemptions
```

---

### `GET /admin/newsletter/subscribers`

Visibilité back-office sur la liste de diffusion (jusqu'ici **aucune**). Renvoie un bloc `counts` (répartition par statut, toujours présent) **et** un export paginé (keyset) filtrable par `status`. Pour récupérer la liste de diffusion réelle, filtrer `?status=confirmed`.

> **Sécurité** : les hash de tokens (confirm / unsubscribe) ne sont **jamais** exposés — ce sont des secrets serveur qui ouvriraient une surface sur les liens un-clic. Lecture seule, via un service dédié (`NewsletterSubscriberDirectory`).

**Query params**

| Param | Valeurs | Défaut | Notes |
|---|---|---|---|
| `status` | `pending` \| `confirmed` \| `unsubscribed` | _tous_ | valeur invalide = `400` (pas d'ignore silencieux) |
| `limit` | `1..500` | `100` | orienté export, borné en dur |
| `cursorAt` | ISO-8601 | _aucun_ | `created_at` du dernier item de la page précédente. À fournir avec `cursorId` (les deux ou aucun). |
| `cursorId` | hex (32 chars) | _aucun_ | id du dernier item — discriminant pour les `created_at` identiques. |

Tri implicite : `created_at DESC, id DESC` (inscriptions les plus récentes). Index dédié `idx_status_created (status, created_at)`.

**Réponse (200)**

```jsonc
{
  "counts": { "pending": 3, "confirmed": 42, "unsubscribed": 5, "total": 50 },
  "items": [
    {
      "id":             "d26d1600cde54bd095e09f8b68ace05f",
      "email":          "jane@example.com",
      "status":         "confirmed",
      "locale":         "fr",
      "source":         "footer",
      "userId":         "ab…",          // hex ou null (inscrit relié à un compte)
      "ipAddress":      "203.0.113.7",   // ou null
      "userAgent":      "Mozilla/5.0 …", // ou null
      "confirmedAt":    "2026-06-18T10:05:00+00:00",
      "unsubscribedAt": null,
      "createdAt":      "2026-06-18T10:00:00+00:00",
      "updatedAt":      "2026-06-18T10:05:00+00:00"
    }
  ],
  "nextCursor": { "at": "2026-06-18T10:00:00+00:00", "id": "d26d…" }
  // `null` quand la page courante contient < `limit` items (= dernière page)
}
```

> `counts` reflète **toute** la table (non filtré par `status`), `items` reflète le filtre courant : un export `?status=confirmed` montre 42 items même si `counts.total` vaut 50.

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Query parameter 'status' must be one of: pending, confirmed, unsubscribed." }` | statut inconnu |
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | demi-curseur |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | parsing Carbon KO |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Compteurs + export de la liste de diffusion confirmée
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/newsletter/subscribers?status=confirmed&limit=500"

# Page suivante
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/newsletter/subscribers?status=confirmed&limit=500&cursorAt=2026-06-18T10:00:00%2B00:00&cursorId=d26d1600cde54bd095e09f8b68ace05f"
```

---

### `POST /admin/notifications`

Pousse une **annonce éditée par l'admin** — soit ciblée sur une liste de destinataires, soit en **broadcast** à tous les utilisateurs. Rejoint le pipeline standard : la ligne est persistée immédiatement, les préférences in-app sont honorées au dispatch, et le cron digest pousse la notification au tick suivant (individuelle ou en digest).

> Le texte est **par locale** et figé tel quel : chaque destinataire est rendu dans **sa** locale (fallback locale par défaut → première fournie). Aucune clé de catalogue : `title`/`body` sont fournis par l'admin et gelés dans `data.translations`.

**Corps (JSON)**

| Champ | Type | Obligatoire | Notes |
|---|---|---|---|
| `translations` | `{ "<locale>": { "title", "body" } }` | oui | locales **supportées** uniquement ; `title`/`body` non vides (title ≤ 150, body ≤ 2000) ; **doit inclure la locale par défaut** `fr-FR` (fallback universel) |
| `url` | string | non | deep-link gelé dans `data.url` (payload push) |
| `userIds` | `["<hex>", …]` | _exactement un mode_ | ciblé, 1..1000 ids |
| `scope` | `"all"` | _exactement un mode_ | broadcast keyset sur tous les users |
| `announcementId` | hex (32) | non | namespace de dedup partagé ; généré et renvoyé si absent |

Fournir **exactement un** de `userIds[]` ou `scope:"all"` (les deux ou aucun = `400`).

**Dedup / idempotence** : tous les destinataires d'une même annonce partagent la clé `system.announcement:<announcementId>`. En broadcast, **renvoyer le `announcementId` retourné** sur chaque appel `?cursor=` suivant pour qu'une page reprise/rejouée ne notifie pas deux fois un même destinataire.

**Query params (broadcast uniquement)**

| Param | Valeurs | Défaut | Notes |
|---|---|---|---|
| `cursor` | hex (32) | _début_ | dernier `lastId` traité par l'appel précédent |
| `batchSize` | `1..1000` | `200` | taille de lot par appel |

**Réponse — ciblé (200)**

```jsonc
{
  "announcementId": "9f1c…",
  "mode":           "targeted",
  "dispatched":     12,                       // lignes persistées
  "skipped":        1,                        // destinataire opt-out in-app
  "failed":         [{ "userId": "ab…", "error": "…" }]
}
```

**Réponse — broadcast (200)** — un lot keyset par appel, boucler jusqu'à `done:true`

```jsonc
{
  "announcementId": "9f1c…",
  "mode":           "broadcast",
  "dispatched":     200,
  "skipped":        0,
  "failed":         [],
  "lastId":         "<hex>",
  "nextCursor":     "<hex>",                  // null quand done
  "done":           false,
  "totalAll":       15234,                    // COUNT(*) users (snapshot)
  "durationMs":     38
}
```

> Comme les autres batches admin, une erreur de dispatch par destinataire **n'avorte pas** la boucle : elle part dans `failed[]`.

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Request body must be a JSON object." }` | corps absent/non-objet |
| `400` | `{ "error": "Provide exactly one of userIds[] or scope:\"all\"." }` | ciblage ambigu |
| `400` | `{ "error": "Invalid cursor." }` / `"Invalid batchSize."` | pagination broadcast KO |
| `422` | `{ "error": "translations must include the default locale \"fr-FR\" (used as fallback)." }` | wording invalide (exemples : locale non supportée, title/body vide, trop long, défaut manquant) |
| `422` | `{ "error": "userIds must be a non-empty array of hex user ids." }` | liste ciblée vide/malformée |
| `403` | `{ "error": "..." }` | auth KO |

**Exemples curl**

```bash
# Ciblé : 2 destinataires, texte bilingue + deep-link
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "translations": {
          "fr-FR": { "title": "Nouveauté", "body": "Découvrez les nouvelles villes." },
          "en-US": { "title": "What'\''s new", "body": "Discover the new cities." }
        },
        "url": "https://hydrogen.app/cities",
        "userIds": ["ab12…", "cd34…"]
      }' \
  http://hydrogen.dev.com/admin/notifications

# Broadcast : 1er appel (announcementId généré et renvoyé)
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "translations": { "fr-FR": { "title": "Maintenance", "body": "Service indisponible 22h-23h." } }, "scope": "all" }' \
  "http://hydrogen.dev.com/admin/notifications?batchSize=500"

# Broadcast : appels suivants — réutiliser announcementId + nextCursor jusqu'à done:true
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "translations": { "fr-FR": { "title": "Maintenance", "body": "Service indisponible 22h-23h." } }, "scope": "all", "announcementId": "9f1c…" }' \
  "http://hydrogen.dev.com/admin/notifications?batchSize=500&cursor=<lastId>"
```

---

## Détails catalogue & géo (JSON:API « 360° »)

Ces endpoints exposent le **détail d'une ressource unique** des domaines adossés à **Meilisearch** (lecture seule, aucune table MySQL côté Hydrogen). Tous suivent la [convention JSON:API 1.1](#convention-de-format--détails-360-en-jsonapi) : **strict superset** du `GET /api/<domaine>/{id}` public — même `type`/`id`/`attributes` (via le `*HitFormatter` partagé) + les mêmes enrichissements (descriptions Markdown, blocs hiérarchiques…) que le public, **plus** tous les champs bruts de l'index Meili rétro-remplis dans `data.attributes` via le trait [`MergesRawHit`](../src/Http/Action/Admin/Support/MergesRawHit.php) (une clé déjà émise par le formatter n'est jamais écrasée).

Robustesse commune : id malformé → `404` (erreur JSON:API) ; index Meili injoignable → `503` ; aucun hit → `404`.

| Endpoint | `type` | `id` (path & `data.id`) | Enrichissements (comme le public) |
|---|---|---|---|
| `GET /admin/establishments/{id}` | `establishments` | 32 hex minuscules | `images` + `description` (assets statiques) |
| `GET /admin/offers/{id}` | `offers` | 32 hex minuscules | — (champs bruts index uniquement) |
| `GET /admin/countries/{code}` | `countries` | code ISO **minuscule** (Meili stocke en MAJ) | `description` (Markdown) |
| `GET /admin/regions/{code}` | `regions` | ISO 3166-2 minuscule | `description` (Markdown) + bloc `country` |
| `GET /admin/subregions/{code}` | `subregions` | ISO 3166-2 minuscule | `description` (Markdown) + blocs `country` + `region` |
| `GET /admin/cities/{id}` | `cities` | UUID **dashé** | blocs `country` + `region` + `subregion` |

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Accept: application/vnd.api+json" \
  "http://hydrogen.dev.com/admin/countries/fr"
```

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "countries",
    "id": "fr",
    "attributes": {
      "name": "France", "slug": "france", "continent": "Europe",
      "description": "# France\n…",
      "…": "+ tous les champs bruts de l'index Meili countries (rétro-remplis)"
    }
  }
}
```

---

## Gestion de l'index pays (`/admin/countries` — écriture)

L'index Meilisearch `countries` **n'a aucune table MySQL** : il EST la source de vérité. Hydrogen expose donc un **CRUD documents** pour piloter directement cet index — réservé admin (`ADMIN_API_TOKEN`), **JSON plat brut**.

**Clé primaire** : code ISO 3166-1 alpha-2, normalisé en **MAJUSCULES** (`FR`, `US`) côté index.

**Asynchronisme** : toute écriture Meili (`addDocuments` / `deleteDocument`) renvoie une *task* `enqueued` — le document n'est consultable qu'une fois la task traitée. La réponse remonte le `taskUid` pour suivi. Réponse commune `202` :

```json
{ "status": "enqueued", "id": "FR", "taskUid": 1465508 }
```

**Forme du document** : le corps est le document pays dans la forme **native** de l'index (`name`, `slug`, `codes`, `stats`, `region`, `status`, `continent`, `timezones`, `continent_id`, `official_name`, `_geo`…). La forme **aplatie** de lecture est aussi acceptée : `latitude`/`longitude` sont repliés dans `_geo {lat,lng}` (sauf si `_geo` est fourni explicitement), et les clés dérivées en lecture (`distanceMeters`, `description`, `_geoDistance`, `_formatted`, `_rankingScore*`, `_matchesPosition`) sont ignorées. La PK `id` est **toujours** forcée depuis le code validé (route ou corps) — un `id` divergent dans le corps est écrasé.

### `POST /admin/countries`

**Création** d'un pays. `id` (alpha-2) et `name` requis dans le corps.

| Status | Body | Sens |
|---|---|---|
| `202` | `{ status, id, taskUid }` | écriture enqueued |
| `400` | `{ "error": "Request body must be a JSON object." }` | JSON invalide |
| `409` | `{ "error": "Country already exists." }` | la PK existe déjà → utiliser `PUT` |
| `422` | `{ "error": "Field 'id' must be a 2-letter ISO code." }` / `"Field 'name' is required."` | validation |
| `503` | `{ "error": "<message Meili>" }` | backend Meili injoignable |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"id":"FR","name":"France","slug":"france","continent":"Europe","latitude":46.2,"longitude":2.2}' \
  "http://hydrogen.dev.com/admin/countries"
```

### `PUT /admin/countries/{code}`

**Remplacement total** d'un pays existant. La PK vient de la route (`{code}`, 2 lettres). `name` requis (remplacement complet, pas de merge partiel).

| Status | Body | Sens |
|---|---|---|
| `202` | `{ status, id, taskUid }` | écriture enqueued |
| `400` | `{ "error": "Request body must be a JSON object." }` | JSON invalide |
| `404` | `{ "error": "Country not found." }` | la PK n'existe pas → utiliser `POST` |
| `422` | `{ "error": "Field 'name' is required." }` | validation |
| `503` | `{ "error": "<message Meili>" }` | backend Meili injoignable |

```bash
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"France","slug":"france","continent":"Europe"}' \
  "http://hydrogen.dev.com/admin/countries/fr"
```

### `DELETE /admin/countries/{code}`

**Suppression** d'un pays. `404` si absent (pas de suppression idempotente silencieuse).

| Status | Body | Sens |
|---|---|---|
| `202` | `{ status, id, taskUid }` | suppression enqueued |
| `404` | `{ "error": "Country not found." }` | la PK n'existe pas |
| `503` | `{ "error": "<message Meili>" }` | backend Meili injoignable |

```bash
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/countries/fr"
```

---

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

Détail d'un social feed en **JSON:API 1.1** (cf. [Convention de format](#convention-de-format--détails-360-en-jsonapi)) : **strict superset** du public `GET /api/social-feeds/{code}` — même enveloppe (`type: "socialFeeds"`, `id` = code Crockford Base32) et même forme d'attributs (via `SocialFeedResourceSerializer`). Deux différences admin :

- Le carrousel n'est **pas filtré aux médias publiés** : l'admin voit **tous** les médias du feed (y compris ceux dépubliés après coup et masqués au partage public).
- Chaque média embarqué est rendu **avec le propriétaire du feed comme viewer**, ce qui fait remonter le bloc de modération owner-only (`flag` / `isRejected`).

`404` (erreur JSON:API) sur code malformé ou feed inconnu.

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Accept: application/vnd.api+json" \
  "http://hydrogen.dev.com/admin/social-feeds/A1B2C3"
```

---

## Back-office staff (`/admin/staff/*`)

Surface **distincte** du reste de `/admin/*`. Là où les endpoints ci-dessus visent des appels service-to-service (Talend, Postman) authentifiés par un **token statique** unique (`ADMIN_API_TOKEN`), le back-office vise des **opérateurs humains** : chacun se connecte avec identifiant + mot de passe et reçoit un **token bearer nominatif** (révocable, à durée limitée). Les deux surfaces coexistent et n'utilisent pas le même token.

### Niveaux de staff

Trois rôles, ordonnés par privilège (un `≥` numérique exprime « au moins ce niveau ») :

| Rôle | slug | Accès |
|---|---|---|
| Consultant | `consultant` | lecture seule de certaines stats |
| Modérateur | `moderator` | + actions de modération (accès limité) |
| Admin | `admin` | **accès absolu**, dont la gestion du staff |

Stockage : colonne `staff.role` (TINYINT : `1`=consultant, `2`=moderator, `3`=admin). L'API ne parle que de slugs. Les comptes pré-existants à la migration sont promus **admin** (fondateurs) ; tout nouveau compte est **consultant** par défaut (moindre privilège).

### Authentification

```bash
# 1. Login → récupérer le token
curl -s -X POST https://host/admin/staff/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"lbenin","password":"********"}'
# ⇒ { "token": "…", "expiresAt": "2026-…", "staff": { … } }

# 2. Porter le token sur les appels suivants
curl -s https://host/admin/staff/me -H "Authorization: Bearer <token>"
```

Durée de session : `STAFF_TOKEN_LIFETIME_HOURS` (défaut **12 h**), glissante (prolongée à chaque requête authentifiée). Seul le **hash SHA-256** du token est stocké (`hxa_bo.staff_token`) — le token en clair n'est montré qu'une fois, au login. Un échec d'auth renvoie `401` (et non `403`) : c'est une surface de login humaine, le client doit se ré-authentifier. Toutes les raisons d'échec sont confondues (pas d'oracle).

### Endpoints

| Méthode | Chemin | Rôle requis | Description |
|---|---|---|---|
| `POST` | `/admin/staff/login` | — (public) | Échange identifiants → token bearer |
| `POST` | `/admin/staff/logout` | tout staff | Révoque la session courante (204) |
| `GET` | `/admin/staff/me` | tout staff | Profil de l'opérateur courant (dont son rôle) |
| `GET` | `/admin/staff` | admin | Liste des opérateurs (keyset `?after`/`?limit`) |
| `POST` | `/admin/staff` | admin | Crée un opérateur (201) |
| `GET` | `/admin/staff/{id}` | admin | Détail d'un opérateur |
| `PUT` | `/admin/staff/{id}` | admin | Met à jour (partiel ; `password` optionnel) |
| `DELETE` | `/admin/staff/{id}` | admin | Supprime (204) |

**Corps de `POST /admin/staff`** :

```json
{
  "username":  "jdupont",
  "email":     "j.dupont@example.com",
  "password":  "Sup3r!Secret",
  "role":      "moderator",
  "name":      "DUPONT",
  "firstname": "Jean",
  "isActive":  true
}
```

`username` (3-20, `[A-Za-z0-9._-]`), `email`, `password` (politique mot de passe : ≥ 12 caractères, casses + chiffre + spécial) et `role` sont requis ; `name`/`firstname`/`isActive` optionnels. `PUT` accepte les mêmes champs, tous optionnels (seuls les champs présents changent ; `password` non vide effectue une rotation).

**Format de `GET /admin/staff/{id}`** : à la différence des listes/mutations staff (JSON plat), le **détail** d'un opérateur suit la [convention JSON:API 1.1](#convention-de-format--détails-360-en-jsonapi) : enveloppe `{ jsonapi, data:{ type:"staff", id, attributes } }` (`id` = entier `staff.id`, remonté au niveau `data.id` ; tous les champs `StaffSerializer` sauf `id` dans `attributes` ; `role` en slug ; hash jamais sérialisé). `404` en erreur JSON:API.

**Garde anti-lockout** : impossible de **rétrograder, désactiver ou supprimer le dernier admin actif** (`422`) — il reste toujours au moins un opérateur capable de gérer le staff.

Erreurs : `400` corps malformé / champ requis manquant ; `401` non authentifié ; `403` rôle insuffisant ; `404` id inconnu ; `422` règle métier (unicité username/email, mot de passe faible, rôle inconnu, lockout). Le hash du mot de passe n'est **jamais** sérialisé.

### Journal des actions staff

« Toutes les actions du staff sont loguées. » Chaque requête **mutante** sur `/admin/staff/*` est tracée dans `hxa_bo.staff_action_log` par [StaffActionLogMiddleware](../src/Http/Middleware/StaffActionLogMiddleware.php) (monté au niveau du groupe — aucune action à modifier). Le login enregistre en plus ses propres événements `staff.login` / `staff.login.failed` (avec l'identifiant tenté, pour voir le brute-force). Contrairement à l'audit `/admin/*` (SQLite, empreinte de token anonyme), ce journal est **nominatif** : il référence le `staff_id` et dénormalise le `username` (la trace survit à la suppression du compte). Table distincte du `staff_log` historique (un diff d'état du compte staff lui-même).

| Colonne | Type | Description |
|---|---|---|
| `id` | BIGINT PK | auto-incrément |
| `staff_id` | INT | acteur (`null` = tentative non authentifiée, ex. login raté) |
| `username` | VARCHAR | identifiant dénormalisé de l'acteur |
| `action` | VARCHAR | label logique (`staff.login`, `staff.request`, …) |
| `method` / `path` / `status` | | requête HTTP + statut final |
| `metadata` | JSON | contexte optionnel |
| `ip_address` / `user_agent` | | provenance |
| `created_at` | DATETIME | |

> **Migration à jouer** : `database/migrations/2026_06_19_160000_refactor_staff_for_back_office.sql` (ajoute `staff.role`/timestamps, crée `staff_token` + `staff_action_log`). À appliquer avant utilisation.

---

## Erreurs : modèle global

| Status | Quand |
|---|---|
| `400` | validation d'input (cursor / batchSize / hex) |
| `403` | auth KO ou middleware fail-closed (env vide) |
| `404` | endpoint admin inconnu — `{ "error": "Admin endpoint not found." }` |
| `405` | mauvaise méthode — `{ "error": "Method not allowed." }` |
| `500` | exception non-gérée — `{ "error": "Internal server error." }` (voir logs serveur pour la stack) |

Tout `/admin/*` renvoie systématiquement du JSON plat `{ "error": "…" }` (y compris pour les 404/405/500 levés par le routeur Slim avant qu'un handler ne soit atteint). Implémentation : [AdminErrorHandler](../src/Http/Admin/AdminErrorHandler.php) routé via [Kernel::configureErrorHandlers()](../src/Http/Kernel.php).

Notamment, **les erreurs par-média dans le batch ne sont pas un 500** : elles partent dans `failed[]` et la boucle continue. C'est délibéré — un caller Talend ne doit pas avorter sur un seul media corrompu.

---

## Journal d'audit

Toute action **mutante** (`POST` / `PUT` / `PATCH` / `DELETE`) sur `/admin/*` est journalisée automatiquement par [AdminAuditMiddleware](../src/Http/Middleware/AdminAuditMiddleware.php), monté au niveau du **groupe** admin — aucune action n'a besoin d'être modifiée. Les `GET` (lectures : stats, santé, listes) ne sont **pas** tracés pour ne pas noyer le signal.

Le middleware enveloppe toute la pile : il observe le **statut final**, y compris les `403` d'auth refusée (un essai non autorisé est aussi un événement de sécurité conservé).

**Stockage** : base **SQLite locale** `var/admin_audit.sqlite` (hors MySQL), via [AdminAuditLogger](../src/Infrastructure/Audit/AdminAuditLogger.php). Choix délibéré : zéro dépendance, fichier unique archivable, **requêtable** (contrairement à un log plat), et fonctionne même si MySQL est injoignable. Écriture **best-effort** : un échec d'audit n'interrompt jamais l'action admin (erreur envoyée dans `error_log`).

**Schéma** (table `admin_audit`, créée automatiquement au premier write) :

| Colonne | Type | Description |
|---|---|---|
| `id` | INTEGER PK | auto-incrément |
| `created_at` | TEXT | ISO 8601 |
| `method` | TEXT | `POST` / `PUT` / `PATCH` / `DELETE` |
| `path` | TEXT | chemin appelé (ex. `/admin/media/{hex}/published`) |
| `query` | TEXT | query string (ou `null`) |
| `status` | INTEGER | statut HTTP final (inclut les `403`) |
| `ip` | TEXT | `REMOTE_ADDR` (pas de `X-Forwarded-For`) |
| `token_fp` | TEXT | **empreinte** du token (12 hex de SHA-256), jamais le token brut — `null` si absent |
| `user_agent` | TEXT | en-tête `User-Agent` (ou `null`) |

Le `token_fp` permet de corréler les actions d'un même appelant sans stocker le secret. Quand des tokens par opérateur existeront, l'empreinte pointera vers un opérateur nommé.

**Consulter le journal** : via l'API, l'endpoint [`GET /admin/audit`](#get-adminaudit) (filtres + pagination keyset). Ou en lecture directe SQLite (ex. les 20 dernières actions) :

```bash
sqlite3 var/admin_audit.sqlite \
  "SELECT created_at, method, path, status, ip, token_fp FROM admin_audit ORDER BY id DESC LIMIT 20;"
```

---

## Sécurité opérationnelle

- **Renouveler le token** si soupçon de fuite (commit, copie dans un canal Slack, etc.) : modifier `ADMIN_API_TOKEN` et redéployer suffit, il n'y a aucun état en DB.
- **Allowlist IP** au reverse-proxy (nginx, Caddy) sur le chemin `/admin/` : recommandé en prod, à coupler avec le token. Le token seul est OK pour un dev local.
- **Logs HTTP** : nginx/Caddy logguent déjà la route et l'IP. Le token n'apparaît **jamais** dans les logs car il est lu depuis le header `Authorization` que les access logs n'enregistrent pas par défaut.
- **Pas de session, pas de XP, pas de notif** : un appel admin ne génère aucun side-effect métier au-delà de la tâche demandée (indexation Meili, dans le cas présent).

---

## Endpoints à venir (placeholders)

À ajouter dans la même surface quand le besoin se présente.

Chaque ajout suit la même convention :
1. Action sous `src/Http/Action/Admin/<domaine>/`.
2. Route dans le groupe `/admin` de [config/routes.php](../config/routes.php), middleware `[AdminAuthenticationMiddleware::class]`.
3. Réponse JSON plate.
4. Section dans ce fichier.
